ADR-0190: One public origin, path-routed — and the deployment template must be executable
Date: 2026-08-20 Status: Accepted Deciders: Adrian (project owner), Claude Opus 5, Codex (review, PR #1173)
Context
deploy/eval/RUNBOOK.md described a three-subdomain deployment — workflow.,
api.workflow. and auth.workflow.savannah-ai.com — with deploy/eval/Caddyfile
defining one site block per subdomain, and deploy/eval/.env.prod.example setting
PUBLIC_APP_URL / PUBLIC_API_URL / PUBLIC_AUTH_URL accordingly.
That topology was never deployed. The live pilot serves a different hostname
from a single origin behind an existing nginx, with the app at /, FastAPI at
/api/ and /v1/, and Keycloak at /auth/. The divergence is not cosmetic: it is
the strongest available evidence that the procedure had never been executed, which
is what #994 alleges.
Worse, the pilot works partly on configuration that is not in the repository. A
docker-compose.override.yml on the box supplies the build args and
KC_HTTP_RELATIVE_PATH. So the deployment template and the running deployment had
diverged in both directions at once: the template described a shape nobody ran, and
the running shape depended on files nobody could check out.
Review of the first attempt to fix this (Codex, PR #1173) found that rewriting the prose was not enough. Measured against the repository's own artifacts, a partner following the corrected runbook on a clean host would still have failed:
| defect | consequence |
|---|---|
Caddyfile served three subdomains | the single documented DNS record gets neither a certificate nor a route |
.env.prod.example set the three old URLs, from which the overlay derives AUTH_ISSUER and KC_HOSTNAME | tokens issued for the wrong host; authentication broken |
no KC_HTTP_RELATIVE_PATH anywhere in the repo | Keycloak serves /realms/… while the proxy forwards /auth/realms/…; discovery, login and the issuer check all 404 |
no APP_ENV | Settings.app_env stays development, so assert_production_safe() returns without checking anything |
LLM_API_KEY declared twice in one mapping | docker compose config exits 1; the overlay is unusable the moment real secrets are supplied |
backups/wal_spool absent on a clean checkout | archive_command fails, WAL accumulates, the disk fills, no recovery set |
| seeded realm users carry zero credentials | the documented password-grant verification cannot pass |
/v1/peppol unrouted | the PEPPOL REST surface returns the frontend |
The duplicate-key defect deserves emphasis: it means this overlay has never been run to completion by anyone. Compose reports interpolation errors before YAML parse errors, so a missing secret masks it — the file only fails once someone supplies a complete environment, which is precisely the moment a real deployment begins.
Decision
One public origin, routed by path, with the topology derived from two variables.
PUBLIC_HOST (bare hostname, required for a TLS site address) and PUBLIC_ORIGIN
(scheme-qualified, required for issuers and browser bundles) are the only inputs.
Compose cannot strip a scheme, so neither can be derived from the other; every other
consumed value is derived, in deploy/eval/docker-compose.prod.yml:
AUTH_ISSUER = ${PUBLIC_ORIGIN}/auth/realms/trust-relay
AUTH_JWKS_URL = ${PUBLIC_ORIGIN}/auth/realms/trust-relay/protocol/…/certs
KEYCLOAK_URL = ${PUBLIC_ORIGIN}/auth
KC_HOSTNAME = ${PUBLIC_ORIGIN}
KC_HTTP_RELATIVE_PATH = /auth
NEXT_PUBLIC_API_URL = ${PUBLIC_ORIGIN}
NEXT_PUBLIC_BACKEND_URL = ${PUBLIC_ORIGIN}
NEXT_PUBLIC_KEYCLOAK_URL = ${PUBLIC_ORIGIN}/auth
Route ownership on the single origin is fixed and explicit:
| path | upstream | why it is named |
|---|---|---|
/api/* | FastAPI | the main API surface |
/v1/* | FastAPI | test_router_prefixes_are_proxied.py records /v1/peppol as a public non-API prefix; omitting it hands PEPPOL to the frontend |
/callback | FastAPI | a direct @app.get route, not a prefix mount, so a catch-all swallows it |
/auth/* | Keycloak | no rewrite — see below |
/ | Next.js | catch-all, last |
/auth is not stripped. Keycloak is told it lives there
(KC_HTTP_RELATIVE_PATH=/auth) and serves /auth/realms/… itself. The alternative —
strip the prefix at the proxy — was rejected: it makes the issuer in the token
disagree with the URL the browser used unless a second correction is applied
elsewhere, and it puts the knowledge in the proxy config, which is the one artifact a
partner is most likely to replace with their own.
APP_ENV is deliberately NOT set to production, and that is a declared gap.
An earlier revision of this decision set it, on the reasoning that a silent
development posture means serving real traffic with mock capabilities and
plaintext PII. Review measured the consequence: assert_production_safe() requires
minio_use_ssl=True and a Postgres URL carrying sslmode=require, in addition
to non-default credentials and PII encryption. This stack runs Postgres and MinIO on
an internal Docker bridge with no TLS, so the guard cannot pass and both the
backend and the worker refuse to boot at import.
Setting it would ship a deployment that cannot start. Removing it silently would
leave an operator believing a guard runs when it does not. So it is removed and
stated — in the overlay, in .env.prod.example, and in the runbook — naming
exactly what goes unchecked: mock capabilities, shipped credentials, PII at rest,
and TLS to the backing services. Making the stack satisfy the guard means
terminating TLS at Postgres and MinIO, which is infrastructure work with its own
scope and is tracked separately.
This is the second time in one decision that the honest answer was to record a limit rather than assert a property: the same shape as the reproducibility declaration in #1174.
Ordering is part of the decision, not a hint. The runbook starts the data tier
and Keycloak, runs alembic upgrade head, and only then starts the application. It
bootstraps backups/wal_spool before Postgres, and reconciles the Keycloak
client after Keycloak is running and with the env file loaded. Each of those was
wrong in the first draft, and each produces a failure that looks like an application
fault rather than a sequencing one.
The deployment record (Appendix B) is the artifact. #994 is explicit that the
timed clean-host run is the deliverable and the document is not. The record carries a
wall-clock field and a Source files edited count whose only acceptable value is 0.
Consequences
Positive
- A partner sets two variables and a posture flag. Nothing else varies by deployment.
- The three artifacts that must agree —
Caddyfile,.env.prod.example,docker-compose.prod.yml— now derive from the same two inputs, so they cannot drift apart by being edited independently. docker compose configon the overlay exits 0 for the first time, verified with a complete dummy environment. Resolved values confirmed per service:AUTH_ISSUERcarrying/auth,KC_HTTP_RELATIVE_PATH=/auth,PUBLIC_HOSTreaching the Caddy container (not merely Compose's substitution), and all fiveNEXT_PUBLIC_*build args.- Single origin removes a class of CORS and cookie problems before they start, and needs one certificate and one DNS record.
test_runbook_is_configuration_only_994.pymakes the "no source edits" property checkable rather than aspirational.
Negative
PUBLIC_APP_URL/PUBLIC_API_URL/PUBLIC_AUTH_URLare gone. Any operator env file written against the old template stops working, loudly. Acceptable: the three-subdomain shape was never deployed, so there is no working deployment to break — but it is a breaking change to the template, not a compatible one.- Everything shares an origin, so a proxy misconfiguration can expose an internal
path that subdomain isolation would have kept separate. The route table above is
therefore an allowlist, and
/is last. - Path-based routing puts more logic in the proxy than
reverse_proxy host → servicedid. Appendix A is longer, and a partner substituting their own proxy has more to get right — hence the explicit table. - This ADR does not prove the runbook works. It makes it self-consistent and mechanically valid. Only a clean-host run by an operator who is not its author can establish that, and that run remains outstanding.
Neutral
- The pilot is unaffected: it runs
docker-compose.ymlplus its owndocker-compose.override.yml, notdeploy/eval/docker-compose.prod.yml. Verified before changing the overlay. - Two variables rather than one is a small ergonomic cost forced by Compose's lack of string manipulation, not a design preference.
Alternatives Considered
Alternative 1: keep three subdomains and fix the artifacts to match
- Repair the
Caddyfile, env template and overlay aroundapp./api./auth.. - Why rejected: it requires three DNS records and three certificates for a single-tenant eval deployment, and it is not the shape that has actually been exercised end to end. Choosing the unexercised option because it was written down first is how the divergence arose.
Alternative 2: strip /auth at the proxy instead of setting KC_HTTP_RELATIVE_PATH
- Let Keycloak keep its default root and rewrite the path in the proxy.
- Why rejected: the issuer must equal the URL the browser used, so stripping requires a compensating correction elsewhere, and it locates the knowledge in the proxy configuration — the artifact a partner is most likely to replace. Telling Keycloak where it lives keeps the fact with the service that owns it.
Alternative 3: leave the runbook prose-only and document the gaps
- Rewrite the wording, list the known defects, and leave the artifacts alone.
- Why rejected: #994's deliverable is an executable procedure. A runbook that documents its own inability to run is a more honest version of the same defect, and the duplicate-key error means the overlay could not have been run regardless of how carefully the prose described it.
Alternative 4: build Helm or Terraform
- Why rejected: #994 rules it out explicitly — the partner has opinions about orchestration, and speculative IaC is discarded work.