ADR-129: Config-layer deploy by surface (decouple config from app image deploys)¶
- Status: Accepted
- Date: 2026-07-29
- Authors: Marko Dragoljevic, Claude (Opus 4.8)
- Related: ADR-114 (shared edge), ADR-121 (per-app Alloy drop-ins), ADR-128 (same "decouple + phase" pattern)
Context¶
The config layer — declarative, non-image files the box runs — is deployed today as a side-effect of full app deploys:
- Caddy vhosts (
infra/caddy/*.caddy) →/etc/caddy/sites/+systemctl restart caddy, cp'd insidedeploy.sh/deploy-player.sh/deploy-operator.sh. - Alloy drop-ins (
infra/observability/*.alloy) →/opt/vps-observability/config.d/+docker kill -s HUP alloy, also inside those deploys. - The base
Caddyfile→/etc/caddy/Caddyfile, cp'd byapply-edge.sh(root).
Consequence: changing one config file (a header, a log rule, a vhost) forces a full privileged-stack redeploy (docker.sock + LLM keys + pipeline-llm) — disproportionate blast radius, and no way to ship just config. There is also no operator control over which surface's config moves.
Decision¶
A dedicated deploy-config.yml deploys the config layer by SURFACE, independently of app
images. Operator picks the surface; nothing else moves.
Surface map (podcast_scraper-owned config only). Names align across surfaces:
<surface>.caddy = the app vhost, <surface>-telemetry.caddy = GlitchTip ingest,
<surface>-analytics.caddy = Umami ingest, <surface>.alloy = the log drop-in.
| Surface | Caddy (deploy-writable /etc/caddy/sites/) |
Alloy | Templating |
|---|---|---|---|
| player — the app + its own ingest | player.caddy, player-telemetry.caddy (→ GlitchTip), player-analytics.caddy (→ Umami) |
player.alloy |
PLAYER_DOMAIN + PLAYER_PREVIEW_COOKIE; GLITCHTIP_UPSTREAM (Caddy env, box-side) |
| operator — the app | operator.caddy |
operator.alloy |
OPERATOR_DOMAIN + OPERATOR_PREVIEW_COOKIE |
Ownership principle (operator's rule): each app owns its own serving vhost, its own
observability/analytics ingest vhosts, and its own log drop-in — one aligned naming scheme
per surface (player-*, operator*). There is no shared "infra" surface: the GlitchTip/Umami
engines are homelab-owned, but each app's ingest vhost rides that app's surface. The
operator surface consumes analytics/telemetry over the homelab tailnet directly, so it has no
ingest vhost of its own.
orrery owns its own routing (was a temporary surface here — now migrated). orrery's Caddy
vhosts (orrery.caddy, orrery-telemetry.caddy, orrery-analytics.caddy) briefly deployed
from this repo as their own surface while orrery had no Caddy-vhost deploy. They now live in
the orrery repo (ops/caddy + ops/deploy_caddy_vhosts.sh, shipped by orrery's
deploy-prod.yml), consistent with ADR-114 tenant ownership — podcast_scraper owns the edge
engine, each tenant owns its routing. The orrery surface has been removed from
deploy-config.yml.
Mechanics: the workflow templates each vhost in the runner (the domain rewrite + the
__*_PREVIEW_COOKIE__ secret, exactly as the deploy scripts' sed), scp's to the box, cp's
into /etc/caddy/sites/, runs caddy validate (with the box's CADDY_BIND_ADDRS /
GLITCHTIP_UPSTREAM systemd env), and restarts caddy only if valid (rollback the vhost
files if not — caddy never restarts on a broken config). Alloy drop-ins scp to config.d/ +
HUP. A dry_run toggle stages + validates without restarting. Gated: environment: prod +
typed DEPLOY_CONFIG.
The base Caddyfile stays in apply-edge.sh. It is root-owned (deploy@ can write
/etc/caddy/sites/ but not /etc/caddy/Caddyfile), and it is bundled with the host-level
systemd env (CADDY_BIND_ADDRS, GLITCHTIP_UPSTREAM) that apply-edge also manages. It is
edge-engine config, not an ingest vhost — the clean permission + ownership boundary keeps it
with the host-hardening path.
Phasing (mirrors ADR-128 — add, prove, then remove):
- Phase 1 (this ADR): add
deploy-config.yml— additive; the app deploys keep cp'ing config as before, so nothing breaks while the new path is proven. - Phase 2 (follow-up): remove the vhost/alloy cp side-effects from
deploy.sh/deploy-player.sh/deploy-operator.sh, leaving them to deploy only app images.
Consequences¶
Positive: a config change ships in seconds via one gated, surface-scoped workflow — no
image, no compose recreate, no full-stack redeploy, minimal blast radius. Operator has full
control (pick player / operator / all). One place for config-layer deploys.
Negative: the vhost sed templating is now in two places until Phase 2 (the workflow +
the app scripts) — a divergence risk, mitigated by Phase 2 removing it from the scripts.
The base Caddyfile remains a separate path (apply-edge) — "Caddy config" isn't 100% in one
place, but the permission boundary makes that the correct split.
Neutral: orrery was briefly a surface here while it had no Caddy-vhost deploy; its routing
has since moved to the orrery repo (ADR-114 tenant ownership), so deploy-config now covers
only podcast_scraper's own surfaces (player, operator).
Alternatives considered¶
- Deploy by config-type (
target: caddy|alloy|all) instead of by surface. Rejected — the operator wants surface isolation (ship player's log rule without touching operator). - Include the base Caddyfile in
deploy-config. Rejected for Phase 1 — needs a new root sudoers grant for/etc/caddy/Caddyfile;apply-edgealready owns it correctly. - Three separate workflows (one per surface). Rejected — one workflow with a
surfaceinput is one place to operate with the same isolation.
References¶
.github/workflows/deploy-config.yml·infra/caddy/*.caddy·infra/observability/*.alloy- EDGE_CONVERGENCE_RUNBOOK (the base Caddyfile / host path)