· · 11 min leestijd Intermediate

cert-manager: automatische TLS certificaten in Kubernetes

Automatiseer TLS certificaat management in Kubernetes met cert-manager. Van Let's Encrypt tot private CAs, nooit meer handmatig vernieuwen.

Een robotarm wisselt gloeiende zegels in een gang terwijl een manager-wezen slaapt onder een gemiste kalenderdatum

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

Extern

Kubernetes Cluster

watched

maakt

ACME protocol

DNS challenge

mount

cert-manager

Certificate
Resource

TLS Secret

Ingress

Let's Encrypt
ACME Server

DNS Provider

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. 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-manager en 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.