Skip to main content

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:

defectconsequence
Caddyfile served three subdomainsthe 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_HOSTNAMEtokens issued for the wrong host; authentication broken
no KC_HTTP_RELATIVE_PATH anywhere in the repoKeycloak serves /realms/… while the proxy forwards /auth/realms/…; discovery, login and the issuer check all 404
no APP_ENVSettings.app_env stays development, so assert_production_safe() returns without checking anything
LLM_API_KEY declared twice in one mappingdocker compose config exits 1; the overlay is unusable the moment real secrets are supplied
backups/wal_spool absent on a clean checkoutarchive_command fails, WAL accumulates, the disk fills, no recovery set
seeded realm users carry zero credentialsthe documented password-grant verification cannot pass
/v1/peppol unroutedthe 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:

pathupstreamwhy it is named
/api/*FastAPIthe main API surface
/v1/*FastAPItest_router_prefixes_are_proxied.py records /v1/peppol as a public non-API prefix; omitting it hands PEPPOL to the frontend
/callbackFastAPIa direct @app.get route, not a prefix mount, so a catch-all swallows it
/auth/*Keycloakno rewrite — see below
/Next.jscatch-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 config on the overlay exits 0 for the first time, verified with a complete dummy environment. Resolved values confirmed per service: AUTH_ISSUER carrying /auth, KC_HTTP_RELATIVE_PATH=/auth, PUBLIC_HOST reaching the Caddy container (not merely Compose's substitution), and all five NEXT_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.py makes the "no source edits" property checkable rather than aspirational.

Negative

  • PUBLIC_APP_URL / PUBLIC_API_URL / PUBLIC_AUTH_URL are 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 → service did. 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.yml plus its own docker-compose.override.yml, not deploy/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 around app. / 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.