Manual certificate management is a recipe for outages. Certificates expire at 3 AM on a holiday weekend. Renewal processes live in tribal knowledge. Teams deploy services without HTTPS because “it’s too complicated.”
cert-manager automates everything. Define what certificates you need, and cert-manager handles issuance, renewal, and Kubernetes Secret management. Forever.
This is one of the first things I install in every cluster.
How cert-manager Works
flowchart TD
subgraph cluster["Kubernetes Cluster"]
CM["cert-manager"]
CERT["Certificate<br/>Resource"]
SECRET["TLS Secret"]
INGRESS["Ingress"]
end
subgraph external["External"]
LE["Let's Encrypt<br/>ACME Server"]
DNS["DNS Provider"]
end
CERT -->|"watches"| CM
CM -->|"creates"| SECRET
CM <-->|"ACME protocol"| LE
CM <-->|"DNS challenge"| DNS
SECRET -->|"mounts"| INGRESS
- You create a Certificate resource
- cert-manager requests a certificate from the issuer (Let’s Encrypt, Vault, etc.)
- cert-manager completes the challenge (HTTP-01 or DNS-01)
- cert-manager stores the certificate in a Kubernetes Secret
- Your Ingress/Gateway uses the Secret for TLS
Renewal happens automatically 30 days before expiration.
Installation
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
For GitOps with ArgoCD:
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
An Issuer tells cert-manager where to get certificates. Two scopes:
- Issuer — Works in one namespace
- ClusterIssuer — Works across all namespaces
Let’s Encrypt (ACME)
The most common setup — free certificates from 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
Important: Start with staging to avoid rate limits:
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
HTTP-01: Let’s Encrypt verifies you control the domain by placing a file at /.well-known/acme-challenge/:
solvers:
- http01:
ingress:
class: nginx
Works for publicly accessible services. Quick and simple.
DNS-01: Let’s Encrypt verifies via DNS TXT record:
solvers:
- dns01:
cloudflare:
email: admin@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
Required for:
- Wildcard certificates (
*.example.com) - Internal services not publicly accessible
- Split-horizon DNS
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
Cloudflare API token needs Zone:DNS:Edit permission.
Private CA
For internal services, use your own CA:
# Create 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
Or let cert-manager create a self-signed CA:
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
Integrate with Vault for PKI:
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
Requesting Certificates
Certificate Resource
Explicitly request a certificate:
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 days
renewBefore: 720h # Renew 30 days before expiry
Ingress Annotation (Automatic)
Let cert-manager create certificates automatically from Ingress:
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 sees the annotation, creates a Certificate resource, and populates the Secret.
Gateway API
For Gateway API instead of Ingress:
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 Certificates
For *.example.com, you need DNS-01:
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"
Note: Include both *.example.com and example.com — wildcard doesn’t cover the apex domain.
Cross-Namespace Secrets
Certificates live in one namespace, but you might need them elsewhere. Options:
Reflector (External Controller)
Use kubernetes-reflector:
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
cert-manager’s official solution for CA bundle distribution:
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 and Troubleshooting
Check Certificate 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 and Challenges
kubectl get orders -A
kubectl get challenges -A
# Debug a stuck challenge
kubectl describe challenge api-example-com-xyz-123 -n production
Prometheus Metrics
cert-manager exports metrics for alerting:
# Alert on expiring certificates
- alert: CertificateExpiringSoon
expr: certmanager_certificate_expiration_timestamp_seconds - time() < 604800
for: 1h
labels:
severity: warning
annotations:
summary: "Certificate {{ $labels.name }} expires in less than 7 days"
# Alert on failed renewals
- alert: CertificateRenewalFailed
expr: certmanager_certificate_ready_status{condition="False"} == 1
for: 1h
labels:
severity: critical
annotations:
summary: "Certificate {{ $labels.name }} is not ready"
Common Issues
Challenge stuck pending:
- HTTP-01: Ingress not routing to cert-manager solver
- DNS-01: API credentials wrong or missing permissions
Rate limited:
- Too many certificates requested from Let’s Encrypt
- Use staging issuer for testing
- Consolidate into wildcard certificates
Secret not updating:
- Check Certificate resource status
- Check CertificateRequest and Order resources
- Look at cert-manager logs:
kubectl logs -n cert-manager deploy/cert-manager
Best Practices
1. Use ClusterIssuers
Unless you need namespace isolation, ClusterIssuers reduce duplication:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
# Available to all namespaces
2. Separate Staging and Production
Always have both issuers:
# letsencrypt-staging for testing
# letsencrypt-prod for production
3. Monitor Expiration
Even with automation, monitor as a safety net:
certmanager_certificate_expiration_timestamp_seconds - time() < 604800
4. Use DNS-01 for Internal Services
If your services aren’t publicly accessible, HTTP-01 won’t work.
5. Standardize Secret Names
Convention like ${service}-tls makes it predictable:
secretName: api-example-com-tls
My Production Setup
# ClusterIssuer for Let's Encrypt with 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 certificate for all 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"
Key decisions:
- DNS-01 via Cloudflare — Works for everything, including wildcards
- Wildcard certificate — One cert for all subdomains, fewer rate limit concerns
- Central namespace — Certificate in cert-manager namespace, reflected where needed
- Prometheus monitoring — Alert before things break
Why This Matters
TLS isn’t optional anymore. Browsers warn on HTTP. APIs require HTTPS. Internal services need encryption for zero trust.
Manual certificate management doesn’t scale:
- Renewal dates get forgotten
- Different teams use different processes
- New services launch without HTTPS
cert-manager makes TLS the default:
- Define once, renew forever
- Same process for every team
- Integrates with GitOps
This is automation where it matters most — security that works without thinking about it.
The best security is the security you don’t have to remember. cert-manager makes TLS automatic, so you can focus on what actually matters.
