ADR-0014: Caddy origin TLS + GHCR image pipeline for the shared stack
Status: Accepted Date: 2026-07-30
Context
ADR-0013 made one shared multi-tenant stack the hosted product, but the deployment machinery still assumed the per-client model: a Traefik edge on :80 only, TLS terminated at Cloudflare in Flexible mode (the Cloudflare→origin hop crossed the internet as plain HTTP — ADR-0008 names this as its weakest link), images built on the VPS from a git checkout with no registry, and no CI deploy path, so "rollback" meant git archaeology plus a rebuild.
Two additional constraints shaped the redesign:
- The backend derives the client IP from the last
X-Forwarded-Forentry (RequestUtils.resolveClientIp) and keys rate limiting, account lockout, and audit logging on it. That is sound with exactly one trusted proxy hop; every additional appending hop replaces the real client with a container IP and collapses all users into one rate-limit bucket. - Flyway has no down-migrations, so any rollback story must work with an older jar booting against a newer schema.
Decision
- Caddy 2 terminates TLS on the VPS (
deploy/Caddyfile,deploy/docker-compose.prod.yml) with automatic Let's Encrypt certificates; Cloudflare stays in front, upgraded to Full (strict). HSTS is emitted at Caddy and nowhere else. - Caddy proxies
/api/*and/wsstraight to the backend, bypassing the frontend nginx, and overwritesX-Forwarded-ForwithCF-Connecting-IP. The header is trustworthy because ports 80/443 are firewalled to Cloudflare's ranges in theDOCKER-USERchain — the firewall is part of the security model. The frontend nginx serves only the static SPA; its baked/apiproxy block stays (dev and the legacy shape still use it) but receives no traffic here. - Images are built by GitHub Actions and pushed to GHCR
(
ghcr.io/jomariabejo/motorph-payroll-{backend,frontend}), taggedsha-<12-hex>per commit (deploy.yml). The VPS never builds; the prod compose file has nobuild:. - Deploys are SSH-driven and health-gated:
deploy/deploy.shpulls first, takes a pre-deploypg_dump, swaps the tag, waits on the container healthcheck, and automatically redeploys the previous tag on failure — still exiting non-zero so the run goes red. A manualrollback.ymlflips to any recorded tag. - Rollback tolerates the newer schema via a compose overlay
(
deploy/docker-compose.rollback.yml) settingSPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS=*:missing. The schema itself is never rolled back; a destructive migration means restoring the pre-deploy dump.
Consequences
Positive
- The Cloudflare→origin hop is finally encrypted and verified (Full strict); ADR-0008's accepted gap is closed for the hosted shape.
- Rollback is a tag flip measured in seconds, with an automatic first responder built into every deploy.
- Real client IPs reach the rate limiter and audit log; the shared-bucket failure mode is designed out and smoke-tested (vps-guide §8).
- VPS CPU/RAM are no longer spent on Maven/npm builds.
Negative
- Deploys have downtime (compose recreate + health gate) — accepted at this scale instead of blue/green.
- The trust chain (CF-Connecting-IP → firewall) is load-bearing: dropping
the
DOCKER-USERallowlist silently degrades IP-keyed security to a spoofable header. The guide makes the firewall mandatory, not optional. - GitHub Actions and GHCR become production dependencies (a registry outage blocks deploys, not uptime).
- Deploys are gated on the container healthcheck, not tests — the backend's known-failing billing tests block a test gate until fixed.
- ADR-0008 remains the shape for legacy per-client on-prem installs; two edge models now coexist in the repo.
References
../../deploy/docker-compose.prod.yml,../../deploy/Caddyfile,../../deploy/deploy.sh,../../deploy/docker-compose.rollback.yml../../.github/workflows/deploy.yml,../../.github/workflows/rollback.yml../deployment/vps-guide.md— the operational runbook- 0008-traefik-cloudflare-tls.md (superseded for the hosted shape), 0013-shared-db-multitenancy.md