Groundwork for moving the app to app.qrmaster.net: the session has to survive the host change from www.qrmaster.net to app.qrmaster.net. - Add COOKIE_DOMAIN and apply it to the auth, CSRF, attribution and OAuth flow cookies. Honoured only in production, because browsers reject dotted domains on localhost - a prod .env copied into a dev environment would otherwise break every login instead of just ignoring the value. - Expire both the host-only and the domain-scoped variant on logout. Next's ResponseCookies is keyed by cookie name and rewrites the entire set-cookie header from its internal map on every set(), so the two variants must be appended manually - otherwise one overwrites the other and the surviving stale cookie keeps the user signed in. - Pass COOKIE_DOMAIN as both build arg and runtime env: process.env is inlined into the Edge middleware bundle, so a runtime-only value would leave the middleware and the route handlers disagreeing about the cookie scope. No behaviour change while COOKIE_DOMAIN is unset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
Plan: Dashboard auf app.qrmaster.net
Stand: 2026-08-12 · Ziel: die eingeloggte App liegt auf app.qrmaster.net, Marketing/SEO bleibt auf www.qrmaster.net.
Zielarchitektur
Ein Docker-Image, ein Container, zwei Hostnames. Caddy routet www.qrmaster.net und
app.qrmaster.net auf denselben Upstream. Die Middleware macht Host-basiertes Routing.
Wichtig: keine Datei zieht um. Die Next-App serviert auf beiden Hosts weiterhin alle Routen.
src/middleware.ts entscheidet pro Host, welcher Pfad ausgeliefert wird, und 301t den Rest auf den
jeweils anderen Host. Damit bleiben alle relativen Links (router.push('/dashboard'),
<Link href="/settings">) unverändert korrekt, weil sie innerhalb desselben Hosts aufgelöst werden.
qrmaster.net --301--> www.qrmaster.net (bleibt wie heute)
www.qrmaster.net -> Marketing, /login, /signup, /r/*, /api/*
app.qrmaster.net -> /dashboard /create /analytics /settings /bulk-creation
/integrations /qr/* /upgrade /onboarding, /api/*
Fixierte Entscheidungen
| Frage | Entscheidung | Begründung |
|---|---|---|
| Hosting | Docker + Caddy auf eigenem Server | Bestand |
/login, /signup |
bleiben auf www | Alle 82 Marketing-CTAs zeigen auf /signup, /signup hat ein hartes Canonical auf www und trägt Ad-Traffic. Umzug wäre teuer ohne Nutzen. |
/onboarding |
zieht auf app | Reiner Logged-in-Flow, kein SEO-Wert |
| Host-Wechsel | genau einmal, nach erfolgreichem Login/Signup | einzige Cross-Host-Stelle im ganzen Flow |
| DB | keine Änderung | — |
Teil A — Deine Aufgaben (Timo)
Reihenfolge beachten: A1–A2 vor dem Deploy von Schritt B5, sonst zeigt die Subdomain ins Leere.
A1. DNS
CNAME app → auf denselben Zielhost wie www (bzw. A-Record auf dieselbe Server-IP).
Kein Proxy-Only-Sonderfall nötig, Caddy holt das Cert selbst.
A2. Caddyfile auf dem Server
app.qrmaster.net in den bestehenden Site-Block aufnehmen, damit Caddy automatisch ein
Let's-Encrypt-Cert zieht:
www.qrmaster.net, app.qrmaster.net {
reverse_proxy qrmaster-web:3000
}
Danach caddy reload. Prüfen: curl -sI https://app.qrmaster.net muss 200 oder 301 liefern,
kein TLS-Fehler.
A3. .env auf dem Server ergänzen
Zwei Variablen statt einer. Die Trennung ist der Kern des ganzen Umbaus:
NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
NEXT_PUBLIC_APP_URL=https://app.qrmaster.net
COOKIE_DOMAIN=.qrmaster.net
NEXTAUTH_URL bleibt https://www.qrmaster.net (wird nur noch von
api/social-assets/route.ts gelesen, kein Auth-Bezug mehr).
A4. Google Cloud Console
Bei den OAuth-Credentials als Authorized redirect URI zusätzlich eintragen:
https://app.qrmaster.net/api/auth/google
Die alte www-URI nicht löschen – sie wird während der Übergangszeit noch von Sessions genutzt, die den Flow auf www gestartet haben.
A5. Nichts zu tun bei Stripe und TikTok
- Stripe-Webhook zeigt auf
www.qrmaster.net/api/stripe/webhookund bleibt gültig (/api/*wird auf beiden Hosts weiter bedient, siehe B5). - TikTok
redirect_uribleibt aufqrmaster.net– verifizierte Domain, nicht anfassen.
A6. Deploy
npm run docker:prod (Rebuild ist zwingend – NEXT_PUBLIC_* wird zur Build-Zeit ins
Client-Bundle inlined, ein reiner Container-Restart genügt nicht).
Teil B — Meine Aufgaben (Code), in Diff-Reihenfolge
B1. Cookie-Domain teilen — muss zuerst live sein
Ohne das ist auf app.qrmaster.net jeder ausgeloggt: das userId-Cookie ist heute host-only.
src/lib/cookieConfig.ts:11—getAuthCookieOptions():domain: process.env.COOKIE_DOMAINin Prod, in Devundefined(localhost verträgt keine Punkt-Domain)src/lib/cookieConfig.ts:24—getCsrfCookieOptions(): ditosrc/middleware.ts:34— Attribution-Cookie: ditosrc/app/(main)/api/auth/logout/route.ts:7— kritisch: löscht heute host-only. Nach der Umstellung existieren bei Bestandsnutzern beide Varianten (alt host-only + neu domain-scoped). Logout muss beide überschreiben, sonst bleibt ein Zombie-Cookie und der Nutzer ist nicht wirklich ausgeloggt. Gilt füruserId,newsletter-adminund das Attribution-Cookie.src/app/(main)/api/auth/google/route.ts:53,62— OAuth-State + Post-Auth-Redirect-Cookie
Kein Forced-Logout nötig: beide Cookie-Varianten tragen denselben signierten Wert, der Server
akzeptiert jede. verifySignedUserIdEdge prüft die Signatur, das Teilen über eigene Subdomains
ist unkritisch.
Dieser Schritt kann allein auf www deployt werden, bevor die Subdomain existiert — nach außen unsichtbar, und wenn app.* dann live geht, funktionieren Sessions sofort.
B2. NEXT_PUBLIC_APP_URL entflechten
Die Variable bedient heute App- und öffentliche URLs. Jede Fundstelle einzeln zuordnen:
Muss auf WWW_URL (öffentlich, teils in QR-Codes kodiert):
src/components/dashboard/QRCodeCard.tsx:82— höchstes Risiko im ganzen Umbau: Basis für die in den QR-Code kodierte/r/<slug>-URL. Bleibt das aufAPP_URL, zeigen alle neu heruntergeladenen und gedruckten Codes auf die Subdomain.src/app/(main)/r/[slug]/route.ts:50,61,84,89— Landing-Basis vcard/text/coupon/feedbacksrc/lib/email.ts:56,562,src/lib/marketingEmail.ts:30— Mail-Links auf Marketing-Inhaltesrc/app/(main)/api/auth/signup/route.ts:20— Verify-Mail-Linksrc/app/(main)/api/stripe/checkout/route.ts:64—cancel_url→/pricingsrc/lib/metaConversions.ts:44,src/app/(main)/api/auth/signup/route.ts:150— Event-Source-URLs
Bleibt/wird APP_URL (eingeloggt):
src/app/(main)/api/stripe/checkout/route.ts:63—success_url→/dashboardsrc/app/(main)/api/stripe/create-checkout-session/route.ts:112,128—appUrl+ returnPathsrc/app/(main)/api/stripe/portal/route.ts:59—return_url→/settingssrc/lib/email.ts:505— hartcodierteshttps://www.qrmaster.net/dashboardim Mail-Footersrc/app/(main)/api/auth/google/route.ts:40,97—redirect_uri(deckt A4 ab)
B3. Post-Auth-Sprung auf app.*
Die einzige Cross-Host-Stelle. sanitizeRedirectPath (src/lib/auth-flow.ts:4) erlaubt bewusst
nur relative Pfade — bleibt so, ich baue den Host separat davor:
src/app/(main)/(auth)/login/ClientPage.tsx:56undlogin/LoginClient.tsx:65src/app/(main)/(auth)/signup/ClientPage.tsx:70src/app/(main)/api/auth/google/route.ts:224,228— Server-Redirectsrc/app/(main)/api/auth/verify-email/route.ts:38— setzt Cookie und redirectedsrc/lib/auth-flow.ts:46—getPostOnboardingDestination
Muster: relativen Zielpfad wie heute bestimmen, dann new URL(path, APP_URL). Weil das
Auth-Cookie nach B1 auf .qrmaster.net gilt, ist der Nutzer nach dem Sprung sofort eingeloggt —
kein Token-Handover über die URL nötig.
B4. Onboarding-Checkliste
src/components/dashboard/OnboardingChecklist.tsx:141 verlinkt /onboarding mit
redirect=/dashboard. Beide Pfade liegen nach dem Umzug auf app.* → bleibt relativ, keine
Änderung. Nur verifizieren.
B5. Middleware: Host-Routing
src/middleware.ts — Kern des Umbaus. Der bestehende Apex-Redirect (Zeile 49) bleibt unberührt.
Neu, direkt danach:
- Host
app.qrmaster.net:/api/*,/_next/*, statische Dateien: durchlassen (Stripe-Webhook, CSRF, alles)protectedPaths(Zeile 145) +/upgrade+/onboarding: bedienen wie heute- alles andere: 301 auf
WWW_URL+ gleicher Pfad /r/*: 301 auf www — QR-Redirects gehören nicht auf die App-Subdomain
- Host
www.qrmaster.net:protectedPaths+/upgrade+/onboarding: 301 aufAPP_URL+ Pfad + Query (damit alte Bookmarks und der Mail-Footer-Link weiter funktionieren)
- Auth-Fail-Redirect (Zeile 166): zeigt auf
/signup— das liegt auf www, also absolut aufWWW_URLumstellen,redirect-Param bleibt relativ
/login und /signup bleiben in publicPaths und werden nur auf www bedient.
B6. Indexierung der Subdomain dichtmachen
app.* darf nicht in den Index, sonst Duplicate Content.
src/middleware.ts: auf Hostapp.*X-Robots-Tag: noindex, nofollowauf alle Responsespublic/robots-app.txtneu anlegen (User-agent: * / Disallow: /), Middleware rewritet/robots.txtauf app.* dorthin.src/app/robots.tsbleibt für www unverändert./sitemap.xmlauf app.* → 301 auf www
Gute Nachricht: /dashboard, /create, /settings sind in src/app/robots.ts:7 bereits
disallowed und nicht in der Sitemap → kein Ranking-Verlust durch den Umzug. Die Canonicals
sind ohnehin hart auf www verdrahtet (src/app/(main)/layout.tsx:13).
B7. Docker-Env-Kette
NEXT_PUBLIC_* wird zur Build-Zeit inlined und zur Laufzeit serverseitig gelesen. Beide
Stellen müssen übereinstimmen, sonst gibt es Bugs, die nur im Client oder nur im Server auftreten:
Dockerfile:34—NEXT_PUBLIC_APP_URLaufhttps://app.qrmaster.net, neuENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"docker-compose.yml:58—NEXT_PUBLIC_WWW_URLundCOOKIE_DOMAINinsenvironmentdesweb-Service durchreichenenv.example+.env.example— neue Variablen dokumentierensrc/lib/env.ts— optional, das Schema kenntNEXT_PUBLIC_*bisher gar nicht
Deploy-Choreografie
Zwei Deploys, nicht einer. Das entkoppelt das Cookie-Risiko vom Routing-Risiko:
- Deploy 1 (nur B1): Cookie-Domain auf
.qrmaster.net. Nur www ist live, nach außen unsichtbar. 24 h beobachten: Login, Logout, Checkout müssen normal laufen. - A1 + A2 + A4: DNS, Caddy, Google Console.
app.qrmaster.netantwortet, serviert aber noch dieselbe App wie www — unkritisch, weil noch nicht verlinkt und dank B6 noch nicht indexierbar. - Deploy 2 (B2–B7): Host-Routing scharf. Ab hier springt Login auf app.*.
Rollback: Deploy 2 zurücknehmen. Weil das Cookie auf .qrmaster.net gilt, bleiben Sessions
auch nach dem Rollback auf www gültig — niemand wird ausgeloggt. DNS/Caddy können stehen bleiben.
Testcheckliste (nach Deploy 2)
Jeweils über beide Hosts:
www.qrmaster.net/dashboard→ 301 aufapp.qrmaster.net/dashboard, eingeloggtapp.qrmaster.net/pricing→ 301 auf www- Signup auf www → Verify-Mail → Link führt eingeloggt auf app.*
- Google-Login von www aus → landet eingeloggt auf app.*/dashboard bzw. /onboarding
- Logout auf app.* → auf www auch ausgeloggt (prüft B1, häufigster Fehler)
- Checkout: Upgrade auf app.* → Stripe →
success_urlapp.*/dashboard, Abbruch → www/pricing - Stripe-Portal → zurück auf app.*/settings
- Stripe-Webhook feuert weiter (Dashboard → Events, keine 4xx)
- QR-Code neu anlegen + herunterladen → kodierte URL ist
www.qrmaster.net/r/<slug>, nicht app.* (prüft B2, das teuerste Fehlerbild) - Bestehender
/r/<slug>redirected + trackt weiter, vcard/coupon/feedback-Landings laden - Mutation auf app.* (QR umbenennen) → CSRF greift, kein 403
curl -sI https://app.qrmaster.net/dashboard | grep -i x-robots-tag→ noindexhttps://app.qrmaster.net/robots.txt→Disallow: /- Search Console:
app.qrmaster.netnicht als Property anlegen, keine Sitemap einreichen
Risiken
| Risiko | Wo | Absicherung |
|---|---|---|
| Gedruckte QR-Codes zeigen auf app.* | QRCodeCard.tsx:82 |
B2, explizit im Test |
| Logout wirkt nicht (Zombie-Cookie) | logout/route.ts |
B1 löscht beide Varianten |
| Client/Server-Env divergieren | Dockerfile vs. docker-compose.yml |
B7, beide Stellen setzen |
| Google-OAuth bricht | Cloud Console | A4, alte URI stehen lassen |
| Duplicate Content auf app.* | — | B6 vor Deploy 2 |
Aufwand
- Deine Seite: ~45 min (DNS, Caddy, .env, Google Console, Deploy)
- Meine Seite: ~4–6 h Code über zwei Deploys
- Keine DB-Änderung, kein Forced-Logout, kein SEO-Verlust