Files
certctl/docs/acme-caddy-walkthrough.md
T
shankar0123 39f065dda4 docs(acme-server): operator-facing reference + threat model + cert-manager walkthrough (Phase 6/7)
Doc-only commit closing the ACME-server work series. After this commit,
an outside reviewer (procurement engineer / Venafi diligence engineer /
Infisical-comparison-shopper) can read the docs cold, understand the
ACME server's surface, follow the cert-manager walkthrough, and reach
a deployment decision without escalating to certctl maintainers.

What ships:
  - docs/acme-server.md final pass: Auth-mode decision tree (when to
    use trust_authenticated vs challenge), RFC 8555 + RFC 9773
    conformance statement (section-by-section table of implemented
    plus procurement-honest 'not implemented' rows for EAB / multi-
    level wildcards / RFC 8738 / cross-CA proxying), Troubleshooting
    (5 failure modes — badNonce / unknownAuthority / HTTP-01
    connection refused / DNS-01 NXDOMAIN / rejectedIdentifier with
    canonical fix for each), Version pinning + tested clients table
    (cert-manager 1.15.0, lego v4, kind v0.20+, Caddy 2.7.x, Traefik
    3.0+), FAQ (5 entries — why two auth modes, vs cert-manager-
    against-LE, can-I-use-from-outside-K8s, migration story, audit-
    log catalog), See-also cross-link block.
  - docs/acme-cert-manager-walkthrough.md: kind → cert-manager →
    certctl → Certificate flow, with YAML blocks byte-equal to
    deploy/test/acme-integration/{clusterissuer-trust-authenticated,
    certificate-test}.yaml to prevent doc/test drift.
  - docs/acme-caddy-walkthrough.md: Caddyfile acme_ca + tls.cas
    options (OS trust store + Caddy pki.ca block).
  - docs/acme-traefik-walkthrough.md: certificatesResolvers.<name>.acme
    .caServer + serversTransport.rootCAs configuration.
  - docs/acme-server-threat-model.md: Threat surface map + JWS forgery
    resistance (alg-confusion / HS256 substitution / replayed nonce /
    URL spoofing / multi-sig / kid-vs-jwk / kid round-trip mismatch),
    Nonce store integrity rationale, HTTP-01 SSRF defense-in-depth
    (pre-dial check + per-dial check + per-redirect check + body cap +
    bounded redirects), DNS-01 cache-poisoning posture (default Google
    Public DNS + operator-owns-private-resolver-posture), TLS-ALPN-01
    chain-not-validated rationale (RFC 8737 §3 explicit), Rate-limit
    tuning, Audit trail catalog, Out-of-scope threats list.
  - docs/connectors.md: TOC renumbered 3→4 etc. to make room for new
    top-level 'ACME Server (Built-in)' section between Issuer Connector
    and Target Connector — distinguishes the consumer-side ACME
    (existing) from the new server-side ACME via env-var-prefix
    call-out (CERTCTL_ACME_* vs CERTCTL_ACME_SERVER_*).

DoD verification:
  - All 5 docs files exist with the structure prescribed by the
    Phase 6 prompt.
  - Every CERTCTL_ACME_SERVER_* env var in docs/acme-server.md maps
    to an actual lookup in internal/config/config.go (verified by
    'grep -oE | sort -u | diff' returning empty).
  - Every YAML snippet in docs/acme-cert-manager-walkthrough.md is
    byte-equal to the corresponding file in deploy/test/acme-integration/
    (verified with 'diff' against awk-extracted YAML blocks).
  - docs/connectors.md has the cross-link subsection with all 4 new
    docs referenced.
  - cowork/CLAUDE.md Architecture Decisions has the new ACME-server
    bullet documenting per-profile URL family + per-profile
    acme_auth_mode + Phase 4-5-6 progression.
  - cowork/WORKSPACE-CHANGELOG.md has the ACME-Server-6 entry plus
    the ACME-Server rollup spanning Phases 1a-6.
  - cowork/infisical-deep-research-results.md Rank 1 marked SHIPPED.
  - 'gofmt -l .' clean (no Go changes); 'go vet ./...' clean.

Acquisition-readiness: every one of the 12 acquisition-grade criteria
from cowork/acme-server-endpoint-prompt.md is verified by the test
suite (Phases 1a-5) plus this doc walkthrough (Phase 6). The full
RFC 8555 + RFC 9773 surface is live; the operator can deploy
end-to-end by reading one walkthrough doc and one env-var table.

Engineering history: cowork/WORKSPACE-CHANGELOG.md 'ACME-Server-6 (docs)'
+ ACME-Server rollup of all 6 phases.
2026-05-03 19:58:15 +00:00

5.8 KiB

Caddy Integration Walkthrough

End-to-end recipe for issuing certs from a certctl-server deployment through Caddy 2.7+. Target audience: operator running Caddy on a VM or container who wants Caddy to ACME-issue from certctl instead of Let's Encrypt.

Prereqs

  • A reachable certctl-server with CERTCTL_ACME_SERVER_ENABLED=true and at least one profile whose acme_auth_mode is set. Profile setup is identical to the cert-manager walkthrough — see docs/acme-cert-manager-walkthrough.md Step 2.
  • Caddy 2.7.x or later. caddy version should show 2.7.0+.
  • Network reachability: Caddy → certctl-server's HTTPS listener (port 8443 by default).
  • The certctl bootstrap CA, in PEM form, captured for the trust configuration below. Capture exactly the same way as the cert-manager walkthrough Step 3 — use cat deploy/test/certs/ca.crt.

Step 1 — Configure Caddy

Caddy's ACME issuer is configured per-site (or globally) via the acme_ca directive in a Caddyfile, or via the tls.acme_ca field in JSON config. The directive points at the directory URL:

{
  email ops@example.com
}

example.com {
  tls {
    acme_ca https://certctl.example.com:8443/acme/profile/prof-test/directory
    issuer acme
  }
  reverse_proxy localhost:8080
}

Notes:

  • acme_ca must point at the directory URL (ending in /directory), not just the base. Caddy uses the directory document to discover the new-account / new-order URLs, exactly the same way cert-manager does.
  • issuer acme is the default; included here for clarity. Caddy can also be configured with issuer zerossl or issuer internal; for certctl integration, acme is the correct issuer.
  • Caddy auto-discovers tls-alpn-01 first when port 443 is bound to Caddy, then falls back to HTTP-01. For trust_authenticated mode profiles, both work without solver round-trips.

Step 2 — Trust the certctl bootstrap CA

Caddy validates the certctl-server's TLS chain before any ACME call, the same way cert-manager does. Two options for trust:

Option A — OS trust store (preferred for VMs)

sudo cp deploy/test/certs/ca.crt /usr/local/share/ca-certificates/certctl-bootstrap.crt
sudo update-ca-certificates
sudo systemctl restart caddy

Caddy honors the system trust store via the Go runtime's crypto/x509 defaults. After update-ca-certificates, Caddy's HTTPS client trusts certctl's self-signed root and the directory call succeeds.

Option B — Caddy tls.cas (for containerized deployments)

{
  pki {
    ca certctl_bootstrap {
      root_cert_file /etc/caddy/certctl-bootstrap.crt
    }
  }
}

example.com {
  tls {
    acme_ca https://certctl.example.com:8443/acme/profile/prof-test/directory
    ca certctl_bootstrap
    issuer acme
  }
  reverse_proxy localhost:8080
}

The pki.ca block registers a named CA Caddy can reference; the tls.ca certctl_bootstrap line in the site block scopes that trust to ACME calls for this site only. This is the right pattern for multi-tenant Caddy deployments where some sites trust certctl + others don't.

Step 3 — Reload Caddy

caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy reloads atomically; in-flight requests complete on the old config while new requests use the new ACME issuer. On the next example.com request, Caddy hits certctl's directory URL, registers an account, submits a new-order, and finalizes — typically completing in under 5 seconds for trust_authenticated mode.

Step 4 — Verify

caddy list-certificates
# example.com (issuer=certctl.example.com): CN=example.com, valid until 2026-06-30

The cert is in Caddy's certificate cache ($XDG_DATA_HOME/caddy/certificates/ by default). Inspect:

openssl x509 -in ~/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.crt -noout -subject -issuer -dates
# subject= CN=example.com
# issuer= CN=certctl test internal CA

(Path layout is Caddy-version-dependent; check caddy environ for the canonical data dir.)

On the certctl side, the operator's audit log captures the issuance event:

psql -c "SELECT actor, action, resource_id FROM audit_events
         WHERE actor LIKE 'acme:%' ORDER BY created_at DESC LIMIT 5;"

Common failure modes

  • Caddy logs tls: failed to verify certificate: x509: certificate signed by unknown authority → certctl bootstrap CA is not in Caddy's trust path. Re-do Step 2; verify with curl --cacert /etc/caddy/certctl-bootstrap.crt https://certctl.example.com:8443/acme/profile/prof-test/directory.
  • Caddy logs urn:ietf:params:acme:error:rateLimited → certctl per-account orders/hour limit hit (default 100/hr). Tune via CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR if you have legitimately high throughput.
  • Caddy logs urn:ietf:params:acme:error:rejectedIdentifier → the SAN list includes an identifier the certctl profile policy rejects. Cross-reference docs/acme-server.md § Troubleshooting.
  • badNonce in Caddy logs → clock skew or multi-replica certctl without sticky sessions; same fix as the cert-manager walkthrough.

Cleanup

caddy stop
# remove the certctl-specific block from your Caddyfile
sudo systemctl reload caddy
# Optional: delete cached certs from the certctl directory namespace.
rm -rf ~/.local/share/caddy/certificates/certctl.example.com-*

See also