Do not use this skill for code-signing certs, SSH keys, or client certs unrelated to TLS termination.
-
Detect the stack. Run these read-only commands and record findings:
kubectl config current-context — confirm a Kubernetes cluster is addressable. No cluster → stop; this skill requires kubernetes.
kubectl get crd certificates.cert-manager.io 2>/dev/null — cert-manager installed?
helm list -A 2>/dev/null | grep -Ei 'cert-manager|jetstack' — cert-manager via Helm?
kubectl get ingressclass 2>/dev/null — what ingress controller is in use (nginx, traefik, istio, contour)?
kubectl get secrets -A --field-selector type=kubernetes.io/tls -o name 2>/dev/null | head — existing TLS secrets (manual uploads vs managed)?
ls aws-load-balancer-controller/ 2>/dev/null or kubectl get crd targetgroupbindings.elbv2.k8s.aws 2>/dev/null — AWS ALB/ACM in the mix instead?
Confirm the detected stack is cert-manager+k8s. If the cluster uses AWS ACM directly via ALB, GCP managed certs via GKE ingress, Azure Key Vault certs, or a commercial CA workflow (Sectigo / DigiCert via manual CSR), STOP and report the detected stack to the user — those are different issuance/rotation surfaces and cert-manager config here would be misleading.
-
Inventory. Build the full cert list.
# cert-manager-managed certs
kubectl get certificates -A -o json | jq -r '
.items[] | [.metadata.namespace, .metadata.name, .spec.secretName, (.status.notAfter // "pending")] | @tsv'
# TLS secrets regardless of source
kubectl get secrets -A --field-selector type=kubernetes.io/tls -o json | jq -r '
.items[] | [.metadata.namespace, .metadata.name,
((.data."tls.crt" | @base64d) | split("-----BEGIN CERTIFICATE-----")[1])] | @tsv'
# Live-edge scan per hostname (what a client actually sees)
for h in api.example.com app.example.com; do
echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates -ext subjectAltName
done
For each TLS secret, decode and inspect:
kubectl -n <ns> get secret <name> -o json \
| jq -r '.data["tls.crt"]' | base64 -d \
| openssl x509 -noout -issuer -subject -dates -ext subjectAltName
Compare to the live edge. Mismatches (ingress serving a stale cert) usually mean the ingress controller needs a reload.
-
Install cert-manager if missing:
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm upgrade --install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--version v1.15.3 --set crds.enabled=true --wait
Validate: kubectl -n cert-manager get pods, cmctl check api.
-
Create ClusterIssuers. Start with staging to avoid the Let's Encrypt rate limit while iterating.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef: { name: letsencrypt-staging-key }
solvers:
- http01:
ingress: { ingressClassName: nginx }
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef: { name: letsencrypt-prod-key }
solvers:
- http01:
ingress: { ingressClassName: nginx }
selector:
dnsZones: ["example.com"]
- dns01:
route53:
region: eu-west-1
hostedZoneID: Z123ABC
selector:
dnsZones: ["example.com"]
HTTP-01 requires the ingress to be reachable from the public internet on :80. DNS-01 requires IRSA / Workload Identity binding to a DNS-write IAM role; never use static long-lived AWS keys.
-
Issue certs. For each hostname group:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-example-com
namespace: app
spec:
secretName: api-example-com-tls
issuerRef: { name: letsencrypt-prod, kind: ClusterIssuer }
dnsNames:
- api.example.com
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
renewBefore: 720h
Reference the secret from the Ingress:
spec:
tls:
- hosts: [api.example.com]
secretName: api-example-com-tls
Wait for Ready=True:
kubectl -n app wait certificate/api-example-com --for=condition=Ready --timeout=5m
cmctl status certificate -n app api-example-com
-
For wildcards (*.example.com): HTTP-01 cannot issue wildcards; DNS-01 only. Use the dns01 solver above and confirm the _acme-challenge.example.com TXT record is being created and deleted by cert-manager (check with dig +short TXT _acme-challenge.example.com).
-
Alerts. Apply the rules from monitoring-setup/references/alertmanager-rules-template.yaml (the tls group). Add a blackbox-exporter probe per hostname:
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: public-hosts
namespace: monitoring
spec:
jobName: blackbox-https
interval: 60s
module: http_2xx
prober:
url: blackbox-exporter.monitoring.svc:9115
targets:
staticConfig:
static:
- https://api.example.com
- https://app.example.com
-
Zero-downtime rotation. To force a renew:
cmctl renew -n app api-example-com
cert-manager writes a new tls.crt/tls.key into the existing secret. Ingress controllers (nginx, Traefik, HAProxy, Istio) hot-reload when the secret's data changes; no pod restart required. Validate:
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -dates
Confirm notAfter advanced. If an ingress is pinned to a mounted file (e.g. Envoy with path: instead of sds:), rotation requires a pod restart — flag this during the inventory.
-
Roll-back. Keep the prior secret for 7 days. cert-manager retains the previous issuance in status.previousIssuedCertificate when rotationPolicy: Always is set. To pin the old cert temporarily, revert the Ingress secretName to the previous secret.
Inventory found 5 manually-uploaded certs expiring within 45 days. After install: