Ich habe Supabase durch Convex ersetzt und es kostenlos selbst gehostet. Hier ist die komplette Anleitung.

8 min read

12 Docker-Container für Supabase, einer für Convex — und null Bandbreiten-Limits beim Self-Hosting. Komplette Migrations-Anleitung inklusive.


Ich war gerade dabei, eine Coding-Session zu beenden und wollte mir eine Piña Colada auf der Terrasse gönnen. Dann: ein Convex-Alert. Usage-Limit fast erreicht.

Meine Lust auf Alkohol war sofort weg.

Erste Reaktion: „Kann nicht sein. Es ist der 3. Das Kontingent wurde vor zwei Tagen zurückgesetzt." Zweite: herausfinden, welches Projekt der Übeltäter war. Dritte: die Queries anschauen. Und da war es — db.query("metrics").collect() überall. Full Table Scan bei jeder Mutation. 12.500 Zeilen. Vier reaktive Queries, die in einer Endlosschleife liefen, jedes Mal wenn sich ein einziges Dokument änderte.

Ich hatte diesen Code geschrieben. Na ja, Claude Code hatte ihn geschrieben. Läuft aufs Gleiche hinaus.

Das hatte mir schon länger im Kopf herumgespukt — Convex selbst zu hosten. Ich hatte bereits verifiziert, dass es möglich war. Ich wusste nur nicht wie. An diesem Abend hatte ich meine Antwort auf das Warum.


TL;DR: Supabase Self-Hosting braucht 12 Docker-Container und mindestens 8 GB RAM (nicht machbar auf einem geteilten VPS). Convex ist sauberer zu hosten: ein Container, gleiche API, unbegrenzte Bandbreite. Der einzige echte Stolperstein ist Reverse-Proxy Multi-Port-Routing. Hier ist die komplette Anleitung, inklusive dem Migrations-Prompt, der den Zwei-Zeilen-Fehler verhindert, den ich gemacht habe.

Technischer Ingenieur vergleicht Cloud-Infrastruktur-Lösungen mit Comic-Strip-Visualisierung
Wenn deine Infrastruktur von Chaos zu Zen in einem einzigen Deployment wechselt


Warum ich Supabase verlassen habe (Die Kurzversion)

Diese Geschichte fängt eigentlich früher an. Vor Convex, vor dem Bandbreiten-Alert — da war ein Supabase-Problem.

Supabase Cloud fraß Marge, die ich nicht machte. Also tat ich, was jeder vernünftige Dev tut: Ich schaute mir Self-Hosting an. Kostenlos, oder? Einfach die Container hochfahren.

Einen Tag später. Zwölf Container konfiguriert, Traefik routete endlich nach drei Fehlstarts zu den richtigen Services, Kong stritt sich mit jeder env var, die ich ihm hinwarf, das Inter-Service-Netzwerk machte sein eigenes Ding aus Gründen, deren Debugging zwei Stunden dauerte. Mein VPS schwitzte. Aber es lief.

Dann führte ich docker ps aus.

supabase-db. supabase-studio. supabase-kong. supabase-auth. supabase-storage. supabase-realtime. supabase-meta. supabase-imgproxy. supabase-vector.

Zwölf Container. Für ein Backend. Kong allein kann unter Last bei 2,5 GB liegen. Minimum 8 GB RAM für Production empfohlen. Ich habe einen VPS, der fünf andere Services laufen hat. Das ist ein Nein.

(Man kann technisch ungenutzte Services deaktivieren, um den Ressourcenverbrauch zu reduzieren. Aber dann wartest du einen partiellen, angepassten Stack auf einem Server, den du dir mit allem anderen teilst. Volle Kontrolle, sagen sie.)

Ich brauchte etwas Leichteres. So fand ich Convex und entdeckte eine Reihe von Vorteilen, die ich nicht erwartet hatte. Wenn du das ganze Bild willst, habe ich erklärt warum Convex mein Standard-Stack für das Bauen von KI-powered SaaS mit Claude Code wurde. Kurzversion: alles in TypeScript, reaktive Queries standardmäßig, Auto-Migrations beim Deploy. Und Claude Code generiert Code, der tatsächlich beim ersten Mal kompiliert, weil es in einer Sprache über den ganzen Stack arbeitet, anstatt SQL-Migrations, Deno Edge Functions und TypeScript Frontend in separaten Kontexten zu jonglieren.

