The one seam — merge ≠ deploy
The gates buy the trust to auto-merge — into stage, where merging changes the code of record and ships nothing. Quality buys speed; deploy stays intentional, on main. ↓ the full model next.
The branch model — stage integrates, main promotes (per-service gated deploy)
stage, gated by CI only, never deploys) and one branch that ships (main, promotion-only). Keep merging work all day; deploy when you decide, one service at a time.Read it left→right: every feature branch opens a PR into stage; the CI gate is the only thing that decides the merge; stage accumulates the whole line and never deploys. When a service is ready, you promote it to main and flip that one service's deploy flag — site/docs deploy freely (your interface stays live), services deploy only when their DEPLOY_<SVC>_ENABLED var + COOLIFY_HOOK_<SVC> secret are set. The pipeline never blocks on deploy again.
The promotion ritual — stage → main, one service at a time
stage; green CI is the gate. No deploy fires, ever. the pipeline runs at full speedstage locally / a preview, prove the change end-to-end (build+run+curl+docker). this is where "works on stage" is earnedvars.DEPLOY_<SVC>_ENABLED=true + secret COOLIFY_HOOK_<SVC> (once per service). arms exactly one appstage → main. On push, each service's deploy job fires its Coolify webhook only if its flag is on + its paths changed. one push, only the armed + changed apps redeployOne-time console setup — what actually unblocks the decouple
main via the first promotion, the immediate decouple is purely Coolify-console — no merge needed:site app (or set DEPLOY_SITE_ENABLED=true once #45 lands). your build-log/interface keeps updating liveCOOLIFY_HOOK_<SVC> deploy-webhook secret + set DEPLOY_<SVC>_ENABLED=true, then promote. per-service, reversible, intentionalStage as a deployed environment
stage a CI-only line ("stage NEVER deploys"). This is the evolution: stage becomes a deployed integration environment — every new or changed service is proven on a live stage first, then promoted to prod. This section defines how, so a service is never wired into production by accident.The design decision — isolation must be structural, not a branch flip
By design, the platform starts on one Docker network with the prod domains — the minimal footprint for a solo operator, chosen deliberately. Adding a stage environment therefore carries an explicit, documented decision: isolation must be structural, because a service "deployed to stage" on the prod network would otherwise be reachable by prod, and vice-versa. Concretely, the rejected shortcut is pointing the existing prod gateway app at the stage branch — that does not create a stage environment; it makes the prod Model gateway (the one live product clients call) run stage code on the prod domain and prod network, conflating the two. The intended end-state below gives stage its own network and domains, so the two environments never touch — a stage that shares prod's network and domains would not be a stage at all.
stage branch to test." That doesn't add an environment — it replaces prod with stage code, on the domain live products depend on. Isolation has to be structural (a separate network + separate domains), not a branch flip on a prod app.The design — isolated, cheap for a solo builder
| Pillar | Prod | Stage |
|---|---|---|
| Network no cross-talk | the internal network | an isolated stage network — docker network create, once |
| App set + branch prod apps NEVER repointed at stage | Coolify apps watch main, on the internal network | separate Coolify apps watch stage, on an isolated stage network |
| Domains wildcard already → VPS; auto-TLS | the prod service domain | a stage.-prefixed domain — internal-only services (the model runtime) need none |
| Container names so they resolve each other, no collision | Model gateway · model runtime | suffixed -stage: gateway-stage · runtime-stage |
| Databases | prod managed-Postgres DB | a copy-on-write branch of prod — instant, cheap, isolated. Stateless services (gateway, runtime) get none |
Two worlds, one VPS: prod apps watch main on the internal network and are never repointed at stage; stage apps watch stage on an isolated stage network, get stage.-prefixed domains, and resolve each other by their -stage names. The dashed wall is the whole point — no container on one network can reach the other. Promote-to-prod is the existing gated-deploy model with a stage rehearsal in front: verify on stage → promote stage→main for that one service + flip its DEPLOY_<SVC>_ENABLED gate → the prod app deploys.
stage branch, isolated stage network), verify, promote, then remove it. Prod (frozen Identity service + Model gateway) is never touched. Run a full parallel stage only when you genuinely need it; until then, ephemeral-per-service is cheaper and just as isolated.Runtime-first walkthrough — the concrete first case
stage branch, on the isolated stage network. Internal only, no domain. the model runtimestage branch, on the isolated stage network, its runtime URL pointed at the stage runtime → a stage domain. the gateway, wired to the stage runtimecurl the stage gateway /api/text/health → "runtime":"ok"; then a real /api/text/complete with {"provider":"self-hosted"}. the round-trip on real infraruntime:ok. same gated promotion, now proven firstruntime:skipped reading is the signal it is misplaced). (b) Prod stays on the main branch with a separate stage gateway standing up for stage work — the prod gateway is never repointed to run stage code. Both are documented guardrails that keep the two environments distinct by construction, not one-off fixes.Stage stops being a promise the CI makes and becomes a place you can curl — isolated by its own network and its own domains, so proving a service never risks the ones a stranger is paying for.
The deploy sequence — dependency-ordered, one button (CD ≠ CI)
stage); CD is this — one manual, dependency-ordered workflow (deploy-all.yml) that fires the per-service Coolify webhooks tier-by-tier, health-gated. Template lifted from a release-workflow pattern (release.yml): workflow_dispatch (never auto), per-job if: vars.<FLAG>, needs: for order.One press deploys everything armed, in the right order: Identity service + Model gateway first (the foundation a product would 404/500 against if it weren't up), each health-gated before the product tier starts; products next (they call the Identity service /me+billing+email and the Model gateway /text); site last. A tier proceeds when its deps succeeded or were skipped (flag off) — never when one failed, so a half-broken foundation can't drag a product live. Scaffolded as .github/workflows/deploy-all.yml.
CI proves the code; CD ships it — deliberately, in dependency order, one button, one service's gate at a time.
Failure modes & recovery — what the gates don't catch
| Failure mode | What happens | Recovery |
|---|---|---|
| Health probe fails post-deploy | A tier's /health · /health/{db,payments} returns non-200 or degraded after its Coolify webhook fires. | The tier's promotion halts and the service rolls back to the previous good image (kept by tag). Because tiers are health-gated in dependency order, the product tier never starts against a dead foundation — products can't land on a broken spine. |
| Rollback itself fails | The previous image will not come healthy — a data-shape or infra fault below the app layer. | An alert fires and the run stops at a documented break-glass manual revert (redeploy a known-good tag by hand, or take the app behind a maintenance page). No further tier promotes until a person clears it. |
| A semantic bug passes every automated gate | Lint · tests · contract · docker-boot · e2e are all green, but the change is wrong in intent — plausible-looking, incorrect behaviour. | Caught before merge by the adversarial multi-agent review pass on money-path / auth changes, and after by a postmortem loop that turns each escape into a new regression test. Human judgement is the backstop — the automated gates are the floor, not the ceiling. |
Auto-merge to stage + automated remote testing — the verification rungs
stage is in place — auto-merge the integration line behind green gates, then verify against a real remote environment before anything is promoted to production.Auto-merge to stage on green CI
PRs into stage squash-merge themselves the moment their CI is green — safe because stage never deploys; auto-merge only advances the integration line. Mechanism: .github/workflows/automerge-stage.yml — an Action that fires when a CI workflow completes, finds the matching green PR targeting stage, and squash-merges it (skips drafts + hold/wip/do-not-merge labels, and waits until every check is green).
⚠️ Constraint surfaced (a Sam decision): GitHub's native auto-merge + branch protection (required status checks) need GitHub Pro or a public repo — they 403 on a free private repo. So we use the Action path, which works on a free private repo via the workflow GITHUB_TOKEN. (workflow_run Actions run from the default branch, so this file ships to main with the CI/CD infra and governs PRs into stage.) If you'd rather have native auto-merge + protected branches, that's the one upgrade that unlocks it.
Automated remote testing on the stage environment — smoke + e2e (next)
The design intent: once the stage environment exists, enable autonomous testing on a remote dev-test target — smoke + e2e. Today verification is local + mocked (the dev-test stacks). The next rung stands up a stage/preview deploy of the line, then runs against its live URLs, automatically, before promotion:
/health green + one real round-trip (Identity service passwordless sign-in {sent:true} · Model /text · a product call). proves the wiring on real infra, not a mockstage→main. "works on stage" is then machine-verified, not assertedThis turns the dashed rungs 4–5 of the pipeline (drawn above) real: verification moves from mocked-local to real-remote and automated, and nothing is promoted to production that hasn't proven itself on a live stage first — promotion of the money spine still waits on a human.
The gate ladder — the "thorough quality check"
stage clears, with its cost and what it catches. Cost-ordered, fail-fast; the green ladder is what lets agents auto-merge to stage unattended.openapi.yaml & git diff --exit-code. ~5s · style drift + code/spec disagreementpytest --cov --cov-branch --cov-fail-under (spine 90 / products 80) + diff-coverage (the patch must meet the floor — kills "ship a feature, skip its tests"). 10–60snot_a_server_error + response_schema_conformance). Extend the Identity service's gate to the products + Model gateway. 30–90s · no endpoint can 500 or liehealthy (catches the IPv4/IPv6 bind class behind the Identity service's old 502). 1–3 min · path-scoped/api/health + /health/{db,payments}. ~30s · post-deploy · turns "green CI" into "green prod"Rungs 0–4 are pre-merge (the trust); rung 5 is post-deploy and decoupled. Anti-coverage-theater: per-service floors (not a repo average that lets the spine hide an untested product), branch coverage, diff-coverage as the real teeth, and the omit/pragma config itself reviewed on every PR (the "ignore the auth tests" failure mode).
Reality check — the core subset vs the fuller map
This page describes the delivery model in full; deliberately, it is grown to fit real load rather than gold-plated ahead of it — so a focused core subset carries the weight and the rest is staged until concurrency and traffic justify it. Here is the core set of CI workflows the design defines under .github/workflows/:
✓ core of the design: the identity spine plus its full gate set · the path-filtered CI (spine · services ruff+matrix · the site design gate) · automerge-stage.yml + the one-button deploy-all.yml (activated from the default branch, since workflow_run/CD fire there). ○ fuller map: a merge queue, CODEOWNERS, services.yml docker-smoke rigor, and remote-stage smoke+e2e.
stage line — activated once promoted to main, since workflow_run/CD fire from the default branch): the stage→main branch model (deploy-gate vars defaulted site-ON/services-OFF), auto-merge to stage on green (automerge-stage.yml), and the one-button dependency-ordered CD (deploy-all.yml + per-service DEPLOY_<SVC>_ENABLED gates, lifted from a release-workflow pattern). Fuller map: a merge queue, CODEOWNERS, services.yml docker-smoke parity with the spine, and the e2e harness promoted to a real CI job on a remote stage env. Read "queue / preview / rollback / remote-e2e" as the fuller map; "auto-merge / gated deploy / branch model" as the core.stage (merge freely, no deploy) · site/ stays live on main via the hourly state-refresh · keep the products and the spine green · add a CODEOWNERS gating core/ (the last cheap pre-revenue gate). The immediate console step is the one true unblock: Coolify git-auto-deploy OFF on the service apps, ON for site.How we got here — options & best practice, condensed
| Option explored | Verdict |
|---|---|
| 1 · Incremental hardening S close the gaps on today's 3 workflows (cache · services rigor · auto-merge · probe) | The right move now (≤4 services); copy-paste won't scale past ~4, no cross-service gate. |
2 · One reusable workflow_call pipeline, matrix-called Mthe reusable-workflow pattern, + a ci-gate aggregator | DRY — tighten a gate once, all inherit; add a service = one line. Adopt when a 3rd/4th service makes copy-paste hurt. |
| 3 · Merge queue + affected-only M re-test each PR vs main-as-it-will-be before landing | Eliminates the stale-but-green-breaks-main failure; overkill until the agent flood is genuinely concurrent. |
Explored: incremental → reusable-matrix → merge-queue. Not either/or — they're the sequenced best-fit below, adopted in that order as scale demands.
2026 best practice — the cited verdict, 5 bullets
- Skip Nx / Turborepo / Bazel below ~5 services —
dorny/paths-filter(mirroring Coolify Watch Paths) is right at our size. - The gatekeeper-job is mandatory: required checks don't understand skipped path-filtered jobs (a skip sits "pending" forever). Fix = one always-running job that
needs:all conditional jobs and is the only required check. - Native merge queue + branch protection + native auto-merge would be the textbook tool for an agent PR flood (batches against future-main) — but they require GitHub Pro or a public repo and 403 on a free private repo. That constraint is exactly why we land via the Action-based
automerge-stage.yml(squash on green CI) instead. Mergify/Trunk are paid alternatives only if CI later becomes the cost bottleneck; Kodiak is unmaintained — avoid. - Keep the contract/Schemathesis gate required — contract/spec drift is the #1 failure mode of agent PRs that slips past unit tests; our DTO-drift + Schemathesis gate is exactly the guard, and green-on-it is what arms the auto-merge into
stage. - Tier trust by path (CODEOWNERS) + reusable
workflow_call+ pip caching +concurrency: cancel-in-progress+ quarantine flaky tests before trusting auto-merge.
The recommended design — sequenced best-fit
| When | Do (concrete) | Why this slice |
|---|---|---|
| Now S · pre-revenue-safe | cache: pip + existing concurrency · a CODEOWNERS gating core/ (the enabler — makes Tier-A promotion real) · auto-merge into stage (automerge-stage.yml) · keep the products green | cheap, safety-first — the ONLY CI work that earns its keep before a stranger pays |
| Soon post-first-dollar · M | the gatekeeper-job as the one required check + native auto-merge (Tier B) · a contract gate on the products · path-scoped docker-smoke · the reusable service-ci.yml matrix | when a 3rd/4th service makes copy-paste hurt & auto-merge throughput matters |
| Later when-concurrent · M | e2e-in-CI (promote the harness) · coverage floors + diff-coverage · the GitHub merge queue + flaky-quarantine · the Model gateway matrix leg | ~0 value until agents truly collide on main — sooner is infra-before-income |
stage, which never deploys, so merging is consequence-free. Into stage: all PRs auto-merge on green CI — green is the standing approval (automerge-stage.yml; skips hold/wip/do-not-merge). Promotion stage→main (the actual deploy decision) is where "never merge without permission" lives: Tier A — money spine & control plane (core/** · .github/** · deploy/secret config · *.env*) needs explicit per-service human approval + a flipped DEPLOY_<SVC>_ENABLED gate; Tier B — low-risk surfaces (site/** · thin off-money-path services) flows to main freely (the live-site/-push grant). CODEOWNERS remains the Tier-A enabler (still to add).Deploy & freeze — the intentional, dependency-ordered ship
stage; shipping is a separate, deliberate act — the one-button, dependency-ordered deploy-all.yml drawn in The deploy sequence above. Coolify git-auto-deploy is OFF for every service app, so that manual workflow is the only thing that ships a service (each job armed by vars.DEPLOY_<SVC>_ENABLED + secret COOLIFY_HOOK_<SVC>, ordered by needs: + a health gate); site/ alone stays live on main via the hourly state-refresh.The freeze cycle — frozen by default, promoted deliberately, re-frozen
DEPLOY_<SVC>_ENABLED var unset, so deploy-all.yml simply skips its job — it never ships, no matter what promotes to main. An intentional promotion is a three-step loop: flip the var on → run deploy-all → flip off.DEPLOY_<SVC>_ENABLED unset → its deploy-all job is skipped → the service never ships.=='true' for this one promotion; run deploy-all.yml from main — its job fires the Coolify webhook, health-gated.So merge moves stage; promotion moves main; deploy is a separate, manual, dependency-ordered, per-service-gated act — armed by a var, fired by a secret, ordered by needs:. Automated agents can flood stage with green PRs all night and nothing reaches prod until a deliberate deploy-all run says it may.
The controlled-change protocol — extending a frozen service
- Additive-only, on a
feat/branch intostage. A new endpoint / provider / field / migration — never a rewrite of the existing contract. Existing consumers must be unaffected. (The Identity service is OpenAPI-first → the DTO-drift + Schemathesis gates prove backward-compatibility; the Model gateway adds providers as one entry in_PROVIDERS, call sites unchanged.) - Full gate ladder green (the same ladder above): lint · tests · contract/Schemathesis · docker boot+health. For a money-path or auth change, an adversarial multi-agent review (a documented pre-merge verification pass) before merge.
- Intentional, flagged deploy. Promote
stage → main(merge ≠ deploy). To ship: flip that service'sDEPLOY_<SVC>_ENABLED→truefor the one deploy, rundeploy-all.yml, then flip it back OFF → re-frozen. Never a standing auto-deploy on a frozen service. - Re-verify live + record the new frozen commit. Health green, the new surface works, existing surface unchanged. The service is frozen again at the new commit.
Planned extensions (the only sanctioned changes to the frozen set)
| Service | Extension | Why / when |
|---|---|---|
| Identity service | Guest-checkout endpoint pair (guest-checkout-session + guest-verify-session) — additive | Unblocks a product's no-login checkout. Existing authed routes untouched; tests + contract green on the branch. |
| Identity service | Parallel live payments webhook | Console step; the additive money-path piece for live charges. |
| Model gateway | New providers via _PROVIDERS (e.g. another text model, or a vision model if a product needs it) | One additive entry each; call sites never change. Only when a product actually needs it — not speculatively. |
| Both | Dependency / security patch bumps | Same protocol — gated, intentional redeploy, re-freeze. Never an ad-hoc push. |
The everyday state of a frozen-infrastructure service is exactly that — frozen. Changing one is a deliberate, planned event, never an ad-hoc push: it is proposed, then executed by this protocol, then re-frozen.
The maturity roadmap — automated recovery under human oversight
The full PR → prod loop — automated path, human-gated deploy
The agent arms gh pr merge --auto on open (the ladder is the approval) → the merge queue rebuilds each candidate on main-as-it-will-be so two green agent PRs can't break main together → Coolify spins a preview at a per-PR subdomain against a fresh copy-on-write Postgres branch (destructive money-path tests on real-shaped data that vanishes on PR close) → the deploy-probe curls /health/{db,payments} + the passwordless-sign-in→checkout→webhook→plan-flip smoke; green clears the image for promotion — and the money spine still waits on a human to authorise the deploy. Mechanical where it's safe, human-gated where it's irreversible.
Automated recovery — what notices at 3am, rolls back, and hands a human the incident
degraded within 60s of promotiongh opens a revert PR of the bad merge; it rides the same queue outDetection is cheap; automated recovery is the goal — main reverted, prod rolled back, and a fix-it incident issue waiting for a human, before anyone woke up.
site/ + thin services, with core/ human-gated. The merge queue, Postgres-branch previews, the agent work-queue, and cost accounting are the genuinely-further-out 20% — build them only as concurrency and product count make them pay.The dev-test stacks & test doubles — documented
docker ps during a dev-test, named and labelled (so a stub is never mistaken for a real service). The mock-provider target is the house rule: fake at the seam, never our own services.| Container | What it is | Real / test-only |
|---|---|---|
| Model gateway | The real model-call gateway (Flask). Fake the model at the seam: run LLM_PROVIDER=mock (a contract-conforming mock provider in _PROVIDERS) — in-process, no stub container. | REAL service |
| Product client | A reference / e2e fixture, NOT a product — the minimal real consumer of the gateway + the canonical house-pattern example. The real text products are separate Product services. | REAL — reference/fixture |
An ad-hoc fake-HTTP model server — the wrong layer. Replace with LLM_PROVIDER=mock (fake at the provider contract, not a stub container). | RETIRE → mock provider | |
| Faked our OWN service — don't fake what we own. Replace with a REAL Identity service: the live deployment or a forked managed-Postgres test branch with test data. | RETIRE → real/forked spine |
LLM_PROVIDER=mock for CI (deterministic, no key) · a self-hosted runtime / an LLM provider for rich local testing · an LLM provider in prod. The Identity service is never faked. Stubs hold no state, carry no keys, never deploy. (skill: fake-external-deps-at-the-seam)