Skip to content
SHC Docs

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 in internal/core/config/default.yaml, shc init grew --acme-provider / --acme-directory / --acme-email (seeded via seedACME, internal/modules/cluster/commands/init.go), and the retired --traefik-le / --traefik-email / --traefik-le-staging flags are pinned ABSENT by init_test.go. The terraform provider ships the matching acme nested block on shc_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.

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) — enumerate acme.providers.*, deref EAB, email falling back to acme.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-providerbody["acme_provider"]InstallRequest.AcmeProvider → runner opts (opts.AcmeProvider ?? priorState) → persistedState.acme_providermodel.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.

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.

  • vars.acme.{caServer,email} + TRAEFIK_ACME_{EMAIL,CASERVER}TRAEFIK_ACME_PROVIDERS
  • single letsencrypt/le-dns-<code> seed blocks → per-provider loops
  • leResolverDefault, "le-dns-"+code in decider → computed acme-<provider>[-dns-<code>]
  • --traefik-le/--traefik-email/--traefik-le-staging flags, X400026CLITLE, TraefikLE* struct fields, leStagingDirectoryURL, seedTraefikLEConfig caServer branch → --acme-provider/--acme-directory/--acme-email writing acme.providers.* + acme.default
  • systemAcmeEmail LE gate → systemAcmeDefault
  • TF clusterTraefikLE + traefik_le module var + topology.acme_email/lb_acme_emailacme = { providers, default, email }shc_config rows
  • top-level acme.email (global) KEPT as fallback contact any provider without its own email inherits
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.