ADR 0030 — Optional Caddy site management
Status: Accepted
Context
Some stacks serve HTTP on the host loopback (for example a web service
listening on 127.0.0.1:8080). Reaching them from outside currently requires
editing the reverse-proxy configuration by hand over SSH: adding a site block
such as app.example.com → reverse_proxy 127.0.0.1:8080, then reloading the
proxy. The panel already covers the rest of the flow — create the stack,
configure it, start it, read its logs — but not this last routing step.
The host already runs Caddy with its own Caddyfile. Some stacks are deployed
by an external continuous-delivery system whose routing is owned elsewhere and
must stay untouched.
Decision
The panel manages only the sites it created
Compose Panel never edits the main Caddyfile and never rewrites an existing
site. It owns exactly one directory of per-domain files:
/etc/caddy/compose-panel.d/
app.example.com.caddy
shop.example.com.caddy
The main Caddyfile imports that directory once, as a host operation outside
the panel:
import /etc/caddy/compose-panel.d/*.caddy
Each file carries its own ownership metadata in a strict header, followed by the single canonical site block:
# compose-panel:managed v1
# compose-panel:stack web
# compose-panel:service web
app.example.com {
reverse_proxy 127.0.0.1:8080
}
V1 scope is deliberately narrow: one public domain → one local HTTP upstream.
No path rules, no wildcards, no Caddy-level authentication options, no
Caddyfile editor. Only TCP ports published on 127.0.0.1 are eligible —
exactly what the managed file dials — so an IPv6-only or UDP publication can
never pass the check while producing a broken proxy.
Host helper behind a Unix socket, no sudo from the container
The panel runs in a container, so a host sudoers rule cannot apply to it.
All privileged work happens in a small host helper, reachable through a Unix
socket mounted into the panel container and activated on demand via systemd:
- Closed protocol:
attach,detach,list/status. No free command, no shell, no user-supplied path: the managed directory and the mainCaddyfileare fixed in the helper's own host-side configuration. The panel sends a structured request (domain,stack,service, upstream port); the helper renders the file itself and is authoritative for writes. - The helper runs as the
caddyuser whenever file permissions andcaddy reloadallow it, rather than asrootunconditionally. - The panel → helper channel uses bounded request/response sizes and timeouts. Every mutation stays behind the panel's session, Origin and CSRF checks and is recorded in the activity log.
Single source of truth, strict ownership
There is no central sites.json: domain uniqueness follows naturally from file
names. The helper only modifies or deletes a file whose header is present and
byte-conformant with the format it generates. It refuses:
- an existing file it does not recognize as panel-managed;
- overwriting a managed file whose content was changed externally.
The existing activity log records who requested each operation; the file header records which stack and service the domain belongs to.
Concurrency: one global Caddy lock plus the per-stack lock
The panel keeps its per-stack lock (one modifying operation per stack,
including saves) and registers Caddy mutations in the shared shutdown drain.
The helper additionally serializes the whole
prevalidate → publish → validate → reload sequence behind an exclusive lock on
the managed directory, so two stacks attaching domains at once cannot interleave
a publish and a reload. The lock is only ever reclaimed from a provably dead
holder (a pid recorded in the lock, never mere age), so long validations
and reloads cannot cause overlapping mutations.
That lock is the single host-side convention for every Caddy change. Any
other tool that edits its own Caddy files or reloads Caddy on the same host
(for example an external deployment pipeline) wraps that step with
deploy/caddy/with-caddy-lock.sh, which takes the same lock with the same
mkdir + <pid>:<token> owner protocol, runs one command, and releases only
its own lock. It runs in the host PID namespace (the helper checks the PID),
never reclaims a lock itself, and gives up without running anything after a
bounded wait. It never writes into the managed directory. A tool that does
not use it is not coordinated: its reload can land between a publish and the
validation, and this is documented as such.
Domain uniqueness across the whole configuration
A domain is unique across the whole Caddy configuration, not only across
managed files. Before publishing, the helper adapts the current
configuration and refuses the attach when the domain is already routed
elsewhere — exactly, or through a single-level wildcard (*.example.com) —
for example by the main Caddyfile or another tool's import. When the
current configuration cannot be adapted (error, timeout, truncated output),
uniqueness is unproven and the attach is refused too. Routes that exist only
in Caddy's running JSON configuration (pushed through the administration API,
not present in any Caddyfile) are not visible to this check.
No network verification in V1
V1 verifies and applies the Caddy configuration only:
| Status | V1 |
|---|---|
| Generated config | Verified locally |
| Caddy config valid | Verified |
| Caddy reload done | Verified |
| DNS resolution | Not verified |
| HTTPS reachable | Not verified |
Fetching a user-supplied domain from the panel would add an SSRF surface
(redirects, private IPs, DNS rebinding) for no conclusive proof: a successful
request from the host does not prove public reachability. V1 instead offers an
Open application link and states what remains to be checked externally.
Network checks, if ever added, need their own ADR.
Explicit refusal when the import is missing
caddy validate alone is not sufficient: an import glob matching no file is
not an error, and a valid configuration does not prove the main Caddyfile
imports the managed directory. Before publishing anything, the helper verifies
both that the active Caddyfile contains the required import directive and
that the adapted configuration (caddy adapt) contains the expected route.
Otherwise it refuses with an actionable message and publishes nothing.
Safe sequence and honest rollback
Prepare candidate (rendered by the helper)
↓
Acquire the shared Caddy lock
↓
Prevalidate: import present, current config adapts, domain not routed elsewhere
↓
Publish candidate atomically, never overwriting (temp file + link)
↓
Validate the complete configuration, check the route in the adapted config
↓
Reload Caddy
↓
Success / rollback
The complete validation can only run after publication: caddy validate
reads the real import, and the import path is fixed. The candidate is
therefore briefly present in the imported directory before it is validated;
the shared lock is what keeps a cooperating writer from reloading Caddy in
that window, and the prevalidation removes the predictable failure (a
duplicate domain) before anything is written. A validation or route-check
failure removes the candidate without any reload.
When the reload fails, the helper restores the previous file and reloads the
restored configuration. If that second reload also fails, the outcome is
reported as unknown and requiring intervention — never as success. caddy reload uses Caddy's administration API, which must stay enabled for this
mechanism.
Time budget
One request may wait for the lock and then run up to five caddy
subprocesses (adapt, validate, adapt, reload, rollback reload), each bounded
by the helper's per-command timeout. The helper's hard deadline is derived
from that timeout — lock wait + five commands + a margin, 135 s with the
defaults — so it can never fire before the rollback reload has had its full
time. The panel's attach/detach call timeout is never below 150 s. A panel
that still gives up first (a longer per-command timeout configured on the
host) reports the outcome as unknown; there is no durable job to resume it.
Externally deployed stacks stay read-only here
Stacks carrying a stack.managed-by label (for example
managed-by: external-deployer) show their routing as read-only information
and cannot attach or detach domains through this integration. The label stays
informational, as in ADR 0025: the read-only rule is a workflow choice, not a
security boundary, and it never disables the panel's other actions.
Activation
Opt-in. When the integration is disabled, the panel behaves exactly as before: no Domains section, no socket access, no new dependency.
Installation (amendment, after 0.5.0)
Host setup is one administrator operation, separate from the day-to-day
attach/detach the helper allows: scripts/setup-caddy.sh --check (read-only)
then --install (explicit confirmation, or --yes). It supports exactly one
layout — Caddy as the host systemd service caddy running
/etc/caddy/Caddyfile, the panel deployed with deploy/compose.yaml — and
refuses anything else, including a Caddy running in a container, rather than
half-installing. Every step is idempotent and backs up what it edits.
- The helper is compiled from the running panel image, offline
(
--network none): same sources and version as the panel, host architecture, no Bun or download on the host. Re-running--installafter a panel update recompiles it. - The only edit to the main
Caddyfileis appending the oneimportline, after a backup, validated as thecaddyuser beforesystemctl reload caddy, restored on failure. An existing import of the managed directory with another pattern is refused, never rewritten. This is an installation step run by the administrator; the helper itself still never edits that file. deploy/compose.yamlmounts/run/compose-panelread-only unconditionally, so the script never edits a versioned file (a local change would conflict with the nextgit pull); the script only appends the two settings to the untrackedcompose-panel.env, refusing other values already set.- The panel is recreated and verified (healthy, mount present, helper status read from inside the container); a panel that does not come back healthy gets its previous settings back.
No installer framework, no interactive wizard, no uninstall automation (the
removal steps stay documented). The file-editing steps are tested on
temporary files (tests/setup-caddy.test.ts); the full run is part of the
manual verification on the target host.
Consequences
- Landed in two steps: first the pure domain model, validation, canonical file
generation and a preview-only Domains section on the stack page (no
privileged path, no mutation, no network call;
COMPOSE_PANEL_CADDY_ENABLEDoff by default with the pure modulesrc/caddy.ts), then the host helper with its systemd socket/service units, the bounded socket client, real attach/detach/status operations under both locks, the import/adapt checks, rollback semantics, and an end-to-end test against a disposable Caddy (COMPOSE_PANEL_CADDY_SOCKET_PATH). - No database, no scheduler, no generic command runner, no Caddy Docker Proxy migration: the existing Caddy layout is kept.
product.md,architecture.md,security.mdanddeployment.mdcarry the feature once PR2 lands; PR1 documents the contract in this ADR only.
Alternatives considered
- Caddy Docker Proxy (labels on containers): rejected for the existing host. It would require changing the Caddy installation, sharing a Docker network and granting the controller access to daemon metadata. Kept as a reasonable option for a host built from scratch, not for this one.
- A
sudo/sudoersrule executed from the panel container: rejected. Asudoinside the container does not execute on the host. - A central
sites.jsonregistry: rejected. It adds a second source of truth that can desynchronize from the Caddy files and is harder to repair by hand. - Panel-side DNS/HTTPS checks in V1: rejected (SSRF surface, inconclusive signal, monitoring scope creep).
- Editing the main
Caddyfilefrom the panel: rejected. The panel only ever writes files it owns in its managed directory.
Verification
PR1: tests/caddy.test.ts (domain/service/stack/port validation, canonical
generation, strict header parsing, refusal of foreign or externally modified
files, loopback binding check), tests/config.test.ts (opt-in flag defaults
and parsing), tests/html.test.ts (Domains preview rendering, escaping, no
form for externally managed stacks) and tests/app.test.ts (section absent
when disabled, preview shown when enabled, invalid input is a warning never a
500). bun run test and bun run typecheck are the gates; no Docker test
is needed for PR1.
PR2: unit tests for the socket client (bounds, timeouts, malformed answers)
and the helper policy (fake Caddy runner), plus an opt-in Linux end-to-end
test against a real, disposable Caddy (tests/caddy-e2e.test.ts, run by
bun run test:caddy:wsl with a pinned Caddy release): attach routes HTTPS
traffic to the upstream and detach removes it; a domain already served by the
main Caddyfile is refused with the routing intact; a failed reload on
attach or detach rolls back to the previous running configuration (the
failing reload is injected, the rollback reload is real); an unreachable Caddy
is reported as unknown. The external lock wrapper is tested against the
helper entry point. Never against the host's production Caddy or daemon.
Manual verification on the target host with its real Caddy before release,
including the systemd sandbox (see docs/deployment.md).
Out of scope
Wildcard domains, path-based routing, Caddy request authentication, editing the
main Caddyfile, Caddy Docker Proxy support, DNS/HTTPS monitoring, automatic
retry or replay after a crash, multi-host or multi-user management.