Lange tijd vernieuwde ik mijn certificaten zoals de meeste mensen dat doen: een agenda-reminder, een handmatige certbot-run en de stille hoop dat ik er op tijd aan zou denken. Dat werkte. Het werkte precies tot de ochtend waarop een service me om de oren sloeg met certfouten en ik geen idee had waarom, omdat de renewal-cron al weken stilletjes faalde.
Dat is het stuk dat niemand je vertelt over handmatige TLS. De fout kondigt zichzelf niet aan. Het certificaat verloopt gewoon, meestal op het slechtst denkbare moment, en je komt erachter omdat een browser tegen iemand staat te schreeuwen. De kennis over vernieuwing eindigt in het hoofd van één persoon. Teams slaan HTTPS over op interne services omdat het handmatig optuigen vervelend genoeg is om uit te stellen.
Ik wilde dat certificaten iets werden dat het cluster bezit, niet iets dat ik moest onthouden. cert-manager geeft je precies dat. Je declareert welke certificaten je nodig hebt, en het regelt uitgifte, vernieuwing en de Kubernetes Secrets waarin ze leven. Je stopt volledig met nadenken over expiratiedata.
Het is een van de eerste dingen die ik installeer in een nieuw cluster, direct na de CNI.
Hoe cert-manager werkt
flowchart TD
subgraph cluster["Kubernetes Cluster"]
CM["cert-manager"]
CERT["Certificate<br/>Resource"]
SECRET["TLS Secret"]
INGRESS["Ingress"]
end
subgraph external["Extern"]
LE["Let's Encrypt<br/>ACME Server"]
DNS["DNS Provider"]
end
CERT -->|"watched"| CM
CM -->|"maakt"| SECRET
CM <-->|"ACME protocol"| LE
CM <-->|"DNS challenge"| DNS
SECRET -->|"mount"| INGRESS
De loop is simpel zodra je hem ziet:
- Je maakt een Certificate resource
- cert-manager vraagt een certificaat aan bij de issuer (Let’s Encrypt, Vault, etc.)
- cert-manager voltooit de challenge (HTTP-01 of DNS-01) om te bewijzen dat je het domein beheert
- cert-manager slaat het certificaat op in een Kubernetes Secret
- Je Ingress of Gateway mount die Secret voor TLS
Vernieuwing gebeurt vanzelf, 30 dagen voor expiratie, zonder dat er een agenda-reminder aan te pas komt. Die laatste zin is het hele punt van deze post.
Installatie
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set installCRDs=true
De --set installCRDs=true is belangrijk. Sla hem over en cert-managers custom resources worden nooit geregistreerd, wat later wat verwarrende fouten oplevert. Als je alles via GitOps met ArgoCD draait zoals ik, ziet de equivalente Application er zo uit:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager
namespace: argocd
spec:
project: default
source:
repoURL: https://charts.jetstack.io
chart: cert-manager
targetRevision: v1.14.0
helm:
values: |
installCRDs: true
prometheus:
servicemonitor:
enabled: true
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
syncOptions:
- CreateNamespace=true
Issuers
Een Issuer vertelt cert-manager waar certificaten vandaan komen. Er zijn twee scopes, en het verschil zorgt geregeld voor verwarring:
- Issuer: werkt in één namespace
- ClusterIssuer: werkt over alle namespaces
Ik gebruik standaard een ClusterIssuer, tenzij ik een echte reden heb om een certificate authority tot één namespace te beperken. Minder duplicatie, minder verrassingen.
Let’s Encrypt (ACME)
Dit is de setup waar de meeste mensen het eerst naar grijpen, gratis certificaten van Let’s Encrypt:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: nginx
Belangrijk: begin met de staging issuer. Let’s Encrypt heeft agressieve rate limits, en als je aan je config zit te sleutelen loop je er tegenaan. Ik leerde dat de trage manier, een week buitengesloten nadat ik een solver-config een paar keer te vaak verkeerd had ingetypt. Staging geeft je untrusted certs die exact dezelfde flow doorlopen:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: nginx
HTTP-01 vs DNS-01 challenges
De challenge is hoe Let’s Encrypt bevestigt dat je het domein daadwerkelijk beheert voordat het je een cert geeft. Er zijn twee manieren om dat te bewijzen, en welke je kiest bepaalt alles daarna.
HTTP-01: Let’s Encrypt verifieert dat je het domein beheert door een bestand te plaatsen op /.well-known/acme-challenge/:
solvers:
- http01:
ingress:
class: nginx
Werkt voor alles wat publiek bereikbaar is op poort 80. Snel op te zetten, niets extra’s te configureren. De adder zit in de naam: de service moet bereikbaar zijn vanaf het publieke internet zodat Let’s Encrypt dat bestand kan ophalen.
DNS-01: Let’s Encrypt verifieert in plaats daarvan via een DNS TXT record:
solvers:
- dns01:
cloudflare:
email: admin@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
DNS-01 is degene die ik het meest gebruik, omdat het de gevallen dekt die HTTP-01 niet aankan:
- Wildcard certificaten (
*.example.com), die HTTP-01 simpelweg niet kan uitgeven - Interne services die nooit het publieke internet raken
- Split-horizon DNS-opstellingen
Cloudflare DNS-01 setup
# API Token Secret
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: cert-manager
type: Opaque
stringData:
api-token: "your-cloudflare-api-token"
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-cloudflare
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-cloudflare-account
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
De Cloudflare API token heeft Zone:DNS:Edit permissie nodig, gescoped tot de zones die je daadwerkelijk gebruikt. Grijp hier niet naar de global API key. Gescopete tokens zijn juist de bedoeling, en een gelekte global key is een vervelende middag.
Private CA
Publieke certificaten zijn prima totdat je services hebt die nooit aan het publieke internet blootgesteld zouden mogen worden. Voor die services draai je je eigen CA, wat de hele trust chain binnen infrastructuur houdt die jij beheert:
# Maak CA key pair
apiVersion: v1
kind: Secret
metadata:
name: ca-key-pair
namespace: cert-manager
type: kubernetes.io/tls
data:
tls.crt: <base64-encoded-ca-cert>
tls.key: <base64-encoded-ca-key>
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
ca:
secretName: ca-key-pair
Of laat cert-manager een self-signed CA maken:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-issuer
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-ca
namespace: cert-manager
spec:
isCA: true
commonName: my-ca
secretName: my-ca-secret
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-issuer
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: my-ca-issuer
spec:
ca:
secretName: my-ca-secret
HashiCorp Vault
Draai je al Vault, dan maakt de PKI engine een solide issuer. Je krijgt kortlevende certs, centrale audit logging en één plek om vanaf te revoken:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: vault-issuer
spec:
vault:
server: https://vault.vault:8200
path: pki/sign/my-role
auth:
kubernetes:
role: cert-manager
mountPath: /v1/auth/kubernetes
secretRef:
name: cert-manager-vault-token
key: token
Certificaten aanvragen
Zodra er een issuer bestaat, zijn er twee manieren om daadwerkelijk een certificaat te krijgen. Je kunt er expliciet om vragen, of je laat cert-manager het afleiden uit een Ingress. Ik gebruik beide, afhankelijk van hoeveel controle ik over de details wil.
Certificate Resource
De expliciete route. Je schrijft precies uit wat je wilt:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-example-com
namespace: production
spec:
secretName: api-example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- api.example.com
- api-v2.example.com
duration: 2160h # 90 dagen
renewBefore: 720h # Vernieuw 30 dagen voor expiratie
Ingress annotatie (automatisch)
De luie route, in de goede zin. Voeg één annotatie toe aan een Ingress en cert-manager doet de rest:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- api.example.com
secretName: api-example-com-tls
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80
cert-manager spot de annotatie, genereert de Certificate resource voor je en vult de Secret. Voor de meeste app deployments is dit alles wat je nodig hebt, en het houdt de TLS config naast de routing config waar die thuishoort.
Gateway API
Stap je over op de Gateway API in plaats van Ingress? Zelfde idee, andere resource:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: cilium
listeners:
- name: https
port: 443
protocol: HTTPS
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- name: wildcard-example-com-tls
Wildcard certificaten
Een wildcard cert dekt elk subdomein onder één naam, wat betekent: één certificaat in plaats van een nieuw exemplaar per service. Dat alleen al houdt je ruim weg van de rate limits van Let’s Encrypt. De prijs is dat je DNS-01 moet gebruiken, want HTTP-01 kan een wildcard niet valideren:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-example-com
namespace: production
spec:
secretName: wildcard-example-com-tls
issuerRef:
name: letsencrypt-cloudflare
kind: ClusterIssuer
dnsNames:
- "*.example.com"
- "example.com"
Let op: noem zowel *.example.com als example.com. De wildcard dekt het apex domein niet, en die regel vergeten is een klassieke manier om een kapot cert op je root domein te serveren.
Cross-Namespace Secrets
Hier zit een addertje dat mensen verrast. Een certificaat-Secret leeft in precies één namespace, maar vaak heb je hetzelfde cert in meerdere namespaces nodig. Een Secret in cert-manager kan niet gemount worden door een pod in production. Dus hoe deel je één cert over namespaces zonder telkens een apart exemplaar aan te maken? Twee aanpakken.
Reflector (externe controller)
kubernetes-reflector kopieert een Secret naar de namespaces die je opgeeft:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: shared-cert
namespace: cert-manager
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "production,staging"
spec:
secretName: shared-cert-tls
# ...
trust-manager
Voor het verspreiden van CA bundles specifiek heeft cert-manager zijn eigen antwoord. trust-manager pusht een trust bundle naar ConfigMaps over namespaces heen, wat je wilt wanneer wat je deelt het CA cert is in plaats van een leaf certificaat:
helm install trust-manager jetstack/trust-manager \
--namespace cert-manager
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
name: my-ca-bundle
spec:
sources:
- secret:
name: my-ca-secret
key: ca.crt
target:
configMap:
key: ca-bundle.crt
namespaceSelector:
matchLabels:
trust-bundle: enabled
Monitoring en troubleshooting
Automatisering is geweldig totdat het stilletjes faalt, wat precies de val is waar ik deze post mee begon te klagen. De oplossing is om cert-manager observeerbaar te maken, zodat een vastgelopen vernieuwing een alert wordt in plaats van een outage. Zo prik ik erin als er iets niet klopt.
Check certificaat status
kubectl get certificates -A
kubectl describe certificate api-example-com -n production
Check certificate requests
kubectl get certificaterequests -A
kubectl describe certificaterequest api-example-com-xyz -n production
Check orders en challenges
kubectl get orders -A
kubectl get challenges -A
# Debug een vastgelopen challenge
kubectl describe challenge api-example-com-xyz-123 -n production
Bij een misdragend cert loop je deze keten af: Certificate, dan CertificateRequest, dan Order, dan Challenge. De fout zit bijna altijd in een van die describe-outputs, en de mislukking duikt meestal op op Challenge-niveau.
Prometheus metrics
Handmatige checks zijn voor debuggen. Voor het vangnet wil je metrics. cert-manager exporteert ze, en deze twee alerts zijn degene waar ik echt op leun:
# Alert op verlopende certificaten
- alert: CertificateExpiringSoon
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 604800
for: 1h
labels:
severity: warning
annotations:
summary: "Certificaat {{ $labels.name }} verloopt binnen 7 dagen"
# Alert op gefaalde vernieuwingen
- alert: CertificateRenewalFailed
expr: certmanager_certificate_ready_status{condition="False"} == 1
for: 1h
labels:
severity: critical
annotations:
summary: "Certificaat {{ $labels.name }} is niet klaar"
Die eerste alert is degene die me gered had in mijn certbot-cron-tijd. Een expiratiewaarschuwing zeven dagen van tevoren geeft je ruim de tijd om het echte probleem op te lossen voordat er iets gebruikergerichts stukgaat.
Veelvoorkomende problemen
Challenge blijft pending:
- HTTP-01: Ingress routeert niet naar cert-manager solver
- DNS-01: API credentials verkeerd of missende permissies
Rate limited:
- Te veel certificaten aangevraagd bij Let’s Encrypt
- Gebruik staging issuer voor testen
- Consolideer in wildcard certificaten
Secret updatet niet:
- Check Certificate resource status
- Check CertificateRequest en Order resources
- Bekijk cert-manager logs:
kubectl logs -n cert-manager deploy/cert-manager
Best practices
Een handvol gewoontes die voortkwamen uit dit een tijdje draaien:
1. Gebruik ClusterIssuers
Tenzij je echt namespace isolatie nodig hebt, beperken ClusterIssuers de duplicatie:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
# Beschikbaar voor alle namespaces
2. Scheid staging en productie
Heb altijd beide issuers:
# letsencrypt-staging voor testen
# letsencrypt-prod voor productie
3. Monitor expiratie
Zelfs met automatisering, monitor als vangnet:
certmanager_certificate_expiration_timestamp_seconds - time() < 604800
4. Gebruik DNS-01 voor interne services
Als je services niet publiek toegankelijk zijn, werkt HTTP-01 niet.
5. Standaardiseer Secret namen
Conventie zoals ${service}-tls maakt het voorspelbaar:
secretName: api-example-com-tls
Mijn productie setup
Dit is wat ik daadwerkelijk draai. Eén DNS-01 ClusterIssuer en één wildcard certificaat, en bijna alles vloeit voort uit die twee resources:
# ClusterIssuer voor Let's Encrypt met Cloudflare DNS
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: certs@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- "example.com"
---
# Wildcard certificaat voor alle services
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-example-com
namespace: cert-manager
spec:
secretName: wildcard-example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- "*.example.com"
- "example.com"
Waarom deze vier:
- DNS-01 via Cloudflare handelt alles af, wildcards inbegrepen, zodat ik nooit hoef na te denken over of een service publiek bereikbaar is
- Een wildcard certificaat betekent één cert voor elk subdomein en praktisch geen rate limit zorgen
- Een centrale namespace houdt het certificaat in
cert-manageren reflecteert het uit naar waar het nodig is - Prometheus alerts zodat ik dagen eerder van een probleem hoor dan een gebruiker
Waarom dit ertoe doet
TLS is een tijd geleden gestopt met optioneel zijn. Browsers waarschuwen bij plain HTTP, APIs weigeren het, en intern verkeer heeft encryptie nodig als je zero trust ook maar enigszins serieus neemt.
De diepere reden waarom ik hierom geef komt terug op frictie. Alles wat afhangt van mij die eraan denkt om iets te doen, is een systeem dat uiteindelijk faalt, want ik vergeet het uiteindelijk. Handmatige certificaatvernieuwing is precies zo’n systeem, verkleed als agenda-reminder. cert-manager maakt van TLS iets dat het cluster zelf onderhoudt: definieer een certificaat één keer, en het vernieuwt voor altijd, op dezelfde manier voor elke service en elk team.
Dat is het soort automatisering dat de moeite waard is. Niet de flashy soort, maar de soort die stilletjes een hele categorie 3-uur-’s-nachts-pages weghaalt en je het probleem laat vergeten. De beste security-control hier is degene waar je over bent gestopt na te denken.
