mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:01:34 +00:00
docs: re-home ACME client walkthroughs under docs/migration/
The three ACME client walkthroughs (Caddy, cert-manager, Traefik) are
conceptually "I have an existing X, here's how to point its ACME
client at certctl." They belong with the migration docs, not with the
acme-server protocol reference.
Renames:
docs/acme-caddy-walkthrough.md → docs/migration/acme-from-caddy.md
docs/acme-cert-manager-walkthrough.md → docs/migration/acme-from-cert-manager.md
docs/acme-traefik-walkthrough.md → docs/migration/acme-from-traefik.md
Each walkthrough's lede gets a "Use this walkthrough when..." paragraph
that closes the WHY-weak gap flagged in the Phase 1 audit. The new
framing tells the reader when to pick this walkthrough versus the
alternatives:
- Caddy: "you're running Caddy 2.7+ and want it to ACME-issue from
certctl instead of Let's Encrypt"
- cert-manager: explicit pointer to cert-manager-coexistence.md for
the keep-cert-manager-running case (vs replacement)
- Traefik: "you're running Traefik 3.0+ and want certctl as your
ACME source of truth"
Cross-reference updates from other docs and README still pending in
Phase 11.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
# Caddy Integration Walkthrough
|
||||
|
||||
> **Use this walkthrough when** you're already running Caddy 2.7+ and
|
||||
> want it to ACME-issue from certctl (your internal CA, your private
|
||||
> PKI, or a local sub-CA chained under an enterprise root) instead of
|
||||
> Let's Encrypt. The Caddyfile changes are minimal; the load-bearing
|
||||
> piece is trusting certctl's bootstrap CA so Caddy's ACME client can
|
||||
> talk to certctl over HTTPS.
|
||||
|
||||
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`](./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](./acme-server.md#certificate-readyfalse-with-rejectedidentifier).
|
||||
- **`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
|
||||
|
||||
- [`docs/acme-server.md`](./acme-server.md) — canonical reference.
|
||||
- [`docs/acme-cert-manager-walkthrough.md`](./acme-cert-manager-walkthrough.md) —
|
||||
K8s-native equivalent.
|
||||
- [Caddy upstream ACME docs](https://caddyserver.com/docs/automatic-https#acme-issuer)
|
||||
— verify behavior pinned here against Caddy 2.7.x semantics.
|
||||
@@ -0,0 +1,263 @@
|
||||
# cert-manager Integration Walkthrough
|
||||
|
||||
> **Use this walkthrough when** you're already running cert-manager
|
||||
> 1.15+ in Kubernetes and want it to issue certs from certctl (your
|
||||
> internal CA, your private PKI, or a local sub-CA chained under an
|
||||
> enterprise root) via the standard ACME `ClusterIssuer` model. If
|
||||
> you want certctl to coexist with cert-manager rather than replace
|
||||
> its issuer backend, see
|
||||
> [`docs/migration/cert-manager-coexistence.md`](cert-manager-coexistence.md)
|
||||
> instead.
|
||||
|
||||
End-to-end recipe for issuing certs from a certctl-server deployment
|
||||
through cert-manager 1.15+. Target audience: Kubernetes operator who
|
||||
has never deployed certctl before and wants a working
|
||||
`Certificate` → `Secret` flow on their cluster in under 30 minutes.
|
||||
|
||||
The Phase 5 integration test (`make acme-cert-manager-test`) automates
|
||||
exactly the recipe below. The YAML snippets in this doc are byte-equal
|
||||
to the files under `deploy/test/acme-integration/` — re-running the
|
||||
test from a fresh clone produces the same results documented here.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- A Kubernetes cluster (kind / k3d / EKS / GKE / AKS / on-prem). For
|
||||
local trial, `kind v0.20+` works exactly the way the Phase 5 test
|
||||
uses it. The kind config lives at
|
||||
[`deploy/test/acme-integration/kind-config.yaml`](../deploy/test/acme-integration/kind-config.yaml).
|
||||
- `kubectl` v1.27+, `helm` v3.13+.
|
||||
- `cert-manager` v1.15.0 installed in the `cert-manager` namespace.
|
||||
If absent, run:
|
||||
|
||||
```
|
||||
bash deploy/test/acme-integration/cert-manager-install.sh
|
||||
```
|
||||
|
||||
which is the same idempotent installer the integration test uses.
|
||||
- A certctl Helm chart published to a registry your cluster can pull
|
||||
from. The Phase 5 test uses an `image.tag=test` placeholder; production
|
||||
deployments use the actual image tag for your release line.
|
||||
|
||||
## Step 1 — Deploy certctl-server
|
||||
|
||||
```
|
||||
helm install certctl-test deploy/helm/certctl/ \
|
||||
--set acmeServer.enabled=true \
|
||||
--set acmeServer.defaultProfileId=prof-test \
|
||||
--set image.tag=test
|
||||
kubectl wait --for=condition=Available --timeout=3m deployment/certctl-test
|
||||
```
|
||||
|
||||
`acmeServer.enabled=true` flips the `CERTCTL_ACME_SERVER_ENABLED`
|
||||
env var which gates the ACME route registration.
|
||||
`acmeServer.defaultProfileId` sets `CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID`
|
||||
so the `/acme/*` shorthand path mirrors the per-profile path family.
|
||||
|
||||
## Step 2 — Create the certctl profile
|
||||
|
||||
The ACME server requires a `certificate_profiles` row to bind issuance
|
||||
to. Create one via the certctl API or GUI; for the simplest case set
|
||||
`acme_auth_mode='trust_authenticated'`:
|
||||
|
||||
```
|
||||
curl -X POST https://certctl-test.default.svc.cluster.local:8443/api/profiles \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer $CERTCTL_API_KEY" \
|
||||
-d '{
|
||||
"id": "prof-test",
|
||||
"name": "ACME test profile",
|
||||
"issuer_id": "iss-internal-ca",
|
||||
"max_ttl_seconds": 7776000,
|
||||
"acme_auth_mode": "trust_authenticated"
|
||||
}'
|
||||
```
|
||||
|
||||
Auth-mode tradeoffs are covered in
|
||||
[`docs/acme-server.md` § Auth-mode decision tree](./acme-server.md#auth-mode-decision-tree).
|
||||
For first-time deployments, `trust_authenticated` is the right default.
|
||||
|
||||
## Step 3 — Capture the certctl bootstrap CA
|
||||
|
||||
cert-manager validates the certctl-server's TLS chain before sending
|
||||
any account / order / finalize JWS. With certctl's self-signed
|
||||
bootstrap cert (the demo default at `deploy/test/certs/server.crt`),
|
||||
cert-manager rejects the directory URL with
|
||||
`x509: certificate signed by unknown authority` unless you feed the
|
||||
bootstrap CA in.
|
||||
|
||||
```
|
||||
cat deploy/test/certs/ca.crt | base64 -w0
|
||||
```
|
||||
|
||||
Capture the output for Step 4. This is **the** single biggest first-
|
||||
time-deploy footgun on the cert-manager integration path. The reference
|
||||
recipe lives in
|
||||
[`docs/acme-server.md` § TLS trust bootstrap](./acme-server.md#tls-trust-bootstrap-read-this-before-configuring-cert-manager).
|
||||
|
||||
## Step 4 — Apply the ClusterIssuer
|
||||
|
||||
```yaml
|
||||
# Phase 5 — sample ClusterIssuer for the certctl trust_authenticated
|
||||
# auth mode (RFC 8555 §6 + certctl auth_mode=trust_authenticated, where
|
||||
# the JWS-authenticated ACME account is trusted to issue any identifier
|
||||
# the profile policy permits — no per-identifier ownership challenges).
|
||||
#
|
||||
# Use this as the starting template for any internal-PKI rollout.
|
||||
# Replace the caBundle placeholder with the base64-encoded PEM of the
|
||||
# certctl-server's self-signed bootstrap root, then `kubectl apply`.
|
||||
#
|
||||
# Generate the caBundle via:
|
||||
# cat deploy/test/certs/ca.crt | base64 -w0
|
||||
# (See certctl/docs/acme-server.md "TLS trust bootstrap" section for the
|
||||
# end-to-end walkthrough — this is the single biggest first-time-deploy
|
||||
# footgun on cert-manager, captured as audit fix #9.)
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: certctl-test-trust
|
||||
spec:
|
||||
acme:
|
||||
email: test@example.com
|
||||
# Replace 'certctl-test' with your release name + adjust the
|
||||
# profile path segment. Default profile path:
|
||||
# https://<service>.<namespace>.svc.cluster.local:8443/acme/profile/<profile-id>/directory
|
||||
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-test/directory
|
||||
# caBundle: Audit fix #9. cert-manager validates the ACME server's
|
||||
# TLS chain before submitting any account/order/finalize. With a
|
||||
# self-signed bootstrap root, the ClusterIssuer MUST carry the root
|
||||
# explicitly via this field.
|
||||
caBundle: |
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
privateKeySecretRef:
|
||||
name: certctl-test-trust-account-key
|
||||
solvers:
|
||||
# In trust_authenticated mode the solver is unused at the
|
||||
# validation step but cert-manager still requires at least one
|
||||
# solver in the spec. http01-via-ingress-nginx is the cheapest
|
||||
# placeholder shape that round-trips correctly through cert-
|
||||
# manager's validation webhooks.
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
```
|
||||
|
||||
This block is byte-equal to
|
||||
[`deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml`](../deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml).
|
||||
Replace the `caBundle` placeholder with the base64 string from Step 3.
|
||||
The full reference YAML lives at
|
||||
[`deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml`](../deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml).
|
||||
|
||||
```
|
||||
kubectl apply -f deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml
|
||||
kubectl wait --for=condition=Ready --timeout=2m clusterissuer/certctl-test-trust
|
||||
```
|
||||
|
||||
The solver block is a placeholder under `trust_authenticated` mode —
|
||||
cert-manager 1.15 still requires at least one solver in the spec, but
|
||||
certctl auto-resolves authzs without a solver round-trip. The
|
||||
http01-ingress-nginx shape validates against cert-manager's webhook
|
||||
without needing an actual ingress controller deployed.
|
||||
|
||||
For `challenge` mode profiles, swap to
|
||||
[`deploy/test/acme-integration/clusterissuer-challenge.yaml`](../deploy/test/acme-integration/clusterissuer-challenge.yaml)
|
||||
— same shape, but the solver is now load-bearing and you need
|
||||
ingress-nginx (or your chosen ingress class) actually deployed for
|
||||
HTTP-01 to work.
|
||||
|
||||
## Step 5 — Apply the Certificate
|
||||
|
||||
```yaml
|
||||
# Phase 5 — Certificate resource the integration test applies and
|
||||
# waits for. The certctl-test-trust ClusterIssuer (trust_authenticated
|
||||
# mode) issues the cert without any solver round-trip; the resulting
|
||||
# Secret 'test-com-tls' is asserted to carry tls.crt + tls.key.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: test-com
|
||||
namespace: default
|
||||
spec:
|
||||
secretName: test-com-tls
|
||||
commonName: test.example.com
|
||||
dnsNames:
|
||||
- test.example.com
|
||||
- www.test.example.com
|
||||
issuerRef:
|
||||
name: certctl-test-trust
|
||||
kind: ClusterIssuer
|
||||
duration: 720h # 30d
|
||||
renewBefore: 240h # 10d
|
||||
```
|
||||
|
||||
This block is byte-equal to
|
||||
[`deploy/test/acme-integration/certificate-test.yaml`](../deploy/test/acme-integration/certificate-test.yaml).
|
||||
|
||||
```
|
||||
kubectl apply -f deploy/test/acme-integration/certificate-test.yaml
|
||||
kubectl wait --for=condition=Ready --timeout=3m certificate/test-com
|
||||
```
|
||||
|
||||
cert-manager creates an `Order`, the ACME flow runs against certctl,
|
||||
and the resulting Secret is populated.
|
||||
|
||||
## Step 6 — Verify
|
||||
|
||||
```
|
||||
kubectl get certificate test-com -o wide
|
||||
# NAME READY SECRET ISSUER STATUS AGE
|
||||
# test-com True test-com-tls certctl-test-trust Certificate is up to date and has not expired 42s
|
||||
|
||||
kubectl get secret test-com-tls -o yaml | yq '.data."tls.crt"' | base64 -d | openssl x509 -noout -subject -issuer -dates
|
||||
# subject= CN=test.example.com
|
||||
# issuer= CN=certctl test internal CA
|
||||
# notBefore=... notAfter=...
|
||||
```
|
||||
|
||||
Both the cert-manager `Certificate` resource and the underlying Secret
|
||||
are populated. The actor on the certctl side is `acme:<account-id>`,
|
||||
which you can correlate via the `audit_events` table:
|
||||
|
||||
```
|
||||
psql -c "SELECT created_at, action, resource_type, resource_id
|
||||
FROM audit_events
|
||||
WHERE actor LIKE 'acme:%'
|
||||
ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
## Common failure modes
|
||||
|
||||
These are operator-side; full troubleshooting reference is in
|
||||
[`docs/acme-server.md` § Troubleshooting](./acme-server.md#troubleshooting).
|
||||
|
||||
- `400 Bad Request: badNonce` → clock skew between certctl-server and
|
||||
cert-manager, or a multi-replica certctl fleet without sticky
|
||||
sessions.
|
||||
- `x509: certificate signed by unknown authority` → missing or stale
|
||||
`caBundle`. Re-run Step 3, paste the fresh value.
|
||||
- `connection refused` from the HTTP-01 validator → ingress controller
|
||||
not deployed, OR your network blocks port 80 inbound to the solver
|
||||
Ingress.
|
||||
- `Ready=False` with `rejectedIdentifier` → CSR has a SAN your profile
|
||||
policy doesn't permit. Decode the `subproblems` array of the RFC
|
||||
7807 problem doc.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```
|
||||
kubectl delete -f deploy/test/acme-integration/certificate-test.yaml
|
||||
kubectl delete -f deploy/test/acme-integration/clusterissuer-trust-authenticated.yaml
|
||||
helm uninstall certctl-test
|
||||
# Optional: delete the certctl profile via API.
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/acme-server.md`](./acme-server.md) — canonical reference.
|
||||
- [`docs/acme-server-threat-model.md`](./acme-server-threat-model.md) —
|
||||
security posture.
|
||||
- [`docs/acme-caddy-walkthrough.md`](./acme-caddy-walkthrough.md) —
|
||||
Caddy-side recipe.
|
||||
- [`docs/acme-traefik-walkthrough.md`](./acme-traefik-walkthrough.md) —
|
||||
Traefik-side recipe.
|
||||
- [`deploy/test/acme-integration/`](../deploy/test/acme-integration/) —
|
||||
Phase 5 integration test (the same recipe, automated).
|
||||
@@ -0,0 +1,205 @@
|
||||
# Traefik Integration Walkthrough
|
||||
|
||||
> **Use this walkthrough when** you're already running Traefik 3.0+
|
||||
> (Kubernetes or VM) and want it to ACME-issue from certctl (your
|
||||
> internal CA, your private PKI, or a local sub-CA chained under an
|
||||
> enterprise root) instead of Let's Encrypt. The Traefik static config
|
||||
> changes are minimal; the load-bearing piece is `serversTransport.rootCAs`
|
||||
> so Traefik trusts certctl's bootstrap CA on every outbound ACME call.
|
||||
|
||||
End-to-end recipe for issuing certs from a certctl-server deployment
|
||||
through Traefik 3.0+. Target audience: operator running Traefik (in
|
||||
Kubernetes or on a VM) who wants to use certctl as their ACME source
|
||||
of truth 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`](./acme-cert-manager-walkthrough.md)
|
||||
Step 2.
|
||||
- Traefik 3.0+ (the v2 API surface for ACME is also supported but the
|
||||
`serversTransport.rootCAs` reference below is v3-shaped).
|
||||
- The certctl bootstrap CA, in PEM form, captured the same way as the
|
||||
cert-manager walkthrough Step 3.
|
||||
|
||||
## Step 1 — Configure Traefik static config
|
||||
|
||||
Traefik's ACME issuer is a `certificatesResolver` in the static config
|
||||
(file or CLI flags or env vars). The relevant fields:
|
||||
|
||||
```yaml
|
||||
# /etc/traefik/traefik.yml (or wherever your static config lives)
|
||||
|
||||
certificatesResolvers:
|
||||
certctl:
|
||||
acme:
|
||||
caServer: https://certctl.example.com:8443/acme/profile/prof-test/directory
|
||||
email: ops@example.com
|
||||
storage: /etc/traefik/acme-certctl.json
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
# OR for trust_authenticated mode profiles:
|
||||
# tlsChallenge: {}
|
||||
|
||||
# certctl uses a self-signed bootstrap cert; Traefik needs the CA
|
||||
# explicitly via serversTransport.rootCAs to call the directory URL.
|
||||
serversTransports:
|
||||
default:
|
||||
rootCAs:
|
||||
- /etc/traefik/certctl-bootstrap.crt
|
||||
|
||||
# Apply the serversTransport globally so every outbound HTTPS call —
|
||||
# including ACME directory + finalize — trusts the certctl CA.
|
||||
api:
|
||||
insecure: false
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `caServer` must point at the directory URL (ending in `/directory`).
|
||||
- `httpChallenge.entryPoint: web` requires Traefik's `web` entryPoint
|
||||
(port 80) to be reachable from certctl-server's HTTP-01 validator.
|
||||
For `trust_authenticated` mode profiles, this is a no-op formality —
|
||||
certctl auto-resolves authzs, so the solver round-trip never happens.
|
||||
- `tlsChallenge: {}` is the alternative that uses TLS-ALPN-01 (RFC 8737)
|
||||
via Traefik's `websecure` (port 443) entryPoint. Either works under
|
||||
`challenge` mode; only the default-of-`tlsChallenge` is recommended
|
||||
for `trust_authenticated` mode.
|
||||
|
||||
## Step 2 — Trust the certctl bootstrap CA
|
||||
|
||||
Two options:
|
||||
|
||||
### Option A — `serversTransport.rootCAs` (preferred)
|
||||
|
||||
```
|
||||
sudo cp deploy/test/certs/ca.crt /etc/traefik/certctl-bootstrap.crt
|
||||
sudo systemctl reload traefik
|
||||
```
|
||||
|
||||
`serversTransports.default.rootCAs` (shown in Step 1 above) tells
|
||||
Traefik's outbound HTTPS client to trust the supplied PEM in addition
|
||||
to the system trust store. This is the right pattern for containerized
|
||||
Traefik where you don't want to install OS-level trust roots.
|
||||
|
||||
### Option B — OS trust store
|
||||
|
||||
For Traefik running directly on a VM, `update-ca-certificates`-style
|
||||
installation works the same way as the Caddy walkthrough Option A.
|
||||
The `serversTransport.rootCAs` field is unnecessary in that case.
|
||||
|
||||
## Step 3 — Reference the resolver from a router
|
||||
|
||||
Per-router (dynamic config):
|
||||
|
||||
```yaml
|
||||
# /etc/traefik/dynamic/example-com.yml
|
||||
|
||||
http:
|
||||
routers:
|
||||
example-com:
|
||||
rule: "Host(`example.com`)"
|
||||
entryPoints: [websecure]
|
||||
tls:
|
||||
certResolver: certctl
|
||||
service: example-com-backend
|
||||
services:
|
||||
example-com-backend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://localhost:8080"
|
||||
```
|
||||
|
||||
Or, in Kubernetes via `IngressRoute` (Traefik CRD):
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: example-com
|
||||
spec:
|
||||
entryPoints: [websecure]
|
||||
routes:
|
||||
- match: Host(`example.com`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: example-com-backend
|
||||
port: 8080
|
||||
tls:
|
||||
certResolver: certctl
|
||||
```
|
||||
|
||||
## Step 4 — Reload Traefik
|
||||
|
||||
```
|
||||
sudo systemctl reload traefik
|
||||
# OR kubectl rollout restart deployment/traefik (if you changed the static config via ConfigMap).
|
||||
```
|
||||
|
||||
On the first request to `example.com`, Traefik hits certctl's directory
|
||||
URL, registers an account, submits a new-order, and finalizes. The cert
|
||||
is persisted to `/etc/traefik/acme-certctl.json` (or its in-cluster
|
||||
PVC equivalent).
|
||||
|
||||
## Step 5 — Verify
|
||||
|
||||
```
|
||||
curl -kvI https://example.com 2>&1 | grep -E 'subject|issuer'
|
||||
# subject: CN=example.com
|
||||
# issuer: CN=certctl test internal CA
|
||||
```
|
||||
|
||||
The cert is signed by certctl's bound issuer (per the `prof-test`
|
||||
profile's `issuer_id`).
|
||||
|
||||
On the certctl side, the audit log captures the issuance:
|
||||
|
||||
```
|
||||
psql -c "SELECT actor, action, resource_id FROM audit_events
|
||||
WHERE actor LIKE 'acme:%' ORDER BY created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
## Common failure modes
|
||||
|
||||
- **Traefik logs `unable to obtain ACME certificate ... x509: certificate
|
||||
signed by unknown authority`** → `serversTransport.rootCAs` is not
|
||||
pointing at the certctl bootstrap CA, OR the file was rotated and
|
||||
Traefik hasn't reloaded. Verify with
|
||||
`curl --cacert /etc/traefik/certctl-bootstrap.crt
|
||||
https://certctl.example.com:8443/acme/profile/prof-test/directory`.
|
||||
- **Traefik logs `urn:ietf:params:acme:error:rateLimited`** → tune
|
||||
`CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR` on the certctl
|
||||
side, OR reduce Traefik's parallel-cert-acquisition concurrency.
|
||||
- **`acme: error: 400 :: POST :: ... :: badNonce`** → clock skew or
|
||||
multi-replica certctl without sticky sessions; same fix as the
|
||||
cert-manager walkthrough.
|
||||
- **Storage file `acme-certctl.json` shows persistent failures** —
|
||||
Traefik retains failed-acquisition state. After fixing the
|
||||
underlying cause, delete the storage file and reload:
|
||||
`rm /etc/traefik/acme-certctl.json && systemctl reload traefik`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```
|
||||
# Remove the certResolver from any router / IngressRoute consuming it.
|
||||
sudo systemctl reload traefik
|
||||
# Delete the persisted ACME storage:
|
||||
sudo rm /etc/traefik/acme-certctl.json
|
||||
# Or in K8s: drop the resolver from the static-config ConfigMap.
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/acme-server.md`](./acme-server.md) — canonical reference.
|
||||
- [`docs/acme-cert-manager-walkthrough.md`](./acme-cert-manager-walkthrough.md) —
|
||||
cert-manager equivalent.
|
||||
- [Traefik upstream ACME docs](https://doc.traefik.io/traefik/https/acme/#caserver) —
|
||||
verify behavior pinned here against Traefik 3.0+ semantics.
|
||||
Reference in New Issue
Block a user