Eine Sorge hatte ich: Vendor Lock-in. Convex wurde im Februar 2025 Open Source. Diese Sorge löste sich in Luft auf.

Also migrierte ich. Alles funktionierte. Und dann, drei Monate später, hätte ich fast das 1 GB/Tag Bandbreiten-Limit erreicht.


Der Weckruf: 821 MB von 1 GB

Das Problem waren die Queries, die ich geschrieben hatte. (Ich meine, du weißt schon wer... 😉)

db.query("metrics").collect() ist ein Full Table Scan. Jede Zeile, jedes Mal. Auf einer 12.500-Zeilen-Tabelle. Und weil Convex reaktive Queries neu ausführen, wann immer sich irgendein Dokument in der abgefragten Tabelle ändert, liefen diese nicht einmal. Sie liefen konstant.

Vier davon: getTopContents scannte alle Metriken, um nach Content zu gruppieren, crossPlatformMetrics aggregierte über Plattformen, Velocity-Queries scannten die ganze Tabelle für Sparklines, mediumOverview las alle Verteilungen für Follower-Counts.

Am 3. März verbrauchte diese Kombination 821 MB meiner 1 GB Tagesquote. Bis zum Abend 💀

Bevor du sagst „füg einfach Indizes hinzu" — ich weiß. Da bin ich hingekommen. Aber die Geschichte endet nicht dort.


Die schnellen Fixes (Mach das zuerst)

Der 80/20-Move, bevor du überhaupt an Self-Hosting denkst: repariere die Queries.

Füge Indizes zu deinem Schema hinzu:

metrics: defineTable({
  content_id: v.id("contents"),
  platform_name: v.string(),
  period: v.string(),
})
  .index("by_period", ["period"])
  .index("by_platform_period", ["platform_name", "period"])
  .index("by_content_id", ["content_id"]),

Filtere nach Periode anstatt alles zu scannen:

// Vorher: liest jede Zeile in der Tabelle
const allMetrics = await ctx.db.query("metrics").collect();

// Nachher: liest nur aktuellen Monat
const currentPeriod = new Date().toISOString().slice(0, 7); // "2026-03"
const metrics = await ctx.db
  .query("metrics")
  .withIndex("by_period", (q) => q.eq("period", currentPeriod))
  .collect();

Begrenze Sparkline-Queries auf aktuelle Perioden. Wenn du ein 6-Monats-Chart zeichnest, baue die Liste der Perioden-Strings, die du brauchst, und frage jeden einzeln mit einem Index ab. Lies nicht drei Jahre Daten für sechs Datenpunkte.

Diese drei Änderungen reduzierten meine Bandbreite um etwa 80%. Claude Code hatte die ursprünglichen Queries geschrieben — schnell zu generieren, null Indizes, funktioniert gut bis es nicht mehr funktioniert. Die Fixes dauerten etwa eine Stunde.

Aber die 1 GB Obergrenze ist immer noch da. Jedes neue Feature knabbert daran. Jeder importierte Artikel, jeder Real-Time Dashboard Refresh. Die Quote ist strukturell. Optimierung kauft Zeit.

Self-Hosting entfernt die Obergrenze.


Die Entscheidung: Self-Host oder Cloud?

Wenn du bereits einen Server mit Docker laufen hast, kostet Self-Hosting von Convex exakt 0€ extra.

Ein Container. Gleiche CLI, gleiche Client-Libraries, gleiche API. Du zeigst deine App auf eine andere URL und der Rest deines Codes ändert sich nicht. Das Backend ist Open Source. Du besitzt es.

Das macht Sinn, wenn du bereits Docker und einen Reverse Proxy konfiguriert hast, wenn du dich Bandbreiten-Limits näherst, oder wenn du volle Datenhoheit bei einem Projekt willst, wo Managed SLAs egal sind.

Es macht keinen Sinn, wenn du einen Server nur dafür mieten müsstest — der Cloud-Tier ist billiger als du denkst. Genauso wenn du Convex Auth oder CDN File Storage nutzt, beides gibt es in der Self-Hosted Version noch nicht. Und wenn Traefik um 4 Uhr morgens zu debuggen wie ein schlechter Abend klingt, bleib bei Cloud. Ich sage das ohne Urteil.

