Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real qrmaster.net subdomain. Production scopes its session cookie to .qrmaster.net, so the browser sends it to every subdomain including staging. With both environments naming the cookie `userId`, the browser holds two cookies of the same name and cookies.get() picks one arbitrarily - staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick `userId_test` instead. Production keeps the `userId` default; changing it there would invalidate every existing session. Wired getAuthCookieName() into the six places that named the cookie literally. The account deletion route now expires both the host-only and the domain-scoped variant like the logout route already does, instead of a single cookies().delete() that would leave the other one behind. NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be built pointing at the staging host - the defaults keep a plain production build byte identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env is inlined into the Edge middleware bundle. robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise returns the production robots.txt and invites crawlers to index a duplicate of www. docker-compose.test.yml is the staging overlay. Two things it must get right, both verified against `docker compose config`: - db and redis need `networks: !override`. Compose MERGES the networks mapping from the base file, and since qrmaster-network is external and shared, a plain list left them attached to it - `db` would then resolve to two containers and staging could read and write the production database. - The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations stopped in April 2026 and the schema has moved on through manual SQL since, so applying them to a fresh database would build a stale schema. Staging gets its schema from `pg_dump --schema-only` against production instead. Verified: tsc clean, production build succeeds, and the merged compose config confirms staging keeps db/redis off the shared network while production resolves unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 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>
POSTGRES_DB=qrmaster
DATABASE_URL=postgresql://postgres:<eigenes>@db:5432/qrmaster?schema=public
# 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 auf Test gleich lassen (qrmaster): der Healthcheck hat pg_isready -d qrmaster hartkodiert (Zeile 19) und würde bei abweichendem Namen den
Container als unhealthy melden. Die Trennung kommt vom Volume, nicht vom Datenbanknamen.
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.
# auf dem Server, aus dem Prod-Stack
docker compose exec db pg_dump -U postgres --schema-only qrmaster > /tmp/schema.sql
# in den Test-Stack einspielen
docker compose -p qrmaster-test exec -T db psql -U postgres qrmaster < /tmp/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