Configuring mutual TLS with Traefik as an ingress controller
Let’s assume the following context: there is a client application that connects to a server application deployed inside a Kubernetes cluster. The client and the server have special requirements that necessitate configuring mutual TLS. The cluster uses Traefik as an ingress controller to manage incoming traffic. How can mTLS be configured? I found myself in this situation a while ago. There are a few tricky parts about this setup, so I wanted to write a post about it.
I’ll start by launching a local Kubernetes cluster using Minikube. I’m using a profile called mtls-demo since I manage multiple local clusters with Minikube:
# Launch a local Kubernetes cluster using the mtls-demo profile.
-> % minikube start -p mtls-demo
...
🏄 Done! kubectl is now configured to use "mtls-demo" cluster and "default" namespace by default
# Switch to the mtls-demo profile. Useful when opening a new terminal.
-> % minikube profile mtls-demo
✅ minikube profile was successfully set to mtls-demo
# Check the status of the cluster.
-> % minikube status For the target service, I’ll use whoami. It’s a small Go server that prints HTTP requests to the output. This will help in inspecting the request body and headers.
Let’s create a namespace for whoami:
-> % cat whoami-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: whoami
-> % k apply -f whoami-namespace.yaml Then a pod in the whoami namespace running the whoami container that exposes port 80 for incoming requests:
-> % cat whoami-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: whoami
namespace: whoami
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami
command:
- "/whoami"
- "--verbose"
ports:
- name: web
containerPort: 80
protocol: TCP
-> % k apply -f whoami-pod.yaml
pod/whoami created
-> % k -n whoami get po
NAME READY STATUS RESTARTS AGE
whoami 1/1 Running 0 76s And finally a service:
-> % cat whoami-service.yaml
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: whoami
spec:
selector:
app: whoami
type: ClusterIP
ports:
- name: whoamiweb
protocol: TCP
port: 8080
targetPort: web
-> % k apply -f whoami-service.yaml
service/whoami created
-> % k -n whoami get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
whoami ClusterIP 10.108.194.137 <none> 8080/TCP 48s Let’s install the Traefik ingress controller using Helm. I’ll set some options through the values.yaml file:
-> % cat traefik-values.yaml
logs:
general:
level: "TRACE"
access:
enabled: true
fields:
headers:
defaultMode: "keep"
ingressRoute:
dashboard:
enabled: true
entryPoints: [web, websecure]
-> % helm install traefik oci://ghcr.io/traefik/helm/traefik --namespace traefik --create-namespace -f traefik-values.yaml
Pulled: ghcr.io/traefik/helm/traefik:36.3.0
Digest: sha256:4c9930fb5db2eb0c31d445933e2a9c86796073a2c35e72c2588cecb217a085d2
NAME: traefik
LAST DEPLOYED: Wed Jul 2 12:07:11 2025
NAMESPACE: traefik
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
traefik with docker.io/traefik:v3.4.3 has been deployed successfully on traefik namespace ! I’ve enabled logging of requests and headers to gain more insight into what the ingress controller is processing. I’ve also exposed the Traefik dashboard to verify that it’s working. To access it:
-> % minikube tunnel The minikube tunnel command requires sudo permission because privileged ports are exposed. After opening the tunnel, access the dashboard at localhost/dashboard/ (note the required trailing slash).
Next, I’ll set up an ingress rule so Traefik knows how to forward traffic to the whoami service:
-> % cat whoami-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: whoami
spec:
ingressClassName: traefik
rules:
- host: localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
name: whoamiweb
-> % k apply -f whoami-ingress.yaml
ingress.networking.k8s.io/whoami created
-> % k -n whoami get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
whoami traefik localhost 127.0.0.1 80 14s Traefik’s default behavior is to look in all namespaces and process all ingress resources with the traefik ingress class. This rule forwards requests having localhost as a host and ’/’ as a path prefix to the whoami service on port 8080.
Let’s test it:
-> % curl http://localhost
Hostname: whoami
IP: 127.0.0.1
IP: ::1
IP: 10.244.0.3
IP: fe80::dc26:15ff:fee1:cbe7
RemoteAddr: 10.244.0.4:47296
GET / HTTP/1.1
Host: localhost
User-Agent: curl/8.7.1
Accept: */*
Accept-Encoding: gzip
X-Forwarded-For: 10.244.0.1
X-Forwarded-Host: localhost
X-Forwarded-Port: 80
X-Forwarded-Proto: http
X-Forwarded-Server: traefik-6489fdb447-jgqgx
X-Real-Ip: 10.244.0.1 So far so good. Let’s move on to setting up TLS. For a full mutual TLS setup, I’ll need a CA certificate, a server certificate, and a client certificate. mTLS works as follows (with some details omitted):
- The client sends a request to the server to ask for its certificate.
- The server sends the client back its certificate.
- The client verifies the server’s certificate against its trust store, i.e. it goes through all the certificate signers it trusts and if the certificate of the server was signed by one of them then the client can trust the server.
Usually this is where TLS usually ends, but in mutual TLS, the server also verifies the client:
- The client sends its certificate.
- The server verifies it against its own trust store.
If the certificate is signed by a trusted CA, the TLS handshake is successful. The CA (Certificate Authority) certificate is the certificate that belongs to a trusted well-known entity (e.g. Google or Amazon) and it’s used to sign the other certificates. A collection of CA certificates forms a trust store.
Let’s generate a CA certificate, a server certificate and a client certificate. I’ll use the localhost domain for all of them and for the Organisation Unit I’ll set the identifier of the certificate, i.e. ca, server, client to be able to distinguish between them.
For this, the openssl tool can be used. I have a few utility scripts for generating the certificates which are pretty intuitive to use:
## Generate the CA certificate.
# Fetch the script for generating self-signed certificates:
wget https://gist.githubusercontent.com/vlasebian/1a5ff3925fe5e21c18484d5929e753ff/raw/5588e2b99151f26d2edb0b47555daaf51098cf9a/gen_self_signed_cert.sh
# Check the script before, don't run random stuff from the internet!
chmod +x gen_self_signed_cert.sh
./gen_self_signed_cert.sh -o ca
## Generate the server and client certificates.
# Fetch the script for generating certificates:
wget https://gist.githubusercontent.com/vlasebian/274c7ca97ee392117e780352bea0daa6/raw/56d4a23fd96b77a19ac9fb1e57c798b350b46930/gen_cert.sh
# Check the script before, don't run random stuff from the internet!
chmod +x gen_cert.sh
./gen_cert.sh --cacrt root.crt --cakey root.key -o server
./gen_cert.sh --cacrt root.crt --cakey root.key -o client
## Inspect certificates:
# Fetch the script for inspecting certificates:
wget https://gist.githubusercontent.com/vlasebian/918023f03b3866b4b0c691f8622d8e0b/raw/dd0bdb44935f8156dcaab50f65c3f7bca8f6dedb/inspect_cert.sh
# Check the script before, don't run random stuff from the internet!
chmod +x inspect_cert.sh
./inspect_cert.sh server.crt The set of certificates looks like this:
-> % ls *.key *.crt
server.crt server.key client.crt client.key ca.crt ca.key Now that we have the certificates, let’s add the tls certificate to the server. I’ll configure tls on the whoami ingress resource so only traffic that matches the ingress rule will be decrypted by the provided certificate. To do that, I have to make the server certificate available in the cluster. This can be done by using a secret. The secret will be used by the whoami ingress resource so it must be created in the whoami namespace:
-> % kubectl create secret tls server-tls-certificate --cert=server.crt --key=server.key -n whoami
secret/server-tls-certificate created
-> % k -n whoami get secret
NAME TYPE DATA AGE
server-tls-certificate kubernetes.io/tls 2 9s I’ll update the whoami ingress resource that we applied previously to pick up the TLS secret and reapply it:
-> % cat whoami-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: whoami
spec:
ingressClassName: traefik
rules:
- host: localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
name: whoamiweb
tls:
- hosts:
- localhost
secretName: server-tls-certificate
-> % k apply -f whoami-ingress.yaml
ingress.networking.k8s.io/whoami configured
-> % k -n whoami describe ingress/whoami
Name: whoami
Labels: <none>
Namespace: whoami
Address: 127.0.0.1
Ingress Class: traefik
Default backend: <default>
TLS:
server-tls-certificate terminates localhost
Rules:
Host Path Backends
---- ---- --------
localhost
/ whoami:whoamiweb (10.244.0.3:80)
Annotations: <none>
Events: <none> Let’s send a request to the endpoint using curl:
-> % curl https://localhost
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above. We have an error because we didn’t provide any CA certificate (or CA path to a directory of certificates) to verify the server certificate against. Let’s pass the ca.crt that we used to sign the server certificate with:
-> % curl --cacert ca.crt https://localhost
Hostname: whoami
IP: 127.0.0.1
IP: ::1
IP: 10.244.0.3
IP: fe80::dc26:15ff:fee1:cbe7
RemoteAddr: 10.244.0.4:35884
GET / HTTP/1.1
Host: localhost
User-Agent: curl/8.7.1
Accept: */*
Accept-Encoding: gzip
X-Forwarded-For: 10.244.0.1
X-Forwarded-Host: localhost
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: traefik-6489fdb447-jgqgx
X-Real-Ip: 10.244.0.1 The request was successful now. You can also bypass the server certificate check by passing the -k option to curl.
Let’s check the actual TLS handshake by providing -v (verbose) flag to curl:
-> % curl -v --cacert ca.crt https://localhost
* Host localhost:443 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:443...
* Connected to localhost (::1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: ca.crt
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; L=San Francisco; O=My Company; OU=server; CN=localhost; emailAddress=admin@example.com
* start date: Jul 1 22:33:24 2025 GMT
* expire date: Jun 29 22:33:24 2035 GMT
* common name: localhost (matched)
* issuer: C=US; ST=California; L=San Francisco; O=My Company; OU=CA; CN=localhost; emailAddress=admin@example.com
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://localhost/
... The server is now configured. But how can we ensure it also authenticates the client? Since Kubernetes Ingress doesn’t support full mTLS natively, we’ll use Traefik features.
I will create another secret to store the CA certificate. The certificate provided by the client will be verified against this certificate.
# Traefik requires that the key of the CA certificate is either tls.ca or ca.crt.
kubectl create secret generic ca-tls-certificate --from-file=ca.crt -n traefik To set the tls options for client authentication, I’ll use the TLSOption Custom Resource Definition (CRD). I’ll create this in the traefik namespace:
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: request-client-cert
namespace: traefik
spec:
clientAuth:
secretNames:
- ca-tls-certificate # CA certificate used by the server to verify the client certificates against
clientAuthType: RequireAndVerifyClientCert
-> % k apply -f traefik-custom-tlsoption.yaml
tlsoption.traefik.io/request-client-cert created Then I’ll update the whoami ingress resource to pick up the tls option by using annotations:
-> % cat whoami-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: whoami
annotations:
# Annotation value format is: <namespace>-<tls-option-name>@kubernetescrd
traefik.ingress.kubernetes.io/router.tls.options: traefik-request-client-cert@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
name: whoamiweb
tls:
- hosts:
- localhost
secretName: server-tls-certificate
-> % k apply -f whoami-ingress.yaml
ingress.networking.k8s.io/whoami configured Let’s send the previous curl request:
-> % curl --cacert ca.crt https://localhost
curl: (56) LibreSSL SSL_read: LibreSSL/3.3.6: error:1404C45C:SSL routines:ST_OK:reason(1116), errno 0 I have an error caused by not providing the client certificate requested by the server. Let’s fix that:
-> % curl --cacert ca.crt --cert client.crt --key client.key https://localhost
Hostname: whoami
IP: 127.0.0.1
IP: ::1
IP: 10.244.0.3
IP: fe80::dc26:15ff:fee1:cbe7
RemoteAddr: 10.244.0.4:41722
GET / HTTP/1.1
Host: localhost
User-Agent: curl/8.7.1
Accept: */*
Accept-Encoding: gzip
X-Forwarded-For: 10.244.0.1
X-Forwarded-Host: localhost
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: traefik-6489fdb447-jgqgx
X-Real-Ip: 10.244.0.1 Now the request is processed. Let’s send it again with a -v flag to check the TLS handshake process:
-> % curl -v --cacert ca.crt --cert client.crt --key client.key https://localhost
* Host localhost:443 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:443...
* Connected to localhost (::1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: ca.crt
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Request CERT (13):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Certificate (11):
* (304) (OUT), TLS handshake, CERT verify (15):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; L=San Francisco; O=My Company; OU=server; CN=localhost; emailAddress=admin@example.com
* start date: Jul 1 22:33:24 2025 GMT
* expire date: Jun 29 22:33:24 2035 GMT
* common name: localhost (matched)
* issuer: C=US; ST=California; L=San Francisco; O=My Company; OU=CA; CN=localhost; emailAddress=admin@example.com
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://localhost/
... If I analyze the output of curl, there are two CERT verify operations, one IN and one OUT. If I compare it with the previous curl request where we passed the -v option, before configuring the TLSOptions in Traefik, there was only one CERT verify operation, for IN. This time the client certificate was also verified.
There are multiple behaviours that can be configured through the clientAuthType field. All the options for the clientAuthType are described here.
One more interesting behaviour would be to make the client certificate required, but let the application process it instead of Traefik. That means we have to forward the headers containing the client certificate to the application. By setting clientAuthType to RequireAnyClientCert Traefik will request the certificate but will not verify it:
-> % cat traefik-custom-tlsoption.yaml
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: request-client-cert
namespace: traefik
spec:
clientAuth:
clientAuthType: RequireAnyClientCert
-> % k apply -f traefik-custom-tlsoption.yaml
tlsoption.traefik.io/request-client-cert configured Notice that I dropped the secret containing the CA. That won’t be needed anymore as Traefik won’t verify the client certificate anymore, this responsability will be passed to the application.
To forward the TLS certificate in the application, I have to set a middleware that tells Traefik to pass the client certificate to the application:
-> % cat traefik-pass-tls-middleware.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: pass-client-cert
namespace: traefik
spec:
passTLSClientCert:
pem: true
-> % k apply -f traefik-pass-tls-middleware.yaml
middleware.traefik.io/pass-client-cert created And I have to provide the middleware on the specific ingress through an ingress annotation:
-> % cat whoami-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: whoami
annotations:
traefik.ingress.kubernetes.io/router.tls.options: traefik-request-client-cert@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: traefik-pass-client-cert@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
name: whoamiweb
tls:
- hosts:
- localhost
secretName: server-tls-certificate
-> % k apply -f whoami-ingress.yaml
ingress.networking.k8s.io/whoami configured Now the client certificate is passed to the application through the request headers:
-> % curl --cacert ca.crt --cert client.crt --key client.key https://localhost
Hostname: whoami
IP: 127.0.0.1
IP: ::1
IP: 10.244.0.3
IP: fe80::dc26:15ff:fee1:cbe7
RemoteAddr: 10.244.0.4:37888
GET / HTTP/1.1
Host: localhost
User-Agent: curl/8.7.1
Accept: */*
Accept-Encoding: gzip
X-Forwarded-For: 10.244.0.1
X-Forwarded-Host: localhost
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: traefik-6489fdb447-jgqgx
X-Forwarded-Tls-Client-Cert: MIICbjCCAhSgAwIBAgIURmC/W6MegyfmdP/9P2VGeP/T1qQwCgYIKoZIzj0EAwIwgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNeSBDb21wYW55MQswCQYDVQQLDAJDQTESMBAGA1UEAwwJbG9jYWxob3N0MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBleGFtcGxlLmNvbTAeFw0yNTA3MDEyMjI2NDBaFw0zNTA2MjkyMjI2NDBaMIGWMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTXkgQ29tcGFueTEPMA0GA1UECwwGY2xpZW50MRIwEAYDVQQDDAlsb2NhbGhvc3QxIDAeBgkqhkiG9w0BCQEWEWFkbWluQGV4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4e9Ee+hCUmQcy/F/+6rfnBcPkdFh6ilSAluB6LLpgpu/VO4bxU+iVsVYN7G8VKjiFfeqZGQzkqBycYvpwz8dqKNCMEAwHQYDVR0OBBYEFLoXGaucJeOAUR8TlQw3zdOzrBiSMB8GA1UdIwQYMBaAFHzXpd9kLcMiT05oLSfYcxmFyz8vMAoGCCqGSM49BAMCA0gAMEUCIBVC7lBi2+VaDhHi/moq23NQQm0Fj9PpewKlPi2VteEMAiEAjaO1qhzdkQ2O4ECxM+9zqywhiagWKOKuIzG1d/jnIBM=
X-Real-Ip: 10.244.0.1