Meine Situation: Ich habe bereits einen dedizierten Server mit Traefik, n8n und mehreren anderen Services. Einen Container hinzuzufügen kostet nichts. Die Rechnung war offensichtlich.


Warum zwei Subdomains

Convex Self-Hosted exponiert zwei Ports von einem einzigen Container. Port 3210 für das Backend — Queries, Mutations, WebSocket. Port 3211 für HTTP Actions — deine REST-Endpunkte.

Zwei Ports, zwei Subdomains:

convex.yourdomain.com        → Port 3210 (Backend + WebSocket)
convex-http.yourdomain.com   → Port 3211 (HTTP Actions)

Diese Architektur ist auch die Grundursache des einzigen echten Problems, auf das ich gestoßen bin.


Die Traefik-Falle

Kurzer Kontext, falls du Traefik noch nie benutzt hast: Es ist ein Reverse Proxy, der vor deinen Docker-Containern sitzt und eingehenden Traffic zum richtigen Service routet. Das Schöne ist, dass es sich selbst konfiguriert, indem es Labels auf deinen Containern liest — keine Config-Datei zu warten. Die meisten Self-Hosting-Tutorials nutzen es. Es funktioniert großartig.

Bis ein Container zwei Ports exponiert.

04:31:36 CET. Erstes docker compose up -d. Traefik-Logs, sofort:

ERR Router convex-actions cannot be linked automatically
  with multiple Services: ["convex-actions" "convex-backend"]
ERR Router convex-backend cannot be linked automatically
  with multiple Services: ["convex-actions" "convex-backend"]

Der Convex-Container hatte noch nicht mal fertig gestartet. Traefik verweigerte bereits das Routing.

Meine anfänglichen Labels sahen so aus:

labels:
  - traefik.enable=true
  - traefik.http.routers.convex-backend.rule=Host(`convex.yourdomain.com`)
  - traefik.http.routers.convex-backend.entrypoints=websecure
  - traefik.http.routers.convex-backend.tls.certresolver=letsencrypt
  - traefik.http.services.convex-backend.loadbalancer.server.port=3210
  - traefik.http.routers.convex-actions.rule=Host(`convex-http.yourdomain.com`)
  - traefik.http.routers.convex-actions.entrypoints=websecure
  - traefik.http.routers.convex-actions.tls.certresolver=letsencrypt
  - traefik.http.services.convex-actions.loadbalancer.server.port=3211

Sieht richtig aus. Ist es nicht.

Wenn ein einziger Docker-Container mehrere Traefik-Services deklariert, bricht Auto-Linking. Traefik sieht zwei Services und weiß nicht, welcher Router zu welchem gehört. In jedem Tutorial, das du je gelesen hast, gibt es einen Container und einen Port — also ist das Mapping implizit und funktioniert. Mit zwei Ports musst du es explizit machen. Zwei Zeilen:

labels:
  - traefik.enable=true
  - traefik.http.routers.convex-backend.rule=Host(`convex.yourdomain.com`)
  - traefik.http.routers.convex-backend.entrypoints=websecure
  - traefik.http.routers.convex-backend.tls.certresolver=letsencrypt
  - traefik.http.routers.convex-backend.service=convex-backend       # ← das hier
  - traefik.http.services.convex-backend.loadbalancer.server.port=3210
  - traefik.http.routers.convex-actions.rule=Host(`convex-http.yourdomain.com`)
  - traefik.http.routers.convex-actions.entrypoints=websecure
  - traefik.http.routers.convex-actions.tls.certresolver=letsencrypt
  - traefik.http.routers.convex-actions.service=convex-actions       # ← das hier
  - traefik.http.services.convex-actions.loadbalancer.server.port=3211

Zwei Minuten zwischen dem Fehler und dem Fix. Die Regel: wann immer du mehrere Ports von einem Container durch Traefiks Docker Provider routest, füge explizite service= Labels hinzu. Jedes Mal, keine Ausnahmen.

