Skip to content
Security Advanced Tutorial

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.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Sep 3, 2026 · 6 min read
Set Up Mutual TLS Between Microservices with cert-manager

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

  1. Helm - cert-manager Documentation — cert-manager.io
  2. SelfSigned Issuer - cert-manager Documentation — cert-manager.io
  3. Certificate Resource - cert-manager Documentation — cert-manager.io
  4. Supported Releases - cert-manager Documentation — cert-manager.io
  5. kind v0.30.0 Release Notes — github.com
  6. Module ngx_http_ssl_module — nginx.org
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading