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:

  1. Je maakt een Certificate resource
  2. cert-manager vraagt een certificaat aan bij de issuer (Let’s Encrypt, Vault, etc.)
  3. cert-manager voltooit de challenge (HTTP-01 of DNS-01) om te bewijzen dat je het domein beheert
  4. cert-manager slaat het certificaat op in een Kubernetes Secret
  5. 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-manager en 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.