The staging database now runs as qrmaster_test instead of reusing the production name. Container and volume already kept the two apart, but a hand-typed psql session against two databases both called `qrmaster` looks identical on either side - the distinct name is what makes the wrong window obvious before a DELETE lands in it. The base compose file hardcodes `pg_isready -d qrmaster` in the db healthcheck, so the overlay has to override the probe as well. Without it the container stays unhealthy and web never starts, because it waits on service_healthy. Verified against `docker compose config`: staging resolves to qrmaster_test in POSTGRES_DB, DATABASE_URL and the healthcheck, while production still resolves to qrmaster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
Plan: Testumgebung auf testmodul.qrmaster.net
Stand: 2026-08-12 · Ziel: eine vollständige zweite Instanz auf testmodul.qrmaster.net, die
einen anderen Branch (test) fährt, damit riskante Features - z.B. ein Bot, der bei
Kundenerfolgen automatisch tweetet - vor dem Merge nach master real getestet werden können.
Status: nur Planung. Es wurde noch kein Code geändert.
Architektur
Zweiter, vollständig eigenständiger Compose-Stack aus demselben Repo, anderer Branch,
eigene Datenbank, eigenes Redis. Caddy routet testmodul.qrmaster.net auf den Test-Container.
www.qrmaster.net ─┐
app.qrmaster.net ─┴─> qrmaster-web (Branch master, Prod-DB)
testmodul.qrmaster.net ─> qrmaster-test-web (Branch test, eigene DB)
Prod und Test teilen sich nichts außer dem Docker-Netzwerk qrmaster-network (das ist
external: true, darüber erreicht Caddy beide Container per Namen).
Ausgangslage im Repo
Im Working Tree liegen uncommittete Änderungen, die nicht aus der Subdomain-Arbeit stammen:
src/lib/cookieConfig.ts-getAuthCookieName()ist angelegt (liestAUTH_COOKIE_NAME, DefaultuserId)src/lib/session.ts- importiertgetAuthCookieName, benutzt es aber nicht; Zeile 14 hat weiterhinexport const AUTH_COOKIE_NAME = 'userId'
Damit ist die Funktion aktuell wirkungslos. Die Verkabelung fehlt an 6 Stellen (siehe unten). Vor dem Weiterbauen klären, ob diese Änderungen bewusst so stehen oder committed werden sollen.
Ein Branch test existiert noch nicht. Vorhanden: analytics, dynamisch,
feature/mockup-landing-page, icons, master.
Falle 1 - Cookie-Kollision (blockierend)
Produktion setzt das Session-Cookie userId auf .qrmaster.net. Der Browser schickt es damit
auch an testmodul.qrmaster.net. Setzt Test sein eigenes userId als Host-Cookie, liegen
zwei gleichnamige Cookies vor, und req.cookies.get('userId') in
middleware.ts:250 liefert undefiniert welches davon. Folge: Login auf
Test verhält sich sporadisch wie ausgeloggt - ein Fehlerbild, das schwer zu greifen ist, weil es
vom Cookie-Zustand des jeweiligen Browsers abhängt.
Lösung: Cookie-Name pro Umgebung konfigurierbar. getAuthCookieName() fertig verkabeln,
Test setzt AUTH_COOKIE_NAME=userId_test.
Zu ändernde Stellen:
| Datei | Was |
|---|---|
src/lib/session.ts:14 |
AUTH_COOKIE_NAME-Konstante durch getAuthCookieName() ersetzen (Zeilen 71, 78 ziehen nach) |
src/middleware.ts:250 |
req.cookies.get('userId') → getAuthCookieName() |
src/app/(main)/api/auth/google/route.ts:227 |
cookies.set('userId', …) |
src/app/(main)/api/auth/verify-email/route.ts:40 |
cookies.set('userId', …) |
src/app/(main)/api/auth/logout/route.ts:12 |
Eintrag in appendExpiredCookies |
src/app/(main)/api/user/delete/route.ts:77 |
cookies().delete('userId') |
Wie COOKIE_DOMAIN muss AUTH_COOKIE_NAME auch zur Build-Zeit gesetzt sein - process.env
wird ins Edge-Middleware-Bundle inlined. Also ARG + ENV im Dockerfile und als Build-Arg im
Compose-Override, analog zu COOKIE_DOMAIN.
Achtung beim Umstellen der Produktion: ändert sich dort der Cookie-Name, werden alle
bestehenden Sessions ungültig (alle Nutzer ausgeloggt). Deshalb Prod beim Default userId
lassen und nur Test abweichen - dann passiert genau nichts.
Falle 2 - Host-Routing würde nach Prod umleiten
Die Middleware kennt nur zwei Hosts. Auf testmodul.qrmaster.net greift der Zweig
else if (isAppPath(path)) und schickt /dashboard per 301 auf app.qrmaster.net, also in
die Produktion.
Lösung ohne Codeänderung: Test zeigt beide Origins auf sich selbst. Dann ist
isHostSplitEnabled() false, getAppHostname() liefert null, und das komplette Host-Routing
schaltet sich ab. Test läuft als Ein-Host-Umgebung mit Marketing und App unter einer Domain -
genau wie die lokale Entwicklung.
Das ist der Grund, warum beide Variablen in .env.test identisch sein müssen. Wer dort später
aus Versehen NEXT_PUBLIC_APP_URL=https://app.qrmaster.net einträgt, schickt seine Testklicks
in die Produktion.
Falle 3 - ausgehende Nebenwirkungen
Das ist beim Twitter-Bot der eigentliche Punkt. Eine Testumgebung, die auf echte Dienste zeigt, ist keine Testumgebung.
| Dienst | Auf Test |
|---|---|
| Twitter/X | eigener App-Key auf einen Test-Account, oder ein DRY_RUN-Flag, das den Tweet nur loggt |
| Stripe | Test-Keys (sk_test_…), eigener Webhook-Endpoint auf testmodul |
| Resend / SMTP | Test-Key oder komplett deaktivieren - sonst mailt Test an echte Kunden |
| Meta Conversions / PostHog / Umami | leer lassen, sonst verschmutzt Test die Prod-Analytics |
| Cron (Retention-Mails) | auf Test abschalten |
Empfehlung für den Bot: das DRY_RUN-Flag von Anfang an einbauen, nicht erst wenn es einmal
schiefging. Ein Bot, der Kundenerfolge tweetet, ist genau die Sorte Feature, die man nicht
"kurz mal live" testen will.
.env.test
NODE_ENV=production
# Ein-Host-Betrieb: schaltet das Host-Routing ab
NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net
NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net
NEXTAUTH_URL=https://testmodul.qrmaster.net
# Host-Cookie, nicht .qrmaster.net - sonst leckt die Test-Session nach Prod
COOKIE_DOMAIN=
AUTH_COOKIE_NAME=userId_test
# Anderes Secret: ein Leak auf Test kann dann keine Prod-Session fälschen
NEXTAUTH_SECRET=<eigenes Secret>
IP_SALT=<eigenes Salt>
# Nicht indexieren
NEXT_PUBLIC_INDEXABLE=false
# Eigene DB im Test-Stack
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<eigenes, hex - ein "/" aus base64 zerlegt die DATABASE_URL>
POSTGRES_DB=qrmaster_test
# DATABASE_URL nicht setzen - Compose baut sie aus den drei Werten oben
# Test-Keys / leer, siehe Falle 3
STRIPE_SECRET_KEY=sk_test_…
RESEND_API_KEY=
NEXTAUTH_URL darf hier auf testmodul zeigen - der einzige echte Leser ist die
Social-Assets-Route, und die soll auf Test ohnehin nicht gegen die verifizierte TikTok-Domain
laufen.
Infra
docker-compose.test.yml als Override, das nur die Abweichungen setzt:
container_name:qrmaster-test-db,qrmaster-test-redis,qrmaster-test-web- die Namen sind im Basis-File fest vergeben und würden sonst kollidieren- Host-Ports entfernen -
5435(db) und8080(adminer) sind schon von Prod belegt entrypoint: ["node", "server.js"]fürweb, siehe Migrationen oben- Build-Args für
COOKIE_DOMAIN,AUTH_COOKIE_NAME,NEXT_PUBLIC_*
Netzwerk-Isolation (kritisch)
db und redis hängen im Basis-File am Netzwerk qrmaster-network
(docker-compose.yml:22), und das ist external: true, also für beide
Stacks dasselbe. Compose vergibt jedem Service automatisch einen Netzwerk-Alias mit seinem
Servicenamen - zwei Stacks mit einem Service db am selben Netzwerk heißt: db löst auf
zwei Container auf und Dockers DNS wählt zufällig. Der Test-Container könnte damit auf der
Produktionsdatenbank landen, nicht deterministisch, sondern mal so und mal so.
Deshalb bekommt der Test-Stack ein eigenes internes Netzwerk. Nur web hängt zusätzlich am
geteilten Netz, damit Caddy es erreicht:
services:
db:
container_name: qrmaster-test-db
ports: !reset []
networks: [test-internal]
redis:
container_name: qrmaster-test-redis
networks: [test-internal]
web:
container_name: qrmaster-test-web
entrypoint: ["node", "server.js"]
networks: [test-internal, qrmaster-network]
networks:
test-internal:
driver: bridge
POSTGRES_DB heißt auf Test qrmaster_test. Die Trennung kommt zwar schon vom eigenen
Container und Volume, aber der abweichende Name macht bei einer von Hand getippten
psql-Sitzung sofort sichtbar, auf welcher Instanz man ist - die billigste Versicherung
gegen ein DELETE in der falschen Datenbank.
Dafür muss der Healthcheck mitgezogen werden: das Basis-File hat pg_isready -d qrmaster
hartkodiert (Zeile 19). Ohne Override prüft er eine Datenbank, die
es nicht gibt, der Container bleibt unhealthy, und web startet wegen
depends_on: condition: service_healthy nie.
Start:
docker compose -p qrmaster-test --env-file .env.test \
-f docker-compose.yml -f docker-compose.test.yml up -d --build
Der Projektname -p qrmaster-test gibt automatisch eigene Volumes - die Test-DB kann die
Prod-DB also nicht anfassen.
Caddy:
testmodul.qrmaster.net {
reverse_proxy qrmaster-test-web:3000
}
robots.ts an NEXT_PUBLIC_INDEXABLE koppeln. Aktuell liefert es hart Allow plus
www-Sitemap; auf Test soll Disallow: / stehen. NEXT_PUBLIC_INDEXABLE=false setzt heute nur
das Meta-Tag in den beiden Layouts, nicht die robots.txt.
Branch-Workflow
git checkout -b test master
git push -u origin test
Deploy auf Test: auf dem Server git checkout test && git pull, dann der Compose-Befehl oben.
Rebuild ist immer nötig, weil NEXT_PUBLIC_* und AUTH_COOKIE_NAME zur Build-Zeit inlined
werden.
Ablauf für ein Feature: Branch von test abzweigen → auf Test deployen und prüfen → nach
master mergen → Prod-Deploy. test bleibt dauerhaft bestehen und wird regelmäßig von
master nachgezogen, damit er nicht wegdriftet.
Datenbank - entschieden: leeres Schema
Eigene PostgreSQL-Instanz im Test-Stack, befüllt mit Struktur ohne Zeilen. Keine Kundendaten verlassen die Produktion.
# Struktur aus Prod ziehen (keine Zeilen)
docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql
# in die Test-DB einspielen
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < schema.sql
Danach einen Testaccount anlegen - entweder über das Signup-Formular auf testmodul oder per
INSERT.
Warum der Dump aus Prod und nicht aus Prisma: prisma/migrations steht auf April 2026.
Alles seitdem (Pulse-Spalten, BARCODE-Enum-Wert, ...) kam per Hand-SQL. Die Produktions-DB
ist damit die einzige Stelle, die die aktuelle Struktur kennt.
Warum leer statt Kopie: beim Tweet-Bot willst du wissen, welcher Testfall den Tweet ausgelöst hat. Mit Prod-Daten könnte es auch irgendein echter Kunde von vor Monaten gewesen sein.
Keine Migrationen - der Entrypoint muss überschrieben werden
docker/entrypoint.sh:5 führt bei jedem Container-Start
npx prisma migrate deploy aus. Das widerspricht der Policy aus CLAUDE.md (Schema-Änderungen
nur per Hand-SQL) und würde auf einer frischen Test-DB die 6 veralteten Migrationen anwenden -
also ein Schema vom Stand April 2026 bauen, dem alle späteren SQL-Änderungen fehlen. Die App
liefe dann in "column does not exist".
Im Test-Override deshalb:
web:
entrypoint: ["node", "server.js"]
Prod bleibt unverändert. Der Schema-Dump bringt die Tabelle _prisma_migrations ohnehin mit,
inklusive der 6 als angewandt markierten Einträge - der Zustand ist also identisch zu Prod.
Separat zu klären (nicht Teil dieses Plans): ob prisma migrate deploy langfristig auch
aus dem Prod-Entrypoint verschwinden soll. Heute ist es dort ein No-Op, aber es ist eine
scharfe Waffe, die bei einem versehentlich hinzugefügten Migrationsfile auf die Produktion
losgeht.
Spätere Schema-Änderungen
Ein SQL-Statement, das auf Test getestet wurde, wird auf Prod erneut von Hand ausgeführt -
es gibt keinen automatischen Weg von Test nach Prod. Die Dateien in sql/ sind der Ort dafür.
Aufwand
| Cookie-Name verkabeln (6 Stellen) + Dockerfile/Compose | ~1 h |
docker-compose.test.yml + robots.ts an INDEXABLE koppeln |
~1 h |
.env.test, Secrets, Test-Keys besorgen |
Timo |
| Caddy-Block + erster Deploy | ~30 min |
| DB-Variante B zusätzlich | +2-3 h für das Anonymisierungs-Skript |
Der Twitter-Bot selbst ist davon unabhängig und noch nicht geschätzt.
Testcheckliste nach dem ersten Test-Deploy
testmodul.qrmaster.netlädt, gültiges Zertifikattestmodul.qrmaster.net/dashboardbleibt auf testmodul und springt nicht nach app.qrmaster.net- Login auf Test funktioniert, während man in Prod eingeloggt ist - beide Sessions unabhängig
- Logout auf Test loggt nicht aus Prod aus (und umgekehrt)
- Im Browser liegen zwei Cookies:
userId(Domain.qrmaster.net) unduserId_test(Hosttestmodul.qrmaster.net) curl -sI https://testmodul.qrmaster.net→ keinX-Robots-Tagnötig, aber/robots.txtliefertDisallow: /- Test-DB enthält keine echten Kunden:
SELECT count(*) FROM "User";muss die Zahl der selbst angelegten Testaccounts sein docker compose -p qrmaster-test exec web env | grep DATABASE_URLzeigt auf den Test-Container, undSELECT count(*)dort weicht von Prod ab - beweist, dass derdb-Alias nicht auf Prod zeigt- Container-Logs beim Start enthalten kein "Applying Prisma migrations"
- Ein Testlauf des Bots postet nichts auf dem echten Account