Set Up Mutual TLS Between Microservices with cert-manager
Issue and auto-rotate short-lived mTLS certificates from a private CA so services authenticate each other with zero shared secrets.
What you'll build
Two services on Kubernetes that authenticate each other with mutual TLS: an nginx backend that rejects any caller without a valid client certificate, and a client that presents one. cert-manager issues both certificates from a private CA you control, with 24-hour lifetimes and automatic rotation. No shared secrets, no manual openssl ceremony.
Prerequisites
Verified September 2026 against:
- cert-manager v1.21.1, which supports Kubernetes 1.33–1.36
- A cluster in that range. kind v0.30.0 works out of the box (its default node image is Kubernetes v1.34.0):
kind create cluster - kubectl and Helm 3.x on your PATH, with cluster-admin on the target cluster
Everything below runs on macOS or Linux as-is.
1. Install cert-manager
Install from the OCI Helm chart. The crds.enabled=true flag matters: without it the Certificate and ClusterIssuer resource types don't exist and every later step fails.
helm install \
cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.1 \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
Wait for all three deployments:
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook deploy/cert-manager-cainjector
2. Bootstrap a private CA
mTLS needs a CA that both sides trust. The standard cert-manager pattern is three resources: a self-signed issuer, a root CA certificate signed by it, and a CA issuer that signs everything else with that root. The root CA Certificate must live in the cert-manager namespace, because a ClusterIssuer looks for its secret there.
# ca.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-bootstrap
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: demo-root-ca
namespace: cert-manager
spec:
isCA: true
commonName: demo-root-ca
secretName: demo-root-ca
duration: 87600h # 10 years; the root is long-lived, leaves are not
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-bootstrap
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: demo-ca
spec:
ca:
secretName: demo-root-ca
kubectl apply -f ca.yaml
kubectl wait clusterissuer/demo-ca --for=condition=Ready --timeout=60s
3. Issue short-lived certificates for both services
Each service gets its own Certificate. The key detail is usages: the backend cert needs server auth, the client cert needs client auth. Lifetimes are 24 hours, and cert-manager renews at 16 hours (renewBefore: 8h), so a stolen key is only useful for a day at most. Since v1.18, rotationPolicy: Always is the default, so the private key is regenerated on every renewal too.
# certs.yaml
apiVersion: v1
kind: Namespace
metadata:
name: demo
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: backend-tls
namespace: demo
spec:
secretName: backend-tls
duration: 24h
renewBefore: 8h
dnsNames:
- backend
- backend.demo.svc
- backend.demo.svc.cluster.local
usages:
- server auth
issuerRef:
name: demo-ca
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: client-tls
namespace: demo
spec:
secretName: client-tls
duration: 24h
renewBefore: 8h
commonName: client.demo
usages:
- client auth
issuerRef:
name: demo-ca
kind: ClusterIssuer
kubectl apply -f certs.yaml
kubectl -n demo get certificate
Both should show READY: True within a few seconds. Each secret contains tls.crt, tls.key, and ca.crt (the CA issuer includes its own cert), which is exactly what both ends of an mTLS handshake need.
4. Deploy the backend with client-certificate verification
nginx does the enforcement: ssl_verify_client on rejects any connection that doesn't present a certificate signed by our CA.
# backend.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: backend-nginx
namespace: demo
data:
default.conf: |
server {
listen 8443 ssl;
ssl_certificate /etc/tls/tls.crt;
ssl_certificate_key /etc/tls/tls.key;
ssl_client_certificate /etc/tls/ca.crt;
ssl_verify_client on;
location / {
return 200 "hello from backend, verified client: $ssl_client_s_dn\n";
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: demo
spec:
replicas: 1
selector:
matchLabels: { app: backend }
template:
metadata:
labels: { app: backend }
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
ports:
- containerPort: 8443
volumeMounts:
- { name: tls, mountPath: /etc/tls, readOnly: true }
- { name: conf, mountPath: /etc/nginx/conf.d }
volumes:
- name: tls
secret: { secretName: backend-tls }
- name: conf
configMap: { name: backend-nginx }
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: demo
spec:
selector: { app: backend }
ports:
- port: 8443
targetPort: 8443
kubectl apply -f backend.yaml
5. Deploy the client and make an mTLS call
The client is a curl container with its own certificate mounted.
# client.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: client
namespace: demo
spec:
replicas: 1
selector:
matchLabels: { app: client }
template:
metadata:
labels: { app: client }
spec:
containers:
- name: curl
image: curlimages/curl:8.16.0
command: ["sleep", "infinity"]
volumeMounts:
- { name: tls, mountPath: /etc/tls, readOnly: true }
volumes:
- name: tls
secret: { secretName: client-tls }
kubectl apply -f client.yaml
kubectl -n demo rollout status deploy/backend deploy/client
Verify it works
Make the call with both sides authenticating:
kubectl -n demo exec deploy/client -- curl -s \
--cacert /etc/tls/ca.crt \
--cert /etc/tls/tls.crt --key /etc/tls/tls.key \
https://backend.demo.svc.cluster.local:8443/
Expected output:
hello from backend, verified client: CN=client.demo
Now prove the backend actually enforces it. Drop the client certificate:
kubectl -n demo exec deploy/client -- curl -s \
--cacert /etc/tls/ca.crt \
https://backend.demo.svc.cluster.local:8443/
<html>
<head><title>400 No required SSL certificate was sent</title></head>
...
Finally, confirm rotation is armed. The renewal time should be roughly 16 hours after issuance; cert-manager will re-issue and update the secrets on its own from here.
kubectl -n demo get certificate backend-tls \
-o jsonpath='{.status.renewalTime}{"\n"}'
Troubleshooting
no matches for kind "Certificate" in version "cert-manager.io/v1" when applying manifests. The CRDs aren't installed. Re-run the Helm install with --set crds.enabled=true, or if cert-manager is already installed without them, upgrade in place: helm upgrade cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.21.1 -n cert-manager --set crds.enabled=true.
Certificate stuck at READY: False with secrets "demo-root-ca" not found (visible in kubectl describe clusterissuer demo-ca). The root CA Certificate was created in the wrong namespace. A ClusterIssuer only reads secrets from the cert-manager namespace, so the demo-root-ca Certificate must have namespace: cert-manager.
curl: (60) SSL certificate problem: unable to get local issuer certificate. The client isn't trusting the private CA. Pass --cacert /etc/tls/ca.crt; the system trust store on the curl image knows nothing about your root.
Backend still serves an old certificate after renewal. The kubelet updates mounted secret files within a minute or two of rotation, but nginx only reads certificates at startup. Send it a reload (kubectl -n demo exec deploy/backend -- nginx -s reload), run a reloader such as Reloader, or restart the deployment. Short-lived certs make this failure loud within a day, which is a feature: you find out in staging, not during an incident.
Next steps
The trust bundle is the weak point of this setup: every workload trusts ca.crt from its own secret, which gets awkward when you rotate the root. trust-manager distributes a CA bundle to every namespace and lets you roll the root without touching workloads. For per-pod certificates that never live in a Secret at all, look at csi-driver, which requests an ephemeral cert on pod start. And if you want mTLS on every connection without configuring each service, a mesh like Linkerd or Istio does this transparently; cert-manager's istio-csr plugs your CA into Istio's identity system.
Sources & further reading
- Helm - cert-manager Documentation — cert-manager.io
- SelfSigned Issuer - cert-manager Documentation — cert-manager.io
- Certificate Resource - cert-manager Documentation — cert-manager.io
- Supported Releases - cert-manager Documentation — cert-manager.io
- kind v0.30.0 Release Notes — github.com
- Module ngx_http_ssl_module — nginx.org
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.