Documentation navigation
Certificate Never Issued
Runbook for a cert-manager Certificate with no issued Secret, triggered by the Certificate Never Issued alert (frontmatter alert: block in this runbook, provisioned by the runbook_alerts Terraform module → infrastructure/prod/synapse-alerts → PrometheusRule CRD synced to the Mimir ruler). Low priority by design: some of these are harmless leftovers, and the endpoints involved may not even exist.
Created (2026-10-16) as the third tier of the certificate alert family. This state used to be swallowed by the two sibling alerts as garbage values — cert-manager's zero sentinel (expiration_timestamp_seconds = 0, Unix epoch) read through their unguarded < 15 days / < 0 days comparisons as "expires in -20,742 days" / "renewal overdue by 20,742 days", so both siblings fired permanently on every never-issued Certificate. The siblings now guard > 0 (live certs only) and this alert owns the no-secret state explicitly, so a genuinely-broken issuance is one quiet P4 list instead of two permanent P3 warnings that everyone ignores.
07-kubernetes/09-certificate-expiring-soon.md— a live cert nearing expiry (renewal not happening).07-kubernetes/17-certificate-renewal-stuck.md— a live cert whose scheduled renewal is overdue (the early signal).- This runbook — no cert at all.
Meaning
cert-manager exports certmanager_certificate_expiration_timestamp_seconds = 0 (Unix epoch) for a Certificate CR with no Secret containing an issued certificate. That state means one of two things:
- First issuance in flight — the resource was created minutes ago and cert-manager is running its first Order/Challenge. Normal; completes in minutes. The
for: 1hwindow is chosen so this never alerts. - Issuance permanently failing — the Order/Challenge has been failing since creation, so the resource has never had a secret. The Certificate cannot do its job: whatever references it (an Ingress TLS block, a Gateway listener, a webhook, a CNPG cluster) is either falling back to a default cert, broken, or — most commonly — gone entirely, leaving the Certificate as a zombie.
The metric carries name (the Certificate CR name), exported_namespace (the Certificate CR's namespace — the scraped namespace label always reads cert-manager, the controller pod's namespace), issuer_name, issuer_kind.
Impact
- Usually none — most never-issued Certificates are leftovers for endpoints that no longer exist (see live examples below). The alert is P4 for exactly this reason.
- If the hostname IS in use, the endpoint has no valid TLS from this Certificate: the ingress presents a default/fallback cert or refuses the handshake. Browsers see cert errors; clients fail
x509. - Hidden cost even when unused — cert-manager keeps a live reconciliation loop for every Certificate: per failed challenge retry it spins a solver pod + Service + HTTPRoute/Ingress and re-attempts every backoff interval. A zombie burns controller cycles and ACME rate-limit headroom (repeated failed authorizations count against the Let's Encrypt rate limits that the healthy certs on the same domain/issuer need).
- If the Certificate is webhook-backed (cert-manager's own, CNPG webhooks) and never issued, the corresponding feature is broken — raise priority accordingly.
Diagnosis
The alert labels carry k8s_cluster_name, exported_namespace, name, issuer_name, environment.
1. Is this a brand-new Certificate? (expected to self-resolve)
export KUBECONFIG=~/.kube/<cluster>.yaml
NS=<exported_namespace>
CERT=<name>
kubectl -n $NS get certificate $CERT -o jsonpath='{.metadata.creationTimestamp}{"\n"}'
If created minutes ago with Issuing=False→True in the status conditions, watch it — healthy first issuance completes in minutes and the alert never progresses past pending. A Certificate created months ago with zero secret is a zombie: continue.
2. Why is issuance failing?
kubectl -n $NS describe certificate $CERT | sed -n '/^Status:/,$p'
kubectl -n $NS get orders,challenges
kubectl -n cert-manager logs deploy/cert-manager --tail=200 | grep $CERT
The certificate status and cert-manager logs name the failure — same taxonomy as 07-kubernetes/09-certificate-expiring-soon.md § Diagnosis step 2 (HTTP-01 self-check 404/timeout, DNS-01 missing record, rate limit, broken issuer). Common zombie signatures:
- Placeholder / undelegated dnsName —
argocd.example.com,*-invalid-replace-me-*, or a domain with no DNS record at all (dig +short <dnsName>empty). Issuance can never succeed; nobody depended on the result. - HTTP-01 self-check 404 — the domain still resolves to the ingress, but no route serves
/.well-known/acme-challenge/*for that host any more (the owner Ingress/Gateway/route was removed or the solver is not routed).curl -s -o /dev/null -w "%{http_code}" http://<dnsName>/.well-known/acme-challenge/probereturning 404 confirms it.
3. Is the Certificate actually used?
# Who references the secret the Certificate would produce?
kubectl -n $NS get certificate $CERT -o jsonpath='{.spec.secretName}{"\n"}'
kubectl -n $NS get ingress,gateway,httproute -o yaml | grep -B5 -A5 "<secretName>"
grep -rn "<secretName>\|$CERT" ~/cc/deployments --include="*.yaml" | grep -v .terraform
If nothing in the cluster and nothing in the deployments repo references the secret name (i.e. the resource is not even GitOps-managed — check the annotations for app.kubernetes.io/managed-by, or owner references from a still-existing Ingress), it is a leftover.
Note: Ingress-shim-created Certificates (ownerReference kind Ingress) live and die with their Ingress — if the Ingress is gone but the Certificate survived (shim adoption quirks can orphan them), handle the Ingress annotation, not just the Certificate.
Mitigation
Step 1 — Activate PIM
Activate the required Azure Privileged Identity Management roles before touching production, as in 03-kafka/01-consumer-lag.md.
Step 2 — Pick the path
- Zombie (nothing references it) — delete the Certificate CR. If it is GitOps-managed, delete it in the deployments repo (ArgoCD self-heals a
kubectl deleteon a managed resource); if hand-applied, confirm ownership first (search the deployments repo and ask the #platform channel for the domain), thenkubectl -n $NS delete certificate $CERT. Deleting is also the ACME-friendliest option: it stops failed-authorization retries from burning rate limit that healthy certs on the same domain need. - Placeholder dnsName — either delete the resource, or fix the configuration that templated a placeholder into it (e.g. set the real ArgoCD domain in the chart values and let the Ingress-shim recreate a valid Certificate). Check siblings: the same chart on other clusters usually has the correct value to copy.
- Legit hostname, failing challenge — follow
07-kubernetes/09-certificate-expiring-soon.md§ Diagnosis step 2 / § Mitigation step 2 (fix the HTTP-01 route, DNS, rate limit, or issuer), then force a retry:
kubectl -n $NS certificate $CERT renew
kubectl -n $NS get certificate $CERT -w # Ready flips Issuing -> True within minutes
Step 3 — Cleanup check
After deleting a zombie, confirm the challenge garbage is gone (cert-manager usually cleans up on delete): kubectl -n $NS get orders,challenges.
Verify
- The alert clears once the Certificate gets a Secret (expiration timestamp becomes real) — the metric flips from 0 within a scrape interval of
Ready=True. - Or the alert simply disappears when the zombie CR is deleted (series drops).
kubectl -n $NS get certificate $CERTshowsReady=Truewith aNot Afterin the future — from then on the siblings own it (09-certificate-expiring-soon.mdfor expiry drift,17-certificate-renewal-stuck.mdfor stuck renewals).
Notes
- The zero sentinel —
expiration_timestamp_seconds = 0is cert-manager's "no Secret" value; unguarded comparisons turn it into "expires in -20,742 days". The siblings now filter> 0; this alert explicitly matches== 0and requires it for 1h so first-issuance races never alert. exported_namespace, notnamespace— the scrape pipeline overwritesnamespacewith the cert-manager controller pod's namespace (alwayscert-manager); the Certificate CR's real namespace is inexported_namespace.kubectl -n cert-manager get certificate ...returns NotFound — use the exported one.- Live examples (2026-10-16 investigation, all firing the old unguarded siblings for 30+ days each):
infra-gateway-us-corti-app-tls(prod-us,shared) — dnsNameinfra.gateway.us.corti.app, HTTP-01 self-check returns 404 (domain resolves to the ingress but no route serves the challenge path), not in the GitOps repo — hand-applied leftover. TLS handshake for the hostname fails outright. Verdict pending ownership: delete.inference-gateway-invalid-replace-me-corti-app-tls(dev-weu,shared) — the name is the diagnosis. cert-manager has been retrialing its HTTP-01 for 30+ days, holding solver pods/HTTPRoutes. Verdict: delete.argocd-server-tls(dev-par02,argocd) — created 2026-07-02 with dnsNameargocd.example.com, a placeholder domain than can never be issued (the same-named certs on every other cluster are fine — dev-par02's chart values never got the real domain). Verdict: set the real domain.chat-dev-weu-corti-app-cert(dev-par01,base) — the counter-example: created the same morning by thelibrechatIngress-shim, first issuance in flight. Expected to self-resolve — the case thefor: 1hwindow exists for.
- Related —
07-kubernetes/09-certificate-expiring-soon.md(live cert nearing expiry) and07-kubernetes/17-certificate-renewal-stuck.md(live cert, renewal overdue), samekubernetes.securitygroup.