ACME/Let's-Encrypt issuance as first-class named providers
STATUS: IMPLEMENTED (2026-07-17). F8 landed on main as designed: the
acme.email/acme.default/acme.providers.<name>config block lives ininternal/core/config/default.yaml,shc initgrew--acme-provider/--acme-directory/--acme-email(seeded viaseedACME,internal/modules/cluster/commands/init.go), and the retired--traefik-le/--traefik-email/--traefik-le-stagingflags are pinned ABSENT byinit_test.go. The terraform provider ships the matchingacmenested block onshc_cluster/shc_node(terraform/provider/internal/provider/cluster_resource.go,node_resource.go). No deltas from the design below.
Status: design (implements sweep-legacy item F8). Clean break — no backcompat.
The traefik_le.staging boolean and the single-global-caServer are DELETED.
Motivation
Section titled “Motivation”bootstrap.traefik_le = { email, staging } collapses a free-form ACME
directory URL into a boolean at the terraform/init boundary. SHC already
models DNS and volumes as named providers selected by context/default; ACME
should match: define many ACME providers, set a default, and let any app
use any of them. staging becomes just another named provider whose
directory is the LE-staging URL.
Config surface (daemon) — an inline registry, not a capability slot
Section titled “Config surface (daemon) — an inline registry, not a capability slot”ACME is not a cloud account: LE is credential-less; the only secret is an
optional ZeroSSL/EAB HMAC (one shared secret, handled like
dns.providers.cloudflare.token via ref+vault:// + config.GetResolved).
So it mirrors the vault.providers/otel.providers/notify.providers
open-map shape, not the shc_connection capability registry.
internal/core/config/default.yaml (replace the acme: block):
acme: email: null # default contact; a provider block may override default: null # provider used when a deployment names none; null => SHC-leaf providers: null # open map keyed by operator label: # acme.providers.<name>.directory # ACME dir URL ("" => LE prod) # acme.providers.<name>.email # optional; overrides acme.email # acme.providers.<name>.eab_kid # optional EAB kid # acme.providers.<name>.eab_hmac # optional EAB HMAC (ref+vault:// ok)acme.providers: null + acme.default: null auto-register as open prefixes
(collectOpenPrefixes, keyspace.go:82) — no RegisterDynamicKeyPrefix call.
New internal/modules/acme/reader.go, mirroring dns/factory.go:98-243:
ResolveProviderName(cfg, override)— override →acme.default→ first key.ProviderTable(ctx)— enumerateacme.providers.*, deref EAB, email falling back toacme.email.
Per-app selection — mirror the dns_provider chain one-for-one
Section titled “Per-app selection — mirror the dns_provider chain one-for-one”Add AcmeProvider everywhere DNSProvider lives:
CLI --acme-provider → body["acme_provider"] → InstallRequest.AcmeProvider
→ runner opts (opts.AcmeProvider ?? priorState) → persistedState.acme_provider
→ model.AcmeProvider → DB column deployment.acme_provider (ent field +
migration ALTER TABLE deployment ADD acme_provider text NULL) →
IngressIntent.AcmeProvider → per-host resolver name.
Terraform shc_deployment.acme_provider mirrors the existing storage
attribute (model + schema + deploySpec + StackInstall wire). Install-time
shaped (recreate to change CA), like storage/runtime.
Traefik render (the crux)
Section titled “Traefik render (the crux)”Two orthogonal axes. Challenge type (HTTP-01 vs DNS-01) stays chosen by
DNS-zone-ownership per host (unchanged). ACME provider (which CA/dir/EAB) is
the NEW axis, chosen by the app (--acme-provider, default acme.default);
it only swaps the account/directory/EAB, never the challenge type. So each
provider is defined in both an HTTP-01 variant and one DNS-01 variant per
lego code — cross product |providers| × (1 + |dns-codes|).
Resolver naming (clean break; letsencrypt/le-dns-* deleted):
- HTTP-01:
acme-<provider> - DNS-01:
acme-<provider>-dns-<legocode>
Carrier (apps/traefik/compose.yaml): delete TRAEFIK_ACME_EMAIL/
TRAEFIK_ACME_CASERVER; add newline/|-delimited TRAEFIK_ACME_PROVIDERS
records <name>|<directory>|<email>|<eab_kid>|<eab_hmac> (busybox has no jq;
matches the existing TRAEFIK_DNS_RESOLVERS split style). The seed script
becomes a nested while read | for code loop emitting one resolver per
(provider × challenge) cell, each with its own acme/acme-<...>.json storage.
The awk-strip prefix changes le-dns- → acme- (daemon owns all acme-*).
Selection moves out of the decider into writeIngressRouteFile:
dnsResolverDecider.ResolverForHost returns the bare lego code;
writeIngressRouteFile(acmeProvider, …) composes
acme-<provider>-dns-<code> (DNS-01 owner) or acme-<provider> (HTTP-01),
resolving empty → acme.default. leResolverDefault const deleted;
leResolverTLS(leEnabled) → leResolverTLS(provider). New daemon seam
SetSystemTraefikACMESource beside SetSystemTraefikDNS01Source; the LE
gate switches from “acme.email set” to “acme.default resolves”.
Threading: IngressIntent gains AcmeProvider (SELECT
COALESCE(deployment.acme_provider,'')); MaterializeIngress passes it.
Clean-break delta (delete → replace)
Section titled “Clean-break delta (delete → replace)”vars.acme.{caServer,email}+TRAEFIK_ACME_{EMAIL,CASERVER}→TRAEFIK_ACME_PROVIDERS- single
letsencrypt/le-dns-<code>seed blocks → per-provider loops leResolverDefault,"le-dns-"+codein decider → computedacme-<provider>[-dns-<code>]--traefik-le/--traefik-email/--traefik-le-stagingflags,X400026CLITLE,TraefikLE*struct fields,leStagingDirectoryURL,seedTraefikLEConfigcaServer branch →--acme-provider/--acme-directory/--acme-emailwritingacme.providers.*+acme.defaultsystemAcmeEmailLE gate →systemAcmeDefault- TF
clusterTraefikLE+traefik_lemodule var +topology.acme_email/lb_acme_email→acme = { providers, default, email }→shc_configrows - top-level
acme.email(global) KEPT as fallback contact any provider without its own email inherits
Estate HCL
Section titled “Estate HCL”acme = { email = "ops@example.com" # cluster-wide contact; a provider's own email overrides it providers = { prod = {} staging = { directory = "https://acme-staging-v02.api.letsencrypt.org/directory" } zerossl = { directory = "https://acme.zerossl.com/v2/DV90", eab_kid = "…", eab_hmac = "ref+vault://kv/zerossl#hmac" } } default = "staging" # staging while validating a reprovision}
resource "shc_deployment" "gitea" { app = "gitea" acme_provider = "prod" # omit => acme.default}Concentrated in the traefik seed-script rewrite (compose.yaml:153-205) and
the one resolver-selection switch (inject_traefik.go:593-605). Everything
else is a mechanical mirror of the proven dns_provider/storage plumbing.