Mutual TLS
Require a client certificate before any request reaches a self-hosted API, either at the Kubernetes ingress or in the API process itself.
What mutual TLS adds
With mutual TLS (mTLS) the client proves its identity with a certificate during the TLS handshake, in addition to the server proving its own. A client without a certificate signed by your CA cannot open a connection at all.
mTLS is off by default and is defence in depth, not a replacement for credentials: every request still needs an API key or OAuth token, exactly as without mTLS. Certificate subjects are never logged.
There are two places to enforce it. Use one, not both:
| Placement | Use when |
|---|---|
At the ingress (Helm ingress.mtls.*) | You run the Helm chart behind ingress-nginx. Recommended |
In the API process (TRANSMUTE_TLS_*) | You run Docker Compose or a plain host install with no ingress in front |
At the ingress (Helm)
Store the CA that signs your client certificates in a Secret under the key ca.crt:
kubectl -n transmute create secret generic transmute-client-ca --from-file=ca.crt=./client-ca.pem
Enable the ingress and its mTLS block:
ingress:
enabled: true
className: nginx
hosts:
- host: transmute.example.internal
paths:
- path: /
pathType: Prefix
tls:
- secretName: transmute-server-tls
hosts: [transmute.example.internal]
mtls:
enabled: true
caSecret: transmute/transmute-client-ca # <namespace>/<secret> holding ca.crt
verifyDepth: 1 # raise if you issue through intermediate CAs
| Value | Default | Meaning |
|---|---|---|
ingress.mtls.enabled | false | Require and verify a client certificate at the ingress |
ingress.mtls.caSecret | (empty) | <namespace>/<secret> holding ca.crt. Required when enabled: helm template and helm install fail without it |
ingress.mtls.verifyDepth | 1 | Maximum certificate chain depth accepted |
The chart renders the ingress-nginx annotations auth-tls-secret, auth-tls-verify-client: "on" and auth-tls-verify-depth. Other ingress controllers need their own equivalent annotations, which you can set through ingress.annotations. The API pods keep serving plain HTTP inside the cluster, so the chart's health probes are unaffected.
Verify from a client:
# without a client certificate: the handshake is refused
curl --cacert server-ca.pem https://transmute.example.internal/v1/health
# with one: 200 {"status":"ok"}
curl --cacert server-ca.pem --cert client.pem --key client.key \
https://transmute.example.internal/v1/health
In the API process
Three variables turn the API's own listener (on PORT) into a TLS or mTLS listener. Each takes a path to a PEM file; mount the files read-only. The private key is a secret: deliver it as a file from your secret store, never inline in a config file.
| Var | Meaning |
|---|---|
TRANSMUTE_TLS_CERT | Server certificate (PEM). With TRANSMUTE_TLS_KEY, the listener serves TLS, minimum TLS 1.2 |
TRANSMUTE_TLS_KEY | Server private key (PEM) |
TRANSMUTE_TLS_CLIENT_CA | CA bundle (PEM). The listener then requires a client certificate signed by one of these CAs and verifies it on every connection |
All three unset (the default) means plain HTTP.
The API refuses to start on any partial or broken configuration, so it never silently falls back to plain HTTP or to TLS without client verification:
- a certificate without a key, or a key without a certificate;
- a client CA without the certificate and key;
- an unreadable or unparsable file, or a CA file containing no certificate.
The startup log line listening (TLS) includes client_cert_required: true when mTLS is active.
Docker Compose example:
services:
api:
image: ghcr.io/403ent/transmute:<release>
ports:
- "8443:8443"
environment:
PORT: "8443"
TRANSMUTE_TLS_CERT: /etc/transmute/tls/server.crt
TRANSMUTE_TLS_KEY: /etc/transmute/tls/server.key
TRANSMUTE_TLS_CLIENT_CA: /etc/transmute/tls/client-ca.pem
volumes:
- /opt/transmute/tls:/etc/transmute/tls:ro
Once mTLS is on, everything that talks to the API needs a client certificate, including your health checks. The requirement applies to every path, /v1/health included, so probe over HTTPS and present a client certificate.
On Kubernetes, prefer the ingress placement: the chart's probes call GET /v1/health over plain HTTP and cannot present a client certificate.