TL;DR cert-manager houdt Certificate-resources in de gaten, haalt het certificaat bij Let’s Encrypt, je eigen CA of Vault, zet het in een Secret en vernieuwt het 30 dagen voor het verloopt. Ik draai één DNS-01 ClusterIssuer via Cloudflare en één wildcard-certificaat, kopieer die Secret naar de namespaces die hem nodig hebben, en laat twee Prometheus-alerts me vertellen wanneer een vernieuwing vastloopt. Niks meer om te onthouden.
Jarenlang vernieuwde ik m’n certificaten zoals de meeste mensen dat doen. Een agenda-reminder, een handmatige certbot-run, en de hoop dat ik er op tijd bij was. Dat werkte. Tot de ochtend waarop een service me om de oren sloeg met certfouten en ik geen idee had waarom. De renewal-cron bleek al weken te falen. Stilletjes.
Dat is het stuk van handmatige TLS waar niemand je voor waarschuwt. De fout meldt zich niet. Het certificaat verloopt gewoon, meestal op een rotmoment, en je hoort het omdat er bij iemand een browser staat te schreeuwen. Ondertussen zit de kennis over hoe die vernieuwing werkt in het hoofd van één persoon, en slaan teams HTTPS op interne services over omdat het handmatig optuigen nét vervelend genoeg is om uit te stellen.
Ik wilde dat het cluster z’n eigen certificaten beheerde, zodat ik het niet hoefde te doen. cert-manager doet precies dat. Je declareert welke certificaten je nodig hebt, hij regelt uitgifte, vernieuwing en de Kubernetes Secrets waar ze in zitten, en naar expiratiedata kijk je niet meer.
Het is een van de eerste dingen die ik in een nieuw cluster installeer, direct na de CNI .
Hoe cert-manager werkt
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. Geen agenda.
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
Die --set installCRDs=true is belangrijk. Sla hem over en de custom resources van cert-manager worden nooit geregistreerd, en de fouten die je later krijgt wijzen daar niet naar. Draai je alles via GitOps met ArgoCD, zoals ik, dan ziet de 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 over het verschil struikelen mensen geregeld:
- Issuer: werkt in één namespace
- ClusterIssuer: werkt over alle namespaces
Ik pak standaard een ClusterIssuer, tenzij ik een echte reden heb om een certificate authority binnen één namespace te houden. Eén issuer in plaats van een kopie per namespace.
Let’s Encrypt (ACME)
De setup waar de meeste mensen als eerste 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
Begin met de staging-issuer. De rate limits van Let’s Encrypt zijn streng, en zit je aan je config te sleutelen, dan loop je ertegenaan. Dat heb ik op de trage manier geleerd, een week buitengesloten nadat ik een solver-config een paar keer te vaak verkeerd had ingetypt. Staging geeft je untrusted certs via exact dezelfde flow, dus daar mag je het eerst stukmaken:
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 controleert dat jij het domein beheert voordat het je een cert geeft. Er zijn twee manieren om dat te bewijzen, en welke je kiest bepaalt alles wat erna komt.
Bij HTTP-01 haalt Let’s Encrypt een bestand op van /.well-known/acme-challenge/ op je domein:
solvers:
- http01:
ingress:
class: nginx
Werkt voor alles wat op poort 80 vanaf het publieke internet bereikbaar is. Snel opgezet, niks extra’s te configureren. Die bereikbaarheid is meteen de adder, want Let’s Encrypt moet dat bestand kunnen ophalen, en een interne service kan het hem niet geven.
Bij DNS-01 kijkt Let’s Encrypt in plaats daarvan naar 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 vaakst gebruik, omdat hij dekt wat HTTP-01 niet kan:
- 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 nodig, gescoped op de zones die je gebruikt. Grijp hier niet naar de global API key. Lekt een gescopete token, dan ben je de DNS van één zone kwijt. Lekt de global key, dan het hele account.
Private CA
Publieke certificaten zijn prima, tot je services hebt die helemaal nooit vanaf het publieke internet bereikbaar mogen zijn. Daarvoor draai je je eigen CA, en dan blijft de hele trust chain binnen infrastructuur 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 is de PKI-engine een solide issuer. Kortlevende certs, één auditlog 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 staat, zijn er twee manieren om er een certificaat uit te krijgen. Expliciet om eentje vragen, of cert-manager het laten afleiden uit een Ingress. Ik gebruik allebei, afhankelijk van hoeveel controle ik over de details wil.
Certificate resource
De expliciete route. Je schrijft 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. Eén annotatie op 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 ziet de annotatie, genereert de Certificate-resource voor je en vult de Secret. Voor de meeste app-deployments is dat alles wat je nodig hebt, en de TLS-config staat naast de routing-config, waar ik hem toch als eerste zou zoeken.
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, dus je krijgt éé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 DNS-01, 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"
Noem zowel *.example.com als example.com. De wildcard dekt het apex-domein niet, en die regel vergeten is hoe je een kapot cert op je root-domein serveert.
Cross-namespace Secrets
Hier zit een addertje waar mensen op stappen. Een certificaat-Secret leeft in precies één namespace, en vaak heb je hetzelfde cert in meerdere 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 manieren.
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 CA-bundles specifiek heeft cert-manager z’n eigen antwoord. trust-manager pusht een trust bundle naar ConfigMaps in meerdere namespaces, en dat wil je als wat je deelt het CA-cert is en geen 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 tot het faalt zonder het te zeggen, en dat is precies de val waar ik deze post mee opende. Dus cert-manager moet observeerbaar zijn, en een vastgelopen vernieuwing moet een alert worden in plaats van een storing. Zo prik ik erin als er iets niet klopt.
Check certificaatstatus
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
Doet een cert raar, dan loop je de keten af. Certificate, dan CertificateRequest, dan Order, dan Challenge. De fout zit bijna altijd in een van die describe-outputs, en meestal duikt hij op bij de Challenge.
Prometheus-metrics
Handmatige checks zijn voor debuggen. Voor het vangnet wil je metrics. cert-manager exporteert ze, en dit zijn de twee alerts waar ik 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 had me gered in m’n certbot-cron-tijd. Zeven dagen waarschuwing is ruim genoeg om het echte probleem op te lossen voordat er iets stukgaat wat een gebruiker ziet.
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, opgedaan door dit een tijdje te draaien:
1. Gebruik ClusterIssuers
Tenzij je 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
Ook met automatisering, monitor als vangnet:
certmanager_certificate_expiration_timestamp_seconds - time() < 604800
4. Gebruik DNS-01 voor interne services
Zijn je services niet publiek bereikbaar, dan werkt HTTP-01 niet.
5. Standaardiseer Secret-namen
Een conventie als ${service}-tls maakt het voorspelbaar:
secretName: api-example-com-tls
Mijn productiesetup
Dit is wat ik draai. Eén DNS-01 ClusterIssuer en één wildcard-certificaat, en bijna alles vloeit uit die twee resources voort:
# 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, dus ik hoef nooit na te denken over of een service publiek bereikbaar is
- Eén wildcard-certificaat betekent één cert voor elk subdomein en praktisch geen zorgen over rate limits
- Eén centrale namespace houdt het certificaat in
cert-manageren reflecteert het naar waar het nodig is - Prometheus-alerts, zodat ik dagen eerder van een probleem hoor dan een gebruiker
Waarom dit ertoe doet
TLS is al een tijd niet meer optioneel. Browsers waarschuwen bij plain HTTP, API’s weigeren het, en intern verkeer moet versleuteld als je zero trust ook maar een beetje serieus neemt.
De diepere reden dat ik hierom geef is frictie. Alles wat ervan afhangt dat ik eraan denk iets te doen, gaat een keer mis, want een keer vergeet ik het. Handmatige certificaatvernieuwing is precies zo’n systeem, met een agenda-reminder erop geplakt. Met cert-manager definieer ik een certificaat één keer en vernieuwt het cluster het, op dezelfde manier voor elke service en elk team.
De certbot-cron is weg. De wildcard hierboven vernieuwt zichzelf 30 dagen voor hij verloopt, en blijft een vernieuwing ooit hangen, dan hoor ik dat zeven dagen eerder dan een browser.