Eine Notiz zu Cloudflare: Ich laufe Cloudflare vor meinen Domains mit deaktiviertem Proxy (DNS-only, graue Wolke) für diese Subdomains. Let's Encrypt muss deinen Server direkt erreichen, um Zertifikate auszustellen. Wenn du die orange Wolke aktiv lässt, bekommst du einen SSL-Konflikt, den du 20 Minuten der falschen Schicht zuschreibst — DNS, dann Traefik, dann Convex. Deaktiviere den Proxy für die Migration.

Dieser Server läuft bereits mehrere Self-Hosted Services, inklusive der Infrastruktur hinter dem Wiederaufbau meines AI Agent Setups von Grund auf, nachdem eine API-Deprecation mich dazu zwang. Die Philosophie ist die gleiche: besitze den Stack, entferne die wiederkehrenden Kosten, behalte die Developer Experience intakt.


Der Prompt, der all das verhindert

Wenn du Claude Code nutzt, um die docker-compose zu generieren, gib ihm diesen Kontext vorab:

Self-host Convex (ghcr.io/get-convex/convex-backend) hinter Traefik.
Einzelner Container, ZWEI Ports: 3210 (Backend + WebSocket) und 3211 (HTTP Actions).
Jeder Port braucht seine eigene Subdomain mit SSL.
Traefik Docker Provider mit Let's Encrypt bereits konfiguriert.

Vier Zeilen. Das ist der Kontext, der das explizite service= Pattern triggert. Ohne ihn defaultet jede LLM zur Standard-Tutorial-Annahme: ein Container, ein Port, implizites Mapping. Funktioniert großartig bis du zwei Ports hast.

Es wird trotzdem andere Sachen halluzinieren. Claude Code macht das immer. Aber wenigstens nicht diese spezielle.


Die Migration (Der Teil, den ich kaum gemacht habe)

Ich fragte Claude Code: „Können wir mein Convex Cloud Projekt zur Self-Hosted Instanz migrieren?"

Ich hatte kaum den Docs-Tab geöffnet, um herauszufinden, wo ich anfangen sollte. Claude Code hatte sie bereits gelesen. „Checking prerequisites." Dann: Verbindung zum Server via SSH, Lesen der existierenden docker-compose, Image pullen, Admin Key generieren. Ich schaute zu. Das war mein Job — einem Terminal zuschauen, das schneller scrollte als ich lesen konnte, während mein AI-Praktikant eine Aufgabe demolierte, für die ich mental zwei Stunden budgetiert hatte.

Fünfzehn Minuten später sagte es mir, ich solle zwei DNS-Records updaten. Tat ich. Fertig.

(Zur Klarstellung: Ich habe überwacht. Sehr aufmerksam. Von meinem Stuhl aus.)

Die Sequenz, für die Neugierigen: mit dem Container laufend und SSL-Zertifikaten ausgestellt — Let's Encrypt dauert etwa 15 Sekunden, der erste curl gibt Exit Code 60 zurück, keine Panik — Migration sind fünf Schritte.

Generiere den Admin Key:

docker compose exec backend ./generate_admin_key.sh

Beginnt mit convex-self-hosted|. Speichere ihn, committe ihn nie.

Erstelle .env.self-hosted in deinem Projekt-Root:

CONVEX_SELF_HOSTED_URL=https://convex.yourdomain.com
CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|your-key-here

Jeder npx convex Befehl braucht ab jetzt --env-file .env.self-hosted. Ohne das zielt die CLI stillschweigend auf die Cloud. Leicht zu vergessen, gefährlich zu verpassen.

Exportiere zuerst von Cloud:

npx convex export --path convex-backup.zip

Das ist dein Rollback. Mach das vor allem anderen.

Deploy Schema, dann importiere — in dieser Reihenfolge:

npx convex deploy --env-file .env.self-hosted --yes
npx convex import --env-file .env.self-hosted convex-backup.zip

Schema zuerst erstellt die Tabellen und Indizes. Import braucht sie, um zu existieren. Tausche die Reihenfolge und der Import schlägt fehl.

Setze Environment Variables:

npx convex env list                                                    # liste Cloud vars zuerst
npx convex env set --env-file .env.self-hosted YOUR_KEY "value"

