Per-Route Cert Migration Runbook
This runbook covers migrating from one shared wildcard ACME certificate
(thesteamedcrab.com covering *.thesteamedcrab.com) to per-listener
fine-grained short-term certificates, using cert-manager's Gateway API
shim to auto-provision a Certificate per HTTPRoute hostname.
Why: smaller blast radius per renewal failure, per-host visibility, no reflector dependency for new namespaces, faster rotation.
Why this needs to be paced: Let's Encrypt limits issuance to 50
certificates per Registered Domain per rolling 7-day window. The
cluster has ~70 HTTPRoutes under thesteamedcrab.com β too many to
flag-day onto ACME without breaching the limit and locking out all
issuance for ~7 days.
State at the start
| Aspect | Reality |
|---|---|
| Cert manifest | One Certificate (thesteamedcrab.com) producing thesteamedcrab-com-tls |
| Cert SANs | apex, *.thesteamedcrab.com, *.app.β¦, *.mcp.β¦ |
| Reflection | Secret reflected to network, istio-system, mcp-system |
| Issuer | letsencrypt-production (DNS01 via Cloudflare) |
| Gateways | 4 β external (network), internal (network), istio (istio-system), mcp-gateway (mcp-system) |
| HTTPRoutes per Gateway | external: 11, internal: 50+, mcp-gateway: 9, istio: 2 |
| Existing TTL | LE default 90d, autorenew at ~30d remaining |
Target state β split by exposure
The 70+ routes split into two cohorts:
- Public-facing (~22):
external(11),mcp-gateway(9),istio(2). These need browser-trusted certs β ACME vialetsencrypt-production. - Internal-only (~50): everything on the
internalGateway. These never face the public internet β private CA, issued by an in-cluster ClusterIssuer. Zero rate-limit concern, instant issuance, no public CT log entries leaking internal hostnames.
Why split
A "just ACME everything" plan would burn the entire 50/week budget twice over and leave no headroom for retries or the existing wildcard's renewal. Splitting halves the scope of the rate-limited work and removes the public CT log noise for internal services.
The cost of the split is one-time: distribute the private CA root to every device that visits internal hostnames (browsers, mobile devices, anything that calls internal APIs). That's a manual import on each device.
If that operational cost is unacceptable, see "Alternative: all-ACME" at the bottom of this runbook.
Rate-limit math (ACME side, ~22 certs)
- Hard ceiling: 50 issuances / 7d / Registered Domain
- Total ACME target certs: ~22
- Renewal exemption: renewals (same FQDN set, same account) are
exempt from the 50/week ceiling but still subject to a 5/week
Duplicate Certificate cap per FQDN set. With
duration: 168h/renewBefore: 48h, each cert renews ~1.4Γ/week β well under 5/week. - Sustainable initial-issuance rate:
50 Γ· 7 β 7/day. The 7-day window is rolling, not calendar β at 10/day you hit the cap on day 5, not day 7. - Wave size: 5/day. Buffer of 2/day for retries and the existing wildcard's renewal pressure.
- Total elapsed: ~5 days for 22 certs.
Critical gotchas (learned the hard way)
Three things bit us during the first execution attempt on 2026-05-02. Read these before writing any YAML.
1. The gateway-shim must be explicitly enabled
cert-manager v1.16+ does not enable the gateway-shim by default,
contrary to what some release notes suggest. You need
config.enableGatewayAPI: true in the Helm values. Without it,
annotating a Gateway with cert-manager.io/cluster-issuer is silently
inert β no Certificate is ever created. Verify before Phase 0:
kubectl get cm -n cert-manager cert-manager -o jsonpath='{.data.config\.yaml}'
# Expect to see: enableGatewayAPI: true
If absent, set it in the chart values and roll out cert-manager first.
2. The reflector pattern fights the gateway-shim across namespaces
This cluster mirrors network/thesteamedcrab-com-tls to istio-system
and mcp-system via reflector. The gateway-shim is per-namespace:
it creates a Certificate in the same namespace as the Gateway. So
annotating the istio Gateway whose listener references the
reflector-mirrored Secret causes:
- shim creates a
Certificateinistio-systemtargeting the mirrored Secret's name - cert-manager re-issues from ACME because "Secret was previously issued by a different issuer" (1 ACME order against the budget)
- ongoing fight: shim's Certificate vs reflector for ownership of the Secret
Don't annotate any Gateway whose listener still references a reflector-mirrored Secret. Migrate that Gateway's HTTPRoutes to per-app listeners with their own Secret names first, then remove the wildcard listener and the reflector dependency, then add the shim annotation.
For this cluster: the istio Gateway is excluded from Phase 0. It gets annotated only after its (small) HTTPRoute set is migrated and the wildcard listener is removed.
3. HTTPRoute sectionName has no fallback
A HTTPRoute attached to a listener that goes Programmed=False
does not fall back to other listeners that match its hostname.
The route is just down. The wildcard listener you keep around
"as a backstop" only catches HTTPRoutes that explicitly point at it
via sectionName.
Implication for the canary: don't pick an app whose downtime is
unacceptable. The canary's listener is Programmed=False until ACME
issues, which can be ~30s but can be much longer if anything's wrong.
Phase 0 β Annotation prep
Verified 2026-05-02 against cert-manager v1.17.1 with
config.enableGatewayAPI: true: when the shim sees a listener whose
referenced Secret doesn't exist (or isn't owned by an in-namespace
Certificate), it creates one. When an in-namespace Certificate already
owns the Secret, the shim is a no-op.
For this cluster, that means it's safe to annotate Gateways in the
network namespace (which holds the manual thesteamedcrab.com
Certificate that owns thesteamedcrab-com-tls), and unsafe to
annotate Gateways in istio-system or mcp-system while they still
reference the reflector-mirrored Secret.
0.1 Pre-flight test (one-time)
Confirm the shim creates Certificates for new Secret refs:
kubectl apply -f - <<'EOF'
---
apiVersion: v1
kind: Namespace
metadata: { name: shim-test }
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata: { name: ss, namespace: shim-test }
spec: { selfSigned: {} }
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: scratch
namespace: shim-test
annotations:
cert-manager.io/issuer: ss
spec:
gatewayClassName: envoy
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: bar.shim-test.thesteamedcrab.com
allowedRoutes: { namespaces: { from: Same } }
tls:
certificateRefs: [{ kind: Secret, name: bar-shim-tls }]
EOF
sleep 20
kubectl get certificate -n shim-test
kubectl delete namespace shim-test
Expected: a bar-shim-tls Certificate appears, Ready: True. If
nothing appears within 30s, the shim isn't running β fix that before
proceeding.
0.2 Add the annotations
Annotate only the external and internal Gateways
(network namespace). Skip istio and mcp-gateway for now β
they're in namespaces that depend on the reflector. They'll be
annotated in Phase 5 after their HTTPRoutes have moved off the
shared Secret.
metadata:
annotations:
# ...existing annotations...
cert-manager.io/cluster-issuer: letsencrypt-production
cert-manager.io/duration: "168h" # 7d
cert-manager.io/renew-before: "48h"
What
durationactually does. Let's Encrypt's default profile always issues 90-day certificates regardless of thedurationrequested by ACME. Thecert-manager.io/durationannotation controls only cert-manager's renewal cadence β it tells cert-manager "treat this cert as expiring after 168h" so it renews early. You still get 90-day certs, just rotated every ~5 days.For actually-short LE certs, opt into the
tlsserverprofile (~6-day validity) by addingacme.cert-manager.io/order-profile-name: tlsserveron the per-listener Certificate or via cert-manager's issuer-level configuration. That changes the LE order profile and the issued cert is genuinely short-lived. Verify withopenssl s_client β¦ | openssl x509 -noout -datesafter issuance.
For the internal Gateway, swap the issuer to the private CA once
Phase 1 is done:
cert-manager.io/cluster-issuer: cluster-internal-ca
0.3 Verify
# Cert count must be unchanged after reconcile.
kubectl get certificate -A
# If a new Certificate appears in any namespace, the shim hit
# something it shouldn't have. Stop and investigate.
If a Certificate appeared, check whether it's in a namespace whose Gateway you annotated. If yes, you almost certainly hit case (2) above β the listener references a reflector-mirrored Secret. Revert the annotation and migrate that Gateway separately later.
Phase 1 β Internal Gateway β private CA (parallel track)
This phase is independent of the ACME phases below β it has no rate limit concerns and can run in any order or in parallel.
1.1 Set up the private CA
(One-time. Skip if already present.)
# cert-manager: bootstrap a self-signed root + ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-bootstrap
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cluster-internal-ca
namespace: cert-manager
spec:
isCA: true
commonName: thesteamedcrab.com Internal CA
secretName: cluster-internal-ca-tls
duration: 87600h # 10y root
renewBefore: 720h
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-bootstrap
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: cluster-internal-ca
spec:
ca:
secretName: cluster-internal-ca-tls
1.2 Distribute the root to client devices
This is the non-zero operational cost. Export the CA cert and import it into:
- Each laptop / desktop browser (or system trust store)
- Each phone / tablet
- Any in-cluster client that calls internal hostnames over TLS
kubectl get secret -n cert-manager cluster-internal-ca-tls \
-o jsonpath='{.data.tls\.crt}' | base64 -d > internal-ca.crt
Don't proceed past 1.2 until you've imported and verified the root on
at least your primary device. (Open https://glance.thesteamedcrab.com
post-migration and confirm no cert warning.)
1.3 Migrate the internal Gateway
Add cert-manager.io/cluster-issuer: cluster-internal-ca annotation
to kubernetes/apps/network/envoy-gateway/config/internal.yaml. Then,
in batches of 10 for review-ability, add a per-app listener and flip
the corresponding HTTPRoute's sectionName. Issuance is instant β
no soak required between batches; just verify each Certificate goes
Ready before moving on.
The wildcard listener stays alive until every internal HTTPRoute is
migrated, so rollback is the same as the ACME track: revert
sectionName and the route falls back to the wildcard.
Phase 2 β Public Gateways canary (1 app)
Pick a low-stakes route on the external Gateway (the highest public visibility). Avoid the highest-traffic apps (immich, jellyfin) until after canary; pick something like the github webhook or a seldom-used bookmark.
β No fallback. Once you flip the HTTPRoute's
sectionNameto the new per-app listener, the route is bound to that listener only. If ACME issuance fails or is slow, the route returns 404 (or connection refused) until the cert lands β the wildcard listener does not serve as a backstop. Pick a canary you can afford to have down for ~30s in the happy case and several minutes in the failure case.
2.1 Add a per-app listener
spec:
listeners:
# existing http and wildcard https listeners untouched
- name: https-<app>
protocol: HTTPS
port: 443
hostname: "<app>.${SECRET_DOMAIN}"
allowedRoutes:
namespaces:
from: All
tls:
certificateRefs:
- kind: Secret
name: <app>-tls
The Gateway already carries the shim annotations from Phase 0; this
new listener picks them up automatically. cert-manager will create a
<app>-tls Certificate within seconds of the listener appearing.
2.2 Flip the HTTPRoute's sectionName
parentRefs:
- name: external
namespace: network
- sectionName: https
+ sectionName: https-<app>
2.3 Verify and soak
# Cert issues in seconds for staging, ~30s for production
kubectl get certificate -n network -w
# Confirm the SAN is just the one hostname and duration β 7d
echo | openssl s_client -connect <app>.thesteamedcrab.com:443 \
-servername <app>.thesteamedcrab.com 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName -dates
Soak 24h. If anything is wrong, revert the HTTPRoute sectionName. The wildcard listener still serves the old wildcard cert; no user impact.
Don't proceed past Phase 2 until this canary has cleanly soaked AND
auto-renewed at least once (renewBefore: 48h means renewal kicks in
on day 5 β wait for that).
Phase 3 β Wave migration on public Gateways
Goal: migrate the remaining ~19 public-facing HTTPRoutes on Gateways
that are already annotated (external 10, mcp-gateway 9) at
5 per day with 12h soaks. The 2 istio routes are deferred to
Phase 5.
Per-wave procedure
For each batch of 5:
- Add 5 listeners to the appropriate Gateway. Sort listeners
alphabetically inside the
listeners:block. - Flip 5 HTTPRoute
sectionNames. - Commit + push. One PR per wave. Title:
feat(network): per-app TLS migration wave N (X/Y). - Watch issuance:
All 5 should reach Ready within ~2 min. Anything stuck β checkkubectl get certificate -A -wkubectl describe certificate <name> -n networkand the Order / Challenge resources. - Soak ~12h between waves. Watch the issuer for rate-limit warnings:
kubectl describe clusterissuer letsencrypt-production kubectl get challenge -A
Recommended wave order
Public Gateway with the manual Certificate first (lower risk β
that's where Phase 0 was verified safe):
- Wave 1:
external(5 of 10; canary already done) - Wave 2:
external(the other 5) - Wave 3:
mcp-gateway(5 of 9) - Wave 4:
mcp-gateway(the other 4)
Total ~4 calendar days at one wave per ~12h. The 2 istio routes are handled in Phase 5 once the reflector dependency is removed.
What to do if you hit a rate-limit error
cert-manager surfaces LE rate-limit responses in the Order resource:
kubectl get order -A | grep -i rate
kubectl describe order -n network <order>
If hit:
- Stop the next wave immediately.
- Don't delete failing Certificates β that doesn't reset the counter and can cause cert-manager to retry, eating more budget.
- The window is rolling; just wait. The first issuance from N days ago drops off after 7d.
- Resume waves once
kubectl get certificate -Ashows no Pending or Failing.
Phase 4 β Cleanup
Run only after every HTTPRoute on every Gateway has been migrated and soaked for 7+ days.
4.0 istio Gateway migration (prerequisite)
The istio Gateway was excluded from Phase 0 / Phase 3 because its listener references the reflector-mirrored Secret. Migrate its HTTPRoutes (~2: kiali and one redirect) before deleting the wildcard Certificate.
For each istio HTTPRoute (<app> = e.g. kiali):
-
Create a manual Certificate in
istio-system(no shim involvement β the istio Gateway is still un-annotated):apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: <app> namespace: istio-system spec: secretName: <app>-tls issuerRef: name: letsencrypt-production kind: ClusterIssuer dnsNames: - <app>.${SECRET_DOMAIN} duration: 168h renewBefore: 48h -
Wait for it to be Ready (1 ACME issuance per cert).
-
Add a per-app listener to the istio Gateway referencing
<app>-tls, then flip the HTTPRoute'ssectionName. -
Soak briefly. Confirm the HTTPRoute serves via the new listener.
After all istio HTTPRoutes are migrated:
-
Remove the wildcard listener from the istio Gateway.
-
Remove
istio-systemfrom the wildcard Certificate'sreflector...reflection-allowed-namespacesannotation list (inkubernetes/apps/network/envoy-gateway/config/certificate.yaml). This stops new mirroring; existing Secret in istio-system can be deleted manually if desired (no consumers). -
Now the istio Gateway has no reflector-mirrored Secret. Add the
cert-manager.io/cluster-issuerannotations from Phase 0. The shim is now safe β every listener's referenced Secret is owned by an in-namespace manual Certificate. (Optionally, delete the manual Certificates afterward and let the shim recreate them, at the cost of one ACME order per cert.)
4.1 Verify no consumers reference the wildcard Secret
grep -rn 'thesteamedcrab-com-tls\|${SECRET_DOMAIN/./-}-tls' kubernetes/
Anything still referencing the wildcard Secret needs to migrate first.
Particular attention: Gateways in istio-system and mcp-system may
still be using the reflector-mirrored copy.
4.2 Remove the wildcard listener from each Gateway
Delete the name: https block (with hostname: "*.${SECRET_DOMAIN}")
from each of:
kubernetes/apps/network/envoy-gateway/config/external.yamlkubernetes/apps/network/envoy-gateway/config/internal.yamlkubernetes/apps/istio-system/gateway/gateway.yamlkubernetes/apps/mcp-system/mcp-gateway/app/gateway.yaml
4.3 Delete the wildcard Certificate manifest
git rm kubernetes/apps/network/envoy-gateway/config/certificate.yaml
Also remove the entry from
kubernetes/apps/network/envoy-gateway/config/kustomization.yaml.
The reflector annotations on the (now-deleted) Certificate's
secretTemplate go away automatically; reflector stops mirroring;
the reflected Secret copies in istio-system and mcp-system are
garbage-collected.
4.4 Final verify
# No wildcard cert in any namespace
kubectl get certificate -A | grep -i wildcard
# Per-app certs all present and Ready
kubectl get certificate -A
# All public certs are short-duration; private CA certs may be longer
kubectl get certificate -A -o json | \
jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) duration=\(.spec.duration // "unset")"'
# Smoke test from outside (public) and from a CA-trusting device (internal)
for host in glance.thesteamedcrab.com photos.thesteamedcrab.com ... ; do
echo "$host:"
echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
| openssl x509 -noout -subject -dates
done
Rollback
The wildcard cert + listeners stay alive through Phase 1, 2, and 3.
Rollback during those phases is just reverting the HTTPRoute's
sectionName. Even after deleting some per-app listeners, the
wildcard listener catches the route.
Phase 4 is irreversible by git revert alone β once the wildcard
Certificate is deleted, re-creating it kicks off a new ACME order
(counts against rate limit). If you must roll back from Phase 4:
- Restore the Certificate manifest via git revert
- cert-manager re-issues β costs 1 against the 50/week budget
- Restore the wildcard listeners
- Flip HTTPRoutes back to
sectionName: https
So don't enter Phase 4 unless you're confident in the new state.
What to watch
kubectl get certificate -Aβ columnREADYshould always beTruekubectl get order -Aβ should be empty in steady state (orders exist transiently during issuance/renewal)kubectl describe clusterissuer letsencrypt-productionβ surfaces ACME backoff messages if rate-limited- ACME audit log at https://crt.sh/?Identity=thesteamedcrab.com β external counter you don't control, useful sanity check
When to stop and ask
- Any wave produces >0 failed Certificates after 5 min
- Total Pending+Failing Certificates count >3 across the cluster
- Any rate-limit error in Order events
- More than one wave in the rolling 7d window has produced retries
- Phase 0.1 test result was ambiguous
Alternative: all-ACME (no private CA)
If managing a private CA root on every client device is unacceptable:
- Skip Phase 1 entirely
- All ~70 routes go through ACME
- Wave size still 5/day; total elapsed ~14 days
- Internal hostnames will appear in public CT logs (https://crt.sh/?Identity=thesteamedcrab.com) β anyone can enumerate your service catalog from the cert transparency feed. Mitigate with hostnames that don't betray service identity if this matters.