Vorbedingung seit dem 24.08.2026 erfüllt.
BLOCKER_ENVIRONMENT_SEPARATIONist durch die owner-freigegebene, VPS-lokale Datengrenze aufgelöst (§1, Nachtrag) — für ein cloudseitiges Staging gilt die Sperre unverändert fort (docs/plans/2026-08-20-sprint-6-staging-blocker.md). Wer gegen ein anderes Ziel als den VPS-Stack deployt, prüft §1 zuerst — auch „nur einmal zum Schauen".
Zielplattform geändert am 21.08.2026. Kanonisch ist seither Confluence „EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + Docker" (Seite 30998530): primär eigener VPS mit Coolify und Docker/OCI, sekundär Railway, Cloudflare Workers ist kein Zielruntime mehr. Die Cloudflare-Artefakte im Repository bleiben vorerst stehen und werden erst nach belegter Container-Parität entfernt (EYT-149) — sie sind historische Implementierungsevidenz, kein Deploymentweg.
Das Runbook entsteht vor dem ersten Deploy mit Absicht: es macht die Owner-Entscheidung billiger, weil danach nichts mehr erfunden werden muss. Alles hier Beschriebene ist aus Artefakten abgeleitet, die im Repository stehen. Was nicht gemessen ist, ist als solches markiert — dieses Dokument behauptet keinen Vollzug.
Ein Deploy ist erst zulässig, wenn alle drei Bedingungen erfüllt sind:
-
Die Pflicht-CI ist am exakten Head grün, einschließlich des Container-Smokes aus §6.
-
Eine eindeutig als NON-PRODUCTION identifizierte EasyTree-Datengrenze existiert und ist nachgewiesen. Erfüllt seit dem 24.08.2026 durch die owner-freigegebene VPS-lokale Datengrenze (Nachtrag unten); für ein cloudseitiges Staging-Ziel weiterhin NICHT erfüllt. Für ein Cloud-Ziel prüfbar mit genau einer Frage:
Ist der project_ref des Ziels von inypnrvpawvhgiyagxbd verschieden?Nein → abbrechen. Die Messung ist eine Control-Plane-Beobachtung (
list_branchesbzw.list_projects), nicht eine Abfrage aus der Anwendung und nicht übereasytree_app.Nachgemessen am 21.08.2026:
list_projectszeigt genau ein Projekt („Bazodiac",ykoijifgweoapitabgxx) und kein EasyTree-Projekt;list_branchesaufinypnrvpawvhgiyagxbdzeigt genau eine Branchmainmitproject_ref == parent_project_ref == inypnrvpawvhgiyagxbd. Die einzige Branch ist die Produktion. Für ein Cloud-Ziel gilt diese Messung unverändert; die heute erfüllte Grenze ist die VPS-lokale aus dem Nachtrag unten.Nachtrag 24./25.08.2026 — die Bedingung ist auf anderem Weg erfüllt. Auf Owner-Auftrag vom 24.08.2026 ist die Datengrenze ein VPS-lokaler Supabase-CLI-Stack auf srv1308064.hstgr.cloud: er hat keinen Cloud-
project_ref, die Gate-Frage ist trivial erfüllt, Produktionsdaten sind unberührt. Ein zweites Supabase-Cloud-Projekt existiert weiterhin nicht (Free-Quota-Lage unverändert); für ein cloudseitiges Staging gilt dieser Abschnitt fort.Und der kostenlose Weg dorthin ist versperrt. Ein Versuch, ein zweites Projekt
easytree-staginganzulegen (get_cost→ 0 $/Monat), wurde mit einem Quota-Fehler abgelehnt: die Free-Projekt-Quota zählt pro Nutzer über alle Organisationen hinweg, in denen er Owner oder Admin ist, undDYAI2025hat sie mit zwei aktiven Free-Projekten ausgeschöpft — eines davon ist die Produktion, das zweite ist über diesen Zugang nicht sichtbar. Ein pausiertes Projekt zählt laut Supabase-Doku nicht mit; das Pausieren oder Löschen von „Bazodiac" hilft deshalb nicht. Die nicht-destruktiven Auswege stehen im Blockerbericht, Nachtrag 2. -
Owner-Freigabe für den Deploy liegt vor. Ohne (2) gegenstandslos.
Fail-closed: Wer (2) nicht positiv belegen kann, hat sie nicht. „Vermutlich Staging" ist kein Nachweis; die Konsequenz eines Irrtums ist ein Schreibzugriff auf die Produktionsdatenbank.
Zwei OCI-Images aus derselben geprüften Codebasis, beide mit im Repository liegender Buildbeschreibung:
| Workload | Dockerfile | Startbefehl | Port | Veröffentlicht |
|---|---|---|---|---|
easytree-api |
apps/api/Dockerfile |
node dist/main.js |
3001 | nein |
easytree-web |
apps/web/Dockerfile |
node apps/web/server.js |
3000 | ja |
Der Hintergrundprozess (Outbox) benutzt dasselbe API-Image und überschreibt nur das Kommando
mit node dist/worker.js; er öffnet keinen Port. Zwei Images für einen Modulgraphen wären zwei
Stellen, die auseinanderlaufen können.
docker-compose.yml im Wurzelverzeichnis beschreibt die Topologie: nur web veröffentlicht einen
Port, api hängt am internen Netz und ist unter dem Dienstnamen api erreichbar. Der Browser
sieht deshalb genau eine Origin und niemals eine interne Adresse.
Build ist reproduzierbar oder er ist keiner. Beide Dockerfiles installieren mit
pnpm install --frozen-lockfile gegen das eingecheckte pnpm-lock.yaml und aktivieren pnpm über
corepack aus dem packageManager-Feld (pnpm@10.28.0). Weicht der Lockfile-Stand ab, bricht
der Build ab, statt aufzulösen.
Railway bleibt kompatibel. Dieselben Dockerfiles, dieselben Startbefehle, dieselben Variablen. Nichts an diesem Weg führt providergebundene Domain- oder Businesslogik ein — Coolify ist Orchestrator, nicht Teil der Facharchitektur.
Der kanonische Variablensatz ist sieben Einträge (packages/config/src/schema.ts,
ENV_VAR_META). Die Trennung ist nicht kosmetisch:
Nicht geheim — dürfen in der Compose-/Coolify-Konfiguration stehen:
NODE_ENV, API_PORT, LOG_LEVEL
Geheim — werden ausschließlich als Plattform-Secret gesetzt, NIE in eine Datei im Repository
und NIE in ein Image:
DATABASE_URL, SUPABASE_URL, SUPABASE_ANON_KEY, DATABASE_SSL_ROOT_CERT
DATABASE_SSL_ROOT_CERT ist in production Pflicht, nicht optional. In .env.example steht
es deshalb auskommentiert — ein Platzhalterwert dort wäre schlimmer als keiner, weil er
funktionierend aussähe. Der Container-Smoke belegt das negativ: mit NODE_ENV=production und
ohne Wurzelzertifikat verweigert der API-Container den Start und nennt die Variable.
Über plain HTTP scheitert jeder Schreibvorgang im Browser still: die
Idempotenzschlüssel-Erzeugung benutzt crypto.randomUUID(), und das existiert nur in einem
Secure Context (HTTPS oder localhost). Symptom: Formular gefüllt, Klick ohne Wirkung, kein
Request im Netzwerkprotokoll, pageerror: crypto.randomUUID is not a function. Lesen und Login
funktionieren — genau deshalb fällt es erst beim ersten Schreibversuch auf. Konsequenz: eine
Staging-Oberfläche wird ausschließlich über HTTPS abgenommen.
EASYTREE_API_PROXY_TARGET ist keine Anwendungsvariable — sie steht bewusst nicht in
ENV_VAR_META — sondern die Naht zwischen Web und API. Sie wird beim START des
Web-Containers gesetzt und bei jeder Anfrage neu gelesen.
Das ist gemessen, nicht vermutet (22.08.2026, Next 16.2.11, EYT-126): der Same-Origin-Proxy
liegt in Route Handlern (apps/web/app/api/[[...pfad]], app/health, app/ready), nicht mehr
in next.config.ts-rewrites(). Der Container-Smoke baut ein Web-Image, hält seinen Digest
fest und startet denselben Digest zweimal gegen verschiedene APIs:
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:191a1af9… nachher=sha256:191a1af9…
Eine frühere Fassung dieses Abschnitts sagte das Gegenteil, und sie war für ihren Stand
korrekt: ein Web-Build mit http://buildtime-marker.invalid:9999, mit einem anderen Wert
gestartet, antwortete auf /health mit HTTP 500 und … ENOTFOUND. Das galt dem
rewrites()-Weg, den es nicht mehr gibt.
Drei Konsequenzen, die vor dem ersten Deploy bekannt sein müssen:
- Das Web-Image ist weder an ein Ziel noch an einen Anbieter gebunden. Für Coolify und für
Railway wird dieselbe Datei mit demselben Startpfad gebaut, und es gibt kein
--build-arg, das sich unterscheiden könnte. Der Wert gehört in die Laufzeitumgebung des Web-Dienstes. - Eine vergessene Laufzeitvariable fällt den Serverstart:
apps/web/instrumentation.tsprüft sie einmal beim Hochfahren, und danach beantwortet Next jede Route mit 500 — auch/und/anmelden. Kein stilles Zurückfallen auf localhost. Ehrliche Grenze: der Prozess bleibt am Leben und hält den Port, der Compose-Healthcheck auf/schlägt fehl und der Container gilt alsunhealthy. Sag nicht, der Container starte nicht. - Der Browser sieht die Adresse nie. Sie ist ausdrücklich kein
NEXT_PUBLIC_*; die Weiterleitung passiert serverseitig im Web-Container. Der Container-Smoke prüft das ausgelieferte HTML, jeden referenzierten Client-Chunk, die Antwortköpfe und denlocation-Kopf dagegen — eine absolute Weiterleitung der API auf sich selbst wird in einen relativen Pfad übersetzt (Pfad und Query bleiben erhalten), ein externes Weiterleitungsziel bleibt unangetastet. Jeder übrige Antwortkopf, dessen Wert die interne Adresse nennt, fällt ersatzlos weg —X-Upstream-Url,Link: <…>; rel="self",Content-Location, einSet-Cookiemit internerDomain. Nicht umgeschrieben, sondern weggelassen: ein Kopf ohne festgelegte Bedeutung trägt keine Struktur, aus der sich eine Übersetzung ableiten ließe. Fremde Adressen bleiben stehen, und mehrereSet-Cookiebleiben mehrere. Getroffen wird die Adresse, nicht das Wort: Kopfnamen werden nicht geprüft, URLs im Wert über ihre Origin verglichen,host:portnur an einer Zeichengrenze und nur bei ausgewiesenem Port, der nackte Hostname nur als ganzer Wert. Sonst verschluckte der Riegel bei der Topologiehttp://api:3001jedesX-Api-Version, jede Doku-URL aufapi.example.orgund jedes Cookieapi_session. Der Smoke belegt beide Richtungen mit einem Stub, der sowohl den leckenden als auch die harmlosen Köpfe nachweislich sendet — sonst wäre weder die Abwesenheit der einen noch die Anwesenheit der anderen ein Nachweis.
Drei Regeln zur Datenbankverbindung, die aus gemessenen Fehlern stammen und nicht verhandelbar sind:
- Keine SSL-Parameter in
DATABASE_URL. Die Verbindung wird ausschließlich inapps/api/src/platform/database/pg-connection.tsgebaut; die Factory entfernt die gesamte Query aus der URL. Grund:pgmerged den geparsten Connection-String über das explizitessl-Objekt, womit?ssl=no-verifyjede Denyliste schlägt. Statische Wächter inapps/api/test/pg-connection.test.tsgehen rot, wenn jemand daran vorbei baut. - Kein
rejectUnauthorized=false, kein No-Verify, keine privilegierte Rolle, kein BYPASSRLS. DATABASE_URLverbindet alseasytree_app, nicht alspostgres. Das EYT-45-Startgate weigert sich zu booten, wenn die Rolle nicht als RLS-gebunden nachweisbar ist — fail-closed und gewollt. Gegenprobe:bash scripts/smoke-api-role-gate.sh.
Migrationen zuerst, Anwendung danach. Weder Coolify noch Railway sind eine zweite
Migration-Authority — Eigentümerin des Schemapfads bleibt die Supabase-GitHub-Integration.
Zwei Wächter erzwingen das, beide in apps/api/test/deploy-authority.test.ts: der erste liest die
wrangler.jsonc, der zweite die beiden Dockerfile und docker-compose.yml. Ruft dort ein
Build- oder Startbefehl ein Schemawerkzeug auf, gehen sie rot.
-
Schema. Migrationen aus
supabase/migrations/auf das Staging-Ziel anwenden — in Dateireihenfolge, vollständig, fail-closed. Kein Dashboard, kein Studio, keine Handänderung. -
Verifikation vor der Anwendung. Anzahl angewandter Migrationen gegen
ls -1 supabase/migrations/*.sql | wc -lprüfen. Abweichung → abbrechen. -
Images bauen — an den exakten Commit gebunden:
SHA="$(git rev-parse HEAD)" docker build -f apps/api/Dockerfile --build-arg "GIT_SHA=${SHA}" -t "easytree-api:${SHA}" . docker build -f apps/web/Dockerfile --build-arg "GIT_SHA=${SHA}" -t "easytree-web:${SHA}" .
GIT_SHAhat in beiden Dockerfiles keinen Vorgabewert mehr und wird geprüft: ohne--build-argbricht der Bau mitGIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD).ab (gemessen 22.08.2026 für beide Images). Ein Image mitrevision=unknownist über diesen Pfad nicht mehr erzeugbar.Das Proxyziel wird hier nicht übergeben — es ist Laufzeitkonfiguration und steht beim Start:
docker run -d --name web -e EASYTREE_API_PROXY_TARGET=http://api:3001 "easytree-web:${SHA}"Beide Images tragen den Commit danach als OCI-Label
org.opencontainers.image.revision. Das ist das Rollbackziel aus §7 und der Beleg aus §8. -
Secrets setzen (§3), danach die Workloads starten.
-
Smokes fahren (§6), bevor irgendjemand die Oberfläche öffnet.
supabase/seed.sql ist synthetische Entwicklungsdatenlage mit festen UUIDs und ausdrücklich
für Nicht-Produktion gedacht. Auf Staging ist sie zulässig; sie war und bleibt auf Produktion
nicht angewandt.
Für einen bedienbaren Mandanten samt Anmeldung existiert
scripts/ops/bootstrap-demo-tenant.mjs — seit dem 26.08.2026 eingecheckt; die erste
Ausführung am 24.08.2026 gegen die echte Staging-Grenze (Bootstrap plus --verify grün und
idempotent, Protokoll vom 24.08., §Demo-Mandant) lief noch mit einer lokalen, nicht
eingecheckten Fassung. Die eingecheckte Fassung rekonstruiert genau dieses Verhalten und
ergänzt zweierlei: die Satzzählung ist auf den Demo-Mandanten eingegrenzt, und ein
Produktionsziel (Produktions-project_ref oder Supabase-Cloud-Host) wird fail-closed
verweigert. Aufruf: DATABASE_URL=… node scripts/ops/bootstrap-demo-tenant.mjs --user-id <uuid> [--verify] — keine Secrets im Repository, die Verbindung kommt
ausschließlich aus der Umgebung.
Die Testdaten der Kernreise (Org E2E Reiseorganisation …e201, Mitarbeiter
E2E-Mitarbeiter Reise …e211 mit Satz und E2E-Mitarbeiter Ohne Satz …e212 ohne
jede Satzversion, Baustelle …e241) sind kanonische Repository-Wahrheit:
apps/web/e2e/auth-journey/fixtures.sql. Ein frischer Staging-Aufbau erzeugt und verifiziert
sie so:
-
Beide Reisenden über den öffentlichen GoTrue-Signup anlegen (Anon-Key, kein Service-Role-Schlüssel) — genau wie
apps/web/e2e/auth-journey/global-setup.ts. -
Die Fixture mit den von GoTrue vergebenen UUIDs einspielen:
psql "$ADMIN_DB_URL" -v ON_ERROR_STOP=1 \ -v benutzer_a=<uuid-a> -v benutzer_b=<uuid-b> \ -f apps/web/e2e/auth-journey/fixtures.sql
-
Die Markerzeile lesen — sie ist der Verify, nicht der Exit-Code:
[auth-journey-fixture] … mitarbeiter=2 satz=1 satz_ohne=0 ….satz_ohne=0ist die definierende Eigenschaft der Satz-fehlt-Negativreise; jede Abweichung bricht das Skript mitE2E-Fixture unvollstaendigab.
Das Skript ist idempotent (on conflict do nothing bzw. für …e212 do update): ein
zweiter Lauf erzeugt nichts Doppeltes, und auf einem Stack mit Altbestand konvergiert er
Anzeigename und Aktiv-Status von …e212 auf den kanonischen Stand. Die Staging-Harness
benutzt den kanonischen Namen als Vorgabewert (EYT_SATZ_FEHLT_MITARBEITER ist seit dem
26.08.2026 optional und nur noch die bewusste Übersteuerung für abweichenden Altbestand) und
misst vor jedem Publish per Pre-flight über die echte API, dass der Mitarbeiter existiert,
aktiv ist und exakt null Satzversionen hat — fehlt die Fixture oder hat sie einen Satz
erworben, bricht die Reise ab, bevor eine frische Woche veröffentlicht und damit
verbraucht ist.
Niemals: Produktionsdaten kopieren, echte Personendaten einspielen, echte Stundensätze verwenden.
Reihenfolge ist nicht beliebig; sie geht vom Billigsten zum Teuersten.
| Symptom | Erste Frage | Werkzeug |
|---|---|---|
| Container startet nicht | Fehlt ein Secret? | Containerlogs; ConfigValidationError nennt die Variable, nie den Wert |
/ready = 503 |
Ist die DB erreichbar? | GET /ready liefert den Indikator, der unten ist |
| Boot bricht mit Rollenfehler ab | Verbindet DATABASE_URL als easytree_app? |
EYT-45-Startgate; das ist korrektes Verhalten, kein Defekt |
self-signed certificate |
Ist DATABASE_SSL_ROOT_CERT gesetzt und vollständig? |
§3; nicht mit No-Verify „lösen" |
| Leere Woche statt Fehler | Antwortet die API oder der Proxy? | EASYTREE_API_PROXY_TARGET — Laufzeitvariable des Web-Dienstes, §3. Fehlt sie, antwortet auch / mit 500 — dann ist es keine leere Woche |
| Web erreicht die API nicht | Liegen beide Workloads im selben internen Netz? | Dienstname muss aus dem Web-Container auflösbar sein |
| Publish schlägt fehl | Läuft die Verbindung über den Transaction-Pooler? | Erwartet: der Laufzeitkanal-Riegel greift — siehe unten |
Der Pooler-Fall ist wichtig und vorhergesagt, nicht überraschend. Über den Supavisor-
Transaction-Pooler wird session_user zu postgres.<tenant>, und app.is_runtime_channel()
lehnt dann ab: Veröffentlichen bricht (Null-Zeilen-Ausnahme in planning-write.repository.ts),
Snapshot-Erzeugen bricht (typisierter WRITE_CHANNEL_REJECTED), Lesen funktioniert weiter.
Wer das sieht, hat ein Verbindungsproblem — keinen Fachfehler. Details:
docs/runbooks/planning-publish.md.
Nicht gemessen: diese Ableitung stammt aus session_user plus den Policies; sie ist nicht
gegen eine echte Poolerverbindung verifiziert, weil pgTAP als postgres läuft.
NODE_ENV=test API_PORT=3001 EXPECT_READY=200 DATABASE_URL=… SUPABASE_URL=… \
SUPABASE_ANON_KEY=… bash scripts/smoke-api.sh # bootet dist, /health, /ready, SIGTERM
bash scripts/smoke-worker.sh # Worker oeffnet KEINEN Port, faehrt sauber herunter
bash scripts/smoke-api-role-gate.sh # API startet NICHT, wenn die Rolle nicht RLS-gebunden ist
# Container-Smoke (EYT-126): baut beide Images, startet die Topologie und
# prueft Health/Readiness, den internen Weg Web -> API, die Abwesenheit der
# internen Adresse im HTML, geheimnisfreie Protokolle und das Verweigern des
# Starts im Produktionsprofil ohne Wurzelzertifikat.
EASYTREE_CONTAINER_SMOKE=required GIT_SHA="$(git rev-parse HEAD)" \
EASYTREE_SMOKE_DATABASE_URL=… EASYTREE_SMOKE_SUPABASE_URL=… EASYTREE_SMOKE_ANON_KEY=… \
bash scripts/smoke-container.shDer Container-Smoke läuft in CI am Ende von db-gates — dort, wo es eine echte PostgreSQL-Instanz
mit der Rolle easytree_app gibt. Er meldet eine greppbare Zeile, und die ist die Aussage,
nicht der grüne Haken:
[container-smoke] mode=required executed=… passed=… skipped=0
Ein fehlgeschlagener Pflicht-Smoke verhindert die Abnahme. Er wird nicht wiederholt, bis er grün ist — er wird verstanden.
Ein Rollback ist die Rückkehr zum zuletzt grünen Image, nicht ein Rebuild aus einem älteren
Commit. Weil beide Images mit dem Commit getaggt und zusätzlich per OCI-Label
org.opencontainers.image.revision gekennzeichnet sind, ist das Ziel maschinell benennbar:
docker inspect easytree-api:<sha> --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'In Coolify heißt der Weg „vorherige Bereitstellung erneut aktivieren"; ohne notierte Commit-SHA
(§8) gibt es kein Ziel zum Zurückkehren. Ausgeführt am 25.08.2026 (Evidenz:
docs/evidence/2026-08-25-eyt-142-staging/README.md): Kandidat 164f1a1 via Coolify gestoppt,
Vorgänger 3b4bbdb gestartet und über OCI-Revision plus /ready=200 verifiziert (32 s), danach
der Kandidat wiederhergestellt und erneut verifiziert (42 s). Einschränkung, ehrlich benannt:
der Vorgänger stammte aus einem Nicht-Coolify-Deploy, geübt ist deshalb der Weg „Coolify-Stop →
docker start des Vorgängers → Coolify-Start" — Coolifys eigene Redeploy-Historie greift erst,
sobald auch der Vorgänger über Coolify deployt wurde.
Fällt der VPS als Plattform aus, ist Railway der dokumentierte Fallback: dieselben Dockerfiles, dieselben Startbefehle. Das ist der Grund, warum die Railway-Kompatibilität in diesem Sprint ausdrücklich nicht angetastet wurde.
Das Datenbankschema wird nicht destruktiv zurückgerollt. Migrationen sind append-only, sobald sie gemergt sind. Eine fehlgeschlagene additive Migration wird mit einer neuen Vorwärts-Migration repariert, nie durch Editieren oder Löschen der alten.
Daraus folgt die Regel, die man vor dem Deploy kennen muss, nicht danach:
Anwendung und Schema rollen nicht gemeinsam zurück. Ein App-Rollback auf eine Version, die eine neuere Spalte noch nicht kennt, ist unkritisch, solange die Migration additiv war — genau deshalb sind additive Migrationen die Auflage. Ein Rollback auf eine Version, die eine entfernte Struktur braucht, ist kein Rollback, sondern ein Ausfall.
Gemessen am 24./25.08.2026: die Staging-Datengrenze ist per supabase db reset vollständig
aus Migrationen + Seed reproduzierbar (18/18 Migrationen, ausgeführt 24.08.), und der
App-Rollback samt Wiederherstellung ist ausgeführt (§7.1). Eine zusätzliche Grenze bleibt
bestehen: veröffentlichte Planversionen, Zuweisungen und Snapshots sind per DB-Trigger
(app.reject_published_row_change) unveränderlich — auch für Superuser. Ein selektiver
Teardown veröffentlichter Stände ist konstruktiv unmöglich; wer eine Woche „zurücksetzen"
will, braucht eine neue Woche oder den vollen Reset.
Ohne diese fünf Angaben ist ein Rollback nicht ausführbar und eine Abnahme nicht prüfbar:
- Commit-SHA des deployten Standes und der CI-Lauf, der an genau diesem Head grün war.
- Image-Tags und OCI-Revision beider Images — das Rollbackziel.
- Migrationsstand des Ziels (Anzahl und letzte Version) vor und nach dem Deploy.
project_refdes Ziels, ausgeschrieben — der Beleg, dass §1 (2) eingehalten wurde.- Smoke-Ergebnisse aus §6, einschließlich der
[container-smoke]-Zeile.
Ausgeführt am 25.08.2026, mit zwei Abweichungen von der Beschreibung unten: angelegt wurde
ein Coolify-Service (Projekt easytree, Umgebung staging) aus einer eigenen Compose-Datei
(/opt/easytree-staging/coolify-compose.yml), die die GHCR-Digests zieht statt zu bauen —
Coolify baut auf diesem Server nichts (2,6 GB freier RAM, siehe Restarbeit-Plan Phase 2). Und die
Topologie folgt dem GoTrue-Issuer-Fix vom 25.08. (API/Worker im Host-Netz), nicht der
Bridge-Topologie der docker-compose.yml. Registry-Zugangsdaten waren nicht nötig: die
GHCR-Pakete sind anonym ziehbar. Der Rest dieses Abschnitts beschreibt weiter den Weg für ein
Ziel mit production-Profil.
- Ressourcentyp: „Docker Compose" mit dieser Repository-Quelle und
docker-compose.ymlaus dem Wurzelverzeichnis. Coolify baut damit beide Images selbst aus dem gepinnten Lockfile. - Branch/Commit: ausdrücklich der geprüfte Head, nicht „latest". Coolify zeigt den deployten Commit an; er muss mit §8 (1) übereinstimmen.
- Laufzeitvariable:
EASYTREE_API_PROXY_TARGETauf den internen Dienstnamen der API (http://api:3001in der Compose-Topologie). Kein öffentlicher Name, keinhttpsnach außen. Ausdrücklich als Environment-Variable des Web-Dienstes, nicht als Build-Argument: das Image ist seit EYT-126 zielneutral, und ein Build-Argument würde es wieder binden. - Build-Argument: ausschließlich
GIT_SHA(der geprüfte Head).docker-compose.ymlerzwingt es mit${GIT_SHA:?…}— ohne den Wert scheitert schondocker compose config. - Secrets: die vier geheimen Variablen aus §3 als Coolify-Secrets, nicht als Build-Argumente — ein Build-Argument landet in der Imagehistorie.
- Öffentliche Domain: ausschließlich auf
web(Port 3000). Die API bekommt keine öffentliche Domain; sie ist nur im internen Netz erreichbar. - Healthcheck: die Compose-Definition prüft
/ready(API) bzw./(Web) mitnode -eund globalemfetch— die Laufzeit-Images enthalten bewusst wedercurlnochwget.
Railway ist über Dashboard-Variablen gegen den generischen Startpfad konfiguriert; es gibt
keine Railway-Datei im Repository, und dieser Slice legt auch keine an. Kompatibel bleibt der
Weg, weil die Images nichts Providerspezifisches enthalten: derselbe node dist/main.js,
dasselbe Variablenset, derselbe Health-/Readiness-Vertrag. Seit EYT-126 unterscheidet sich nicht
einmal mehr das Web-Image: der Wert von EASYTREE_API_PROXY_TARGET ist eine Laufzeitvariable des
Dienstes (§3), also dasselbe Image für Coolify und Railway — belegt über denselben
Image-Digest gegen zwei Ziele im Container-Smoke.
Nicht gemessen: ein Railway-Deploy aus diesen Dockerfiles ist nicht ausgeführt worden. Belegt ist die Kompatibilität auf der Ebene „reproduzierbarer Container- und Startpfad", nicht auf der Ebene „läuft dort".
Überarbeitet am 22.08.2026 (EYT-126) auf das Laufzeit-Proxyziel, ausgehend von der Fassung vom
21.08.2026 auf den Containerpfad (Confluence 30998530). Abgeleitet aus: den beiden Dockerfiles,
docker-compose.yml, scripts/smoke-container.sh, packages/config/src/schema.ts,
pg-connection.ts, den bestehenden Smoke-Skripten und dem lokal ausgeführten Container-Smoke
([container-smoke] mode=local executed=27 passed=27 skipped=0, 22.08.2026), der ein Image
gegen zwei Ziele, das Nicht-Lecken der internen Adresse in gewöhnlichen Antwortköpfen, das
unveränderte Durchreichen harmloser Köpfe mit demselben Wort und beide Fail-closed-Fälle
belegt.
Ausgeführt am 24.08.2026 (Erstdeploy, 3b4bbdb, docker-run-Weg) und am 25.08.2026
(Coolify-Deploy der GHCR-Digests von 164f1a1, reale Kernreise 14/14, Rollback-Übung).
Protokolle: docs/plans/2026-08-24-vps-staging-deploy-protokoll.md und
docs/evidence/2026-08-25-eyt-142-staging/README.md. Drei Korrekturen aus der Wirklichkeit
stehen jetzt hier: die VPS-lokale Datengrenze in §1, die HTTPS-Pflicht in §3, der ausgeführte
Rollback in §7. Weiterhin nicht gemessen: ein Railway-Deploy (§10) und Coolifys eigene
Redeploy-Historie als Rollbackweg (§7.1, Einschränkung).
Korrigiert am 26.08.2026: §1 (2) trug noch die Bewertung „Heute nicht erfüllt" aus dem Stand vor dem 24.08., während der Nachtrag im selben Abschnitt die erfüllte VPS-lokale Grenze beschrieb — zwei sich widersprechende Gegenwartsaussagen in einem Abschnitt. Jetzt steht dort eine: VPS-lokal erfüllt, cloudseitig weiterhin offen (Quota-Lage unverändert, Blockerbericht Nachtrag 2). Die historischen Messungen vom 21.08. bleiben als solche stehen.