Zwei Sachen, die Leute erwischen: NEXT_PUBLIC_* Variablen in Next.js werden zur Build-Zeit eingebacken. Sie in Vercel zu ändern macht nichts bis du redeployest. Das Dashboard sieht updated aus. Die App redet immer noch mit der Cloud. Redeploy.

Und wenn du HTTP Actions Consumer hast — eine statische Site, externe Webhooks — brauchen sie die HTTP Actions URL separat updated. Backend funktioniert heißt nicht Actions funktionieren. Anderer Port, andere Subdomain, teste beide.


Backups: Jetzt dein Problem

#!/bin/bash
BACKUP_DIR="/path/to/backups"
DATE=$(date +%Y%m%d-%H%M)
VOLUME_NAME="your-project_convex_data"  # check: docker volume ls

mkdir -p "$BACKUP_DIR"
docker run --rm \
  -v "${VOLUME_NAME}:/data:ro" \
  -v "${BACKUP_DIR}:/backup" \
  alpine tar czf "/backup/convex-${DATE}.tar.gz" -C / data

find "$BACKUP_DIR" -name "convex-*.tar.gz" -mtime +30 -delete

Zu Cron hinzufügen, täglich um 3 Uhr. Der Volume-Name enthält dein Projekt-Verzeichnis als Prefix — check mit docker volume ls wenn du unsicher bist.

Halte deine Convex Cloud Instanz 48 Stunden nach Migration am Leben. Deine Cloud-Daten wurden nie modifiziert. Rollback ist eine env var Änderung und ein Redeploy. Drei Minuten.


Ergebnisse

Latenz: Self-Hosted auf einem OVH-Server in Frankreich läuft bei etwa 461ms Round-Trip versus 413ms auf Convex Cloud US. Vernachlässigbar. EU-User auf einem EU-Server könnten bessere Latenz sehen als der US-basierte Cloud-Default.

Bandbreiten-Angst: weg. Ich shippte zwei neue Dashboard-Features in der Woche danach, ohne einmal an Quota zu denken.

Der Supabase-Vergleich hält auch auf Infrastruktur-Ebene: ein Convex-Container im Idle nutzt unter 200 MB RAM. Supabase Self-Hosted startet bei 12 Containern und 8 GB empfohlen. Für ein Side Project auf einem geteilten VPS ist dieser Unterschied alles.

Irgendwo um Mitternacht schloss ich das Terminal. Die Piña Colada stand immer noch auf dem Tisch.

Salud.


Checkliste

□ Erstelle 2 DNS A Records, die auf deine Server IP zeigen
□ Deaktiviere Cloudflare Proxy (graue Wolke) für beide Subdomains
□ Schreibe docker-compose.yml mit expliziten service= Labels auf beiden Routern
□ docker compose up -d
□ Warte ~15s für SSL-Zertifikate (Exit Code 60 beim ersten curl = normal)
□ Generiere Admin Key
□ Erstelle .env.self-hosted
□ Exportiere von Cloud: npx convex export --path backup.zip
□ Deploy Schema: npx convex deploy --env-file .env.self-hosted --yes
□ Importiere Daten: npx convex import --env-file .env.self-hosted backup.zip
□ Setze env vars: npx convex env set --env-file .env.self-hosted KEY value
□ Teste Backend Queries
□ Teste HTTP Actions separat (andere Subdomain)
□ Update NEXT_PUBLIC_CONVEX_URL + redeploy Frontend
□ Update alle HTTP Actions Consumer
□ Richte Backup Cron ein
□ Halte Cloud 48h als Rollback am Leben
□ Verifiziere Backup läuft am nächsten Tag

Quellen


Ich schreibe darüber, was wirklich passiert, wenn du Production-Projekte mit AI-Tools baust — nicht die Highlight-Reel. Wenn das nützlich klingt, abonniere.


Das Cover-Bild ist 100% AI. Ich bin der Typ, der Traefik-Labels um 4 Uhr morgens debuggt — ein Designer bin ich nicht.



Vom Supabase-Chaos zum Convex-Self-Hosting: Meine Erfahrungen mit Docker, Bandbreiten-Limits und effizienten Deployment-Strategien findest du in meinem Newsletter.

Newsletter kostenlos abonnieren