Managing TLS in Kubernetes is different enough from regular server TLS that it’s worth covering separately. The concepts are the same — certificates, private keys, certificate authorities — but the mechanics of how you provision, store, and rotate certificates involve Kubernetes-specific objects.
I’ve worked through this on a few clusters. Here’s the practical picture.
The Basics: TLS Secrets
Kubernetes stores TLS certificates in Secrets of type kubernetes.io/tls. These contain two pieces:
tls.crt— the certificate (PEM format)tls.key— the private key (PEM format)
Create one manually:
kubectl create secret tls my-tls-secret \
--cert=path/to/certificate.pem \
--key=path/to/private.key \
--namespace=production
Or as YAML:
apiVersion: v1
kind: Secret
metadata:
name: my-tls-secret
namespace: production
type: kubernetes.io/tls
data:
tls.crt: <base64-encoded-certificate>
tls.key: <base64-encoded-key>
The certificate file should be the full chain (cert + intermediates), not just the leaf certificate. Nginx and most other ingress controllers expect the full chain.
Ingress with TLS
A basic Ingress resource with TLS:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: production
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- example.com
- www.example.com
secretName: my-tls-secret
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-service
port:
number: 80
The tls section references the Secret. The Ingress controller reads the certificate from the Secret and configures TLS accordingly.
Multiple hosts with different certificates:
spec:
tls:
- hosts:
- api.example.com
secretName: api-tls-secret
- hosts:
- app.example.com
secretName: app-tls-secret
cert-manager: Automating Certificate Issuance
Manual certificate management doesn’t scale. cert-manager is the standard solution for automating certificate issuance and renewal in Kubernetes. It supports Let’s Encrypt, ZeroSSL, Vault, and custom CAs.
Install with Helm:
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
ClusterIssuer for Let’s Encrypt
Create a ClusterIssuer (cluster-wide, not namespace-scoped):
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: nginx
This uses HTTP-01 challenge. For wildcard certificates, use DNS-01:
solvers:
- dns01:
cloudflare:
email: you@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
Create the Cloudflare API token secret:
kubectl create secret generic cloudflare-api-token \
--from-literal=api-token=your-token \
--namespace=cert-manager
Certificate Resource
Request a certificate explicitly:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: production
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
commonName: example.com
dnsNames:
- example.com
- www.example.com
cert-manager creates a Secret with the certificate and manages renewal automatically.
Ingress with cert-manager Annotation
The simpler approach — annotate the Ingress and cert-manager handles everything:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: production
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- example.com
secretName: example-com-tls # cert-manager creates and manages this
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80
cert-manager detects this Ingress, sees the annotation, and automatically creates a Certificate resource and manages the TLS Secret. When the cert nears expiry, cert-manager renews it automatically. No manual intervention.
Checking Certificate Status
# List all Certificate objects
kubectl get certificates --all-namespaces
# Detailed status of a certificate
kubectl describe certificate example-com-tls -n production
# Check cert-manager logs for issues
kubectl logs -n cert-manager deployment/cert-manager
Certificate conditions show the current state:
kubectl get certificate example-com-tls -n production -o jsonpath='{.status.conditions[*]}'
A healthy certificate shows Ready=True. If it’s stuck, the events section of kubectl describe usually explains why — typically DNS challenge failures, rate limits, or networking issues.
Internal Services: Private CA with cert-manager
For services that don’t need public certificates:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
ca:
secretName: internal-ca-keypair
First, create the CA secret:
# Create CA key and certificate (or use your existing CA)
openssl genrsa -out ca.key 4096
openssl req -new -x509 -key ca.key -out ca.crt -days 3650 \
-subj "/CN=Internal CA/O=MyOrg"
kubectl create secret tls internal-ca-keypair \
--cert=ca.crt --key=ca.key \
--namespace=cert-manager
Then cert-manager can issue certificates signed by your internal CA. Install the CA certificate in your service trust stores for them to trust these certificates.
mTLS Between Services
For mutual TLS between services (both sides authenticate with certificates):
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: service-client-cert
namespace: production
spec:
secretName: service-client-tls
issuerRef:
name: internal-ca
kind: ClusterIssuer
commonName: service-a
usages:
- client auth
- digital signature
- key encipherment
Services mount this Secret and present the client certificate when connecting to other services. Service meshes like Istio automate this even further by transparently injecting mTLS without application changes.
Common Issues
Certificate stuck in Pending — check if the HTTP-01 challenge endpoint is accessible from Let’s Encrypt’s servers. Also check if there are existing Ingress rules blocking /.well-known/acme-challenge/.
Rate limit errors — Let’s Encrypt rate limits are per domain. Switch to the staging server for testing: https://acme-staging-v02.api.letsencrypt.org/directory.
Wrong certificate being served — if your Ingress controller serves the wrong cert, check for conflicts in TLS section across Ingress resources for the same hostname.
Certificate expiry not caught — cert-manager renews at 2/3 of the validity period by default. But if renewals are failing silently, you won’t know until it’s almost expired. Set up monitoring for Certificate resources — a Ready=False condition means something needs attention.
For the concepts behind certificate chains and how trust works in these setups, the SSL certificate chain guide explains how root, intermediate, and end-entity certificates relate to each other.