Website und App auf getrennten Domains (craftvia.de / app.craftvia.de)

Der Proxy trennt nach Host, sobald MARKETING_HOST gesetzt ist: craftvia.de zeigt nur die
Produktseiten und leitet Login, Testphase, App und API auf die App-Domain aus AUTH_URL um;
www leitet auf die Hauptdomain; auf der App-Domain landet / ohne Sitzung beim Login und
Produktseiten gehen zurück zur Website. Interne Aufrufe (Healthcheck) bleiben unverändert.
Links bleiben relativ, damit keine Domain im Image steckt.

Dazu Test scripts/test-host-routing.ts, Env-Vorlagen mit MARKETING_HOST und Registry,
scripts/generate-coolify-env.sh für lokal erzeugte Geheimnisse und DEPLOY.md §2.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 09:08:47 +02:00
co-authored by Claude Opus 5
parent f665999d81
commit 4d5be72ebb
7 changed files with 206 additions and 5 deletions
+9 -1
View File
@@ -22,6 +22,10 @@ RLS_ENFORCED=false
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
REDIS_PASSWORD=CHANGE_ME_redis_password
# --- Fertige Images (docker-compose.coolify.prebuilt.yml, DEPLOY.md §9) ---
REGISTRY=git.certvia.de/msolarczek
IMAGE_TAG=main
# --- Objektspeicher (Service "garage", S3-kompatibel) ---
# Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern
# der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der
@@ -60,7 +64,11 @@ AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32
# PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt.
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern)
AUTH_URL=http://REPLACE-WITH-COOLIFY-SSLIP-DOMAIN
AUTH_URL=https://app.craftvia.de
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
# Leer = alles auf einer Domain.
MARKETING_HOST=craftvia.de
# Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost:
AUTH_TRUST_HOST=true
+4
View File
@@ -63,6 +63,10 @@ PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
# MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern.
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
AUTH_URL=https://app.craftvia.example
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
# Leer = alles auf einer Domain.
MARKETING_HOST=craftvia.example
# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig.
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
# WEBAUTHN_ORIGIN=https://app.craftvia.example
+19
View File
@@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-
Proxies dürfen kein niedrigeres Body-Limit haben.
- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH.
### 2.1 Website und App auf getrennten Domains
Ein `app`-Container bedient beide Domains; der Proxy trennt sie (`src/lib/marketing/hosts.ts`,
Test `scripts/test-host-routing.ts`):
| Domain | zeigt | leitet um |
|---|---|---|
| `craftvia.de` | `/`, `/funktionen/**`, `/sicherheit`, `/preise`, `/impressum` | alles andere (Login, `/testen`, App, API) → `app.craftvia.de` (308) |
| `www.craftvia.de` | – | → `craftvia.de` (308) |
| `app.craftvia.de` | die Anwendung; `/` ohne Sitzung → `/login` | Produktseiten → `craftvia.de` (308) |
- Env: `AUTH_URL=https://app.craftvia.de`, `MARKETING_HOST=craftvia.de` (leer = eine Domain für alles).
- Coolify, Service `app`, Feld *Domains*: `https://app.craftvia.de:3000,https://craftvia.de:3000,https://www.craftvia.de:3000`.
- DNS: `craftvia.de` und `app.craftvia.de` als A/AAAA auf den Server, `www.craftvia.de` als CNAME auf `craftvia.de`.
- Die Links der Produktseiten bleiben relativ (`/login`, `/testen`); die Umleitung übernimmt der Proxy.
So steckt keine Domain im Image, und dieselben Images laufen auf Test und Produktion.
- Sitzungs-Cookies gelten nur für `app.craftvia.de`. Interne Aufrufe (Healthcheck über `127.0.0.1`)
werden nicht umgeleitet.
## 3. Ersteinrichtung (Coolify)
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Erzeugt die Env-Datei für den Coolify-Testserver aus .env.coolify.example — mit frischen
# Geheimnissen, die nur auf diesem Rechner entstehen. Die Datei ist per .gitignore (.env*)
# vom Repo ausgeschlossen; Inhalt in Coolify unter "Environment Variables" einfügen.
#
# ./scripts/generate-coolify-env.sh # schreibt .env.coolify.local
# APP_DOMAIN=app.craftvia.de MARKETING_HOST=craftvia.de ./scripts/generate-coolify-env.sh
set -euo pipefail
OUT="${1:-.env.coolify.local}"
APP_DOMAIN="${APP_DOMAIN:-app.craftvia.de}"
MARKETING_HOST="${MARKETING_HOST:-craftvia.de}"
if [ -e "$OUT" ]; then
echo "$OUT existiert bereits und wird nicht überschrieben (Geheimnisse wären sonst verloren)." >&2
exit 1
fi
hex() { openssl rand -hex "$1"; }
DB_PASSWORD="$(hex 24)"
sed \
-e "s|CHANGE_ME_db_password|${DB_PASSWORD}|g" \
-e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(hex 24)|" \
-e "s|^S3_ACCESS_KEY=.*|S3_ACCESS_KEY=GK$(hex 12)|" \
-e "s|^S3_SECRET_KEY=.*|S3_SECRET_KEY=$(hex 32)|" \
-e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(hex 32)|" \
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(hex 32)|" \
-e "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 32 | tr -d '\n')|" \
-e "s|^PASSWORD_PEPPER=.*|PASSWORD_PEPPER=$(hex 32)|" \
-e "s|^AUTH_URL=.*|AUTH_URL=https://${APP_DOMAIN}|" \
-e "s|^MARKETING_HOST=.*|MARKETING_HOST=${MARKETING_HOST}|" \
.env.coolify.example > "$OUT"
{
echo ""
echo "# --- erzeugt von scripts/generate-coolify-env.sh ($(date +%F)) ---"
echo "MFA_ENC_KEY=$(hex 32)"
echo "BACKUP_ENC_KEY=$(hex 32)"
} >> "$OUT"
chmod 600 "$OUT"
echo "Geschrieben: $OUT (nur lokal, Rechte 600)."
echo "Noch von Hand ergänzen: SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM, ANTHROPIC_API_KEY, TRIAL_CONTACT_EMAIL."
echo "Danach sicher ablegen (Passwortmanager) — PASSWORD_PEPPER und MFA_ENC_KEY sind nicht rotierbar."
+51
View File
@@ -0,0 +1,51 @@
// Domain-Trennung craftvia.de ↔ app.craftvia.de (src/lib/marketing/hosts.ts)
//
// Lauf: npx tsx scripts/test-host-routing.ts
import { hostSplit, routeByHost, type HostDecision } from "../src/lib/marketing/hosts";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const env = { MARKETING_HOST: "craftvia.de", AUTH_URL: "https://app.craftvia.de" };
const split = hostSplit(env)!;
const route = (host: string, path: string, hasSession = false): HostDecision => {
const [pathname, query] = path.split("?");
return routeByHost({ host, pathname, search: query ? `?${query}` : "", hasSession }, split);
};
const to = (d: HostDecision) => (d.kind === "redirect" ? `${d.status} ${d.location}` : "next");
console.log("\n— Konfiguration —");
ok(hostSplit({ AUTH_URL: "https://app.craftvia.de" }) === null, "ohne MARKETING_HOST keine Trennung (lokal)");
ok(hostSplit({ MARKETING_HOST: "craftvia.de" }) === null, "ohne AUTH_URL keine Trennung");
ok(hostSplit({ MARKETING_HOST: "app.craftvia.de", AUTH_URL: "https://app.craftvia.de" }) === null, "gleiche Domain → keine Trennung");
ok(hostSplit({ MARKETING_HOST: "https://Craftvia.de/", AUTH_URL: "https://app.craftvia.de" })?.marketingHost === "craftvia.de", "MARKETING_HOST mit Schema/Slash/Großschreibung wird normalisiert");
console.log("\n— Website-Domain —");
ok(to(route("craftvia.de", "/")) === "next", "/ zeigt die Startseite");
ok(to(route("craftvia.de", "/funktionen/planung")) === "next", "Funktionsseiten bleiben auf der Website");
ok(to(route("craftvia.de", "/impressum")) === "next", "Impressum bleibt auf der Website");
ok(to(route("craftvia.de", "/login")) === "308 https://app.craftvia.de/login", "/login → App-Domain");
ok(to(route("craftvia.de", "/testen?ref=preise")) === "308 https://app.craftvia.de/testen?ref=preise", "/testen → App-Domain, Query bleibt erhalten");
ok(to(route("craftvia.de", "/dashboard")) === "308 https://app.craftvia.de/dashboard", "App-Seiten → App-Domain");
ok(to(route("craftvia.de", "/api/v1/sync")) === "308 https://app.craftvia.de/api/v1/sync", "API → App-Domain (308 behält die Methode)");
ok(to(route("craftvia.de:443", "/preise")) === "next", "Port im Host wird ignoriert");
ok(to(route("www.craftvia.de", "/preise")) === "308 https://craftvia.de/preise", "www → Hauptdomain");
console.log("\n— App-Domain —");
ok(to(route("app.craftvia.de", "/")) === "307 https://app.craftvia.de/login", "/ ohne Sitzung → Login");
ok(to(route("app.craftvia.de", "/", true)) === "next", "/ mit Sitzung → weiter (Seite leitet auf Dashboard bzw. /m)");
ok(to(route("app.craftvia.de", "/funktionen")) === "308 https://craftvia.de/funktionen", "Produktseiten → Website");
ok(to(route("app.craftvia.de", "/impressum")) === "308 https://craftvia.de/impressum", "Impressum → Website");
ok(to(route("app.craftvia.de", "/testen")) === "next", "Testphase bleibt in der App");
ok(to(route("app.craftvia.de", "/planning", true)) === "next", "App-Seiten bleiben in der App");
console.log("\n— Interne Aufrufe —");
ok(to(route("127.0.0.1:3000", "/")) === "next", "Healthcheck über 127.0.0.1 unverändert");
ok(to(route("craftvia-app:3000", "/login")) === "next", "Container-interne Aufrufe unverändert");
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ alle Prüfungen grün");
process.exit(failures ? 1 : 0);
+58
View File
@@ -0,0 +1,58 @@
/**
* Domain-Trennung Website ↔ App: `craftvia.de` zeigt nur die Produktseiten, `app.craftvia.de` die
* Anwendung. Aktiv nur, wenn `MARKETING_HOST` gesetzt ist; die App-Domain kommt aus `AUTH_URL`.
* Ohne `MARKETING_HOST` (lokal, Tests) läuft alles wie bisher auf einem Host.
*
* Links der Produktseiten bleiben relativ (/login, /testen): der Proxy leitet sie von der
* Website-Domain auf die App-Domain um. So wird keine Domain beim Image-Bau festgeschrieben.
*/
export const MARKETING_PATHS = ["/", "/funktionen", "/sicherheit", "/preise", "/impressum"] as const;
export type HostSplit = { marketingHost: string; appHost: string; appOrigin: string };
export type HostDecision = { kind: "next" } | { kind: "redirect"; location: string; status: 307 | 308 };
export function hostSplit(env: Record<string, string | undefined> = process.env): HostSplit | null {
const marketingHost = env.MARKETING_HOST?.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/[/:].*$/, "");
const authUrl = env.AUTH_URL?.trim();
if (!marketingHost || !authUrl) return null;
let app: URL;
try {
app = new URL(authUrl);
} catch {
return null;
}
const appHost = app.hostname.toLowerCase();
if (appHost === marketingHost) return null;
return { marketingHost, appHost, appOrigin: app.origin };
}
export function isMarketingPath(pathname: string): boolean {
return MARKETING_PATHS.some((p) => pathname === p || (p !== "/" && pathname.startsWith(p + "/")));
}
export function routeByHost(input: { host: string; pathname: string; search: string; hasSession: boolean }, split: HostSplit): HostDecision {
const host = input.host.toLowerCase().split(",")[0].trim().replace(/:\d+$/, "");
const target = input.pathname + input.search;
const marketingOrigin = `https://${split.marketingHost}`;
// www → Hauptdomain
if (host === `www.${split.marketingHost}`) return { kind: "redirect", location: marketingOrigin + target, status: 308 };
// Website-Domain: nur Produktseiten, alles andere (Login, Testphase, App, API) gehört zur App
if (host === split.marketingHost) {
return isMarketingPath(input.pathname) ? { kind: "next" } : { kind: "redirect", location: split.appOrigin + target, status: 308 };
}
// Interne Aufrufe (Healthcheck über 127.0.0.1, Worker) bleiben unverändert
if (host !== split.appHost) return { kind: "next" };
// App-Domain: Produktseiten wohnen auf der Website, die Startseite ist der Login
if (input.pathname !== "/" && isMarketingPath(input.pathname)) return { kind: "redirect", location: marketingOrigin + target, status: 308 };
if (input.pathname === "/" && !input.hasSession) return { kind: "redirect", location: `${split.appOrigin}/login`, status: 307 };
return { kind: "next" };
}
+20 -4
View File
@@ -1,4 +1,5 @@
import { NextResponse, type NextRequest } from "next/server";
import { hostSplit, routeByHost } from "@/lib/marketing/hosts";
/**
* Route gate (Next.js 16 proxy, formerly middleware): redirects anonymous
@@ -25,6 +26,25 @@ const matchesAny = (pathname: string, paths: string[]) =>
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasSessionCookie =
request.cookies.has("authjs.session-token") ||
request.cookies.has("__Secure-authjs.session-token");
// craftvia.de (Website) ↔ app.craftvia.de (App) — nur mit MARKETING_HOST, siehe src/lib/marketing/hosts.ts
const split = hostSplit();
if (split) {
const decision = routeByHost(
{
host: request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "",
pathname,
search: request.nextUrl.search,
hasSession: hasSessionCookie,
},
split,
);
if (decision.kind === "redirect") return NextResponse.redirect(decision.location, decision.status);
}
if (matchesAny(pathname, PUBLIC_PATHS)) {
return NextResponse.next();
}
@@ -41,10 +61,6 @@ export function proxy(request: NextRequest) {
return NextResponse.next();
}
const hasSessionCookie =
request.cookies.has("authjs.session-token") ||
request.cookies.has("__Secure-authjs.session-token");
if (!hasSessionCookie) {
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {