ADR-0014: native [tls.acme] emission in the importer
- Status: accepted
- Date: 2026-07-31
- Deciders: fabriziosalmi
- Tags: cli, migration, tls, acme, adoption
Context
ADR-0012 (Traefik) and ADR-0013 (Caddy) each deferred native [tls.acme] emission: a TLS route imported to a placeholder cert plus a partial finding, and automatic HTTPS was left for the operator to add by hand. That was the right call while the front-ends were being built, but with all three importer front-ends landed (#320–#322) and the fleet swaps starting, the deferral is now the thing standing between an imported config and a real swap — every live HTTPS target (a Caddy auto-HTTPS site, a Traefik certresolver router) would otherwise import to a cert that does not exist.
Feasibility is confirmed: Zion's [tls.acme] needs only email + domains (the rest of AcmeConfig has defaults, and the block is deny_unknown_fields), validate_semantics/build_router_quiet do not couple to it, and the placeholder cert_path/key_path are exactly the bootstrap cert the ACME flow expects so :443 binds before the first issuance. An emitter test round-trips a [tls.acme] document through self_validate.
Decision
Resolve the deferral: the importer emits [tls.acme] when the source uses ACME and a contact e-mail is known.
ZionDocgainsacme: Option<AcmeOut { email, domains }>;emit::renderwrites a[tls.acme]block (with the bootstrap-cert comment) and only the two known fields. The bootstrap cert stays in[tls].- E-mail source, in precedence order: the source config's own ACME e-mail (Traefik
--certificatesresolvers.<r>.acme.email, Caddytls <email>or the globalemail), else the new--acme-email EMAILCLI flag. An e-mail is never invented. - Domains are the imported route hosts (Caddy restricts to non-localhost / non-IP hosts; a bare-port or localhost-only site gets no ACME).
- Findings: Traefik
certresolverand Caddy ACME-managedtlsmove frompartialtoconvertwhen an e-mail is available; with no e-mail they staypartialand name the fix (--acme-email …). Explicit Caddytls <cert> <key>still converts to[tls]cert paths (no ACME). - The certificate-manager caveat is preserved, not auto-resolved: when the origin is itself a cert manager, running ACME in parallel is wrong — but the importer cannot know that, so it faithfully translates the source's ACME intent and the finding notes the alternative (point
[tls]at the existing cert). That stays a per-repo deployment decision.
Consequences
- Positive: an HTTPS target now imports to a config that actually serves HTTPS.
zion import caddy Caddyfile --var DOMAIN=… --acme-email ops@…yields a completezion.tomlwith a real[tls.acme]— the missing half of a live swap. No new dependencies; only knownAcmeConfigfields are emitted. - Negative: one more doc-level aggregation in each front-end (union of route hosts → ACME domains) and a shared
AcmeOuton the neutral seam. - Neutral / risks:
[tls.acme]is emitted only on explicit ACME intent (a source ACME directive) or an explicit--acme-emailopt-in — never inferred from a public-looking hostname alone, so an import never silently turns on ACME the operator did not ask for.
Alternatives considered
- Keep deferring — rejected: it is the blocker to real swaps now that the front-ends are done.
- Invent / default an e-mail — rejected: a wrong ACME account e-mail is worse than an honest
partial. No e-mail → staypartial. - Emit
[tls.acme]for every public-looking host — rejected: importing a config should not silently enable ACME issuance; require an ACME signal or the--acme-emailopt-in.
References
- ADR-0012 / ADR-0013 (the deferral this resolves); ADR-0011 (the seam)
src/import/map.rs(AcmeOut,ZionDoc.acme),src/import/emit.rs([tls.acme]render + round-trip test),src/import/traefik.rs/src/import/caddy.rs(population + findings),src/cli.rs(--acme-email)[tls.acme]/AcmeConfiginsrc/config.rs,src/acme.rs