Automatic TLS with cert-manager, Let's Encrypt, and Traefik IngressRoutes

Published: 2026-04-15

Every subdomain on this server gets a valid TLS certificate automatically. No manual renewals, no certbot cronjob, no wildcard cert. Here's the full picture from ClusterIssuer to browser padlock.


Two issuers: staging and production

yamlapiVersion: 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
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
---
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
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

The staging issuer doesn't count against Let's Encrypt rate limits (50 certs/domain/week). Always use staging first when setting up a new domain — if the HTTP-01 challenge fails (wrong DNS, port 80 blocked), you won't burn production attempts.


HTTP-01 challenge mechanics

The http01 solver proves domain ownership by serving a token at:

http://<domain>/.well-known/acme-challenge/<token>

cert-manager temporarily creates an Ingress object for this path. Traefik picks it up and routes the ACME server's validation request to cert-manager's solver pod.

Prerequisites:

  1. The domain's A record must point to the server's public IP before requesting the cert.
  2. Port 80 must be open. Traefik listens on 80 with hostNetwork: true and redirects to 443 — but the ACME challenge is served on 80 before the redirect fires.

Certificate object

yamlapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: site-tls
  namespace: default
spec:
  secretName: site-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - example.com
    - www.example.com
    - blog.example.com
    - cv.example.com
    - proxy.example.com
  renewBefore: 720h  # 30 days before expiry

All subdomains share one certificate with multiple SANs. cert-manager:

  1. Requests a single cert covering all names
  2. Stores the key and cert chain in the site-tls Secret
  3. Automatically renews ~30 days before expiry
  4. Triggers a cert rotation if you add new entries to dnsNames

IngressRoute wiring

Traefik uses its own CRD instead of the standard Ingress. The TLS section references the same Secret:

yamlapiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: site-main
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`example.com`)
      kind: Rule
      services:
        - name: site-backend
          port: 8000
    - match: Host(`www.example.com`)
      kind: Rule
      middlewares:
        - name: www-redirect
      services:
        - name: site-backend
          port: 8000
  tls:
    secretName: site-tls

Each subdomain gets its own route rule.


Subdomain → path prefix middleware

The FastAPI app serves everything under one router. Subdomains are rewritten to path prefixes by a Traefik Middleware:

yamlapiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: blog-prefix
spec:
  addPrefix:
    prefix: /weblog
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: www-redirect
spec:
  redirectRegex:
    regex: "^https://www\\.(.+)"
    replacement: "https://${1}"
    permanent: true

blog.example.com/ → app sees /weblog/. One pod, one Service, one Deployment handles all subdomains.


Checking cert status

bash# List all certificates
kubectl get certificate -A

# Check specific cert
kubectl describe certificate site-tls

# Check the ACME order (if cert is pending)
kubectl get orders -A
kubectl describe order <order-name>

# Check the challenge (if order is pending)
kubectl get challenges -A
kubectl describe challenge <challenge-name>

The Ready: True condition means the cert is issued and stored. If stuck in False, check the order → challenge chain for the specific failure.


Debug: manual HTTP-01 challenge verification

bash# Check that cert-manager's solver pod is running
kubectl get pods -n cert-manager

# Check that Traefik has picked up the temporary Ingress
kubectl get ingress -A | grep acme

# Verify port 80 is reachable
curl -v http://example.com/.well-known/acme-challenge/test

Certificate renewal

cert-manager renews automatically. To force immediate renewal:

bashkubectl delete secret site-tls

cert-manager detects the missing Secret, re-triggers the ACME flow, and creates a new cert. Traefik will briefly serve the old cert from memory until the Secret is recreated (usually under 60 seconds).


Rate limits

Let's Encrypt limits:

  • 50 certs per registered domain per week
  • 5 failures per account per hostname per hour
  • Staging has no limits (but certs are not trusted by browsers)

If you hit a rate limit, check https://crt.sh/?q=example.com to see all issued certs.


Summary

  • cert-manager + Let's Encrypt + Traefik provides fully automatic TLS with zero manual renewal
  • ClusterIssuer is created once; each Certificate resource references it by name — no per-app ACME config needed
  • HTTP-01 challenge works for individual subdomains; DNS-01 is required for wildcard certs
  • Start with the Let's Encrypt staging issuer to avoid rate limits during setup; switch to production after first successful cert
  • Check cert status with kubectl describe certificate -n <ns> and kubectl get certificaterequest — most issues are visible there