Tutorial: set up mTLS between Traefik and Docker Compose services using smallstep CA and automatic certificates — step by step
Docker & Containers · By Kevin Marcondes · Published on · 11 min read
This is a tutorial to set up mTLS between Traefik and services running in Docker Compose using a smallstep CA and automatic certificates. You will see the architecture, files, commands and end-to-end verification. Traefik will authenticate to the backend with a client certificate, and backends will require mTLS to accept connections, with issuance and renewal handled automatically by step-ca.
Architecture: how mTLS between Traefik and services works
Traefik acts as a reverse proxy and connects to backend services over TLS, presenting a client certificate and validating the server certificate. The backend requires a valid client certificate to accept the connection.
We use a local Certificate Authority (smallstep step-ca) to issue: (1) a client certificate for Traefik and (2) a server certificate for each service. Renewal is automatic using the step CLI.
This approach protects internal connections, reduces man-in-the-middle risk and authenticates both sides of the connection. If you’re evaluating your PKI, compare alternatives in smallstep vs HashiCorp Vault for mTLS in Docker Compose.
- Traefik validates the server certificate (trusted root CA).
- Service validates Traefik’s client certificate (mTLS required).
- Certificates issued and renewed by step-ca via the step CLI.
- External HTTP routing remains simple; mTLS is applied only on the Traefik→service upstream.
| Component | Certificates/Files | Purpose |
|---|---|---|
| step-ca | root_ca.crt, intermediária, ca.json | Emit and renew certificates |
| Traefik | traefik-client.crt, traefik-client.key, root_ca.crt | mTLS as a client; trust the backend |
| Service (e.g. Nginx) | server.crt, server.key, root_ca.crt | Server TLS and require client certificate |
Prerequisites and environment scope
You need Docker and Docker Compose installed, shell access and free ports 80 and 9000. We’ll create a single Docker network with three services: step-ca, Traefik and an example Nginx backend.
Domains used are local (app.localhost) and internal names (app.internal). Adapt to your environment. Do NOT expose the CA on the internet; keep it accessible only on the Compose internal network.
- Updated Docker and Compose.
- Ports 80 (Traefik) and 9000 (step-ca) available.
- Text editor to create files.
- Basic knowledge of YAML and TLS (SAN, CA).
How to start a smallstep CA in Docker and get the fingerprint?
The smallstep CA (step-ca) will be our internal PKI. We’ll initialize the CA repository in a Docker volume, generate root/intermediate certificates and store the password. The root fingerprint is used for secure bootstrap by clients.
The commands below create the volume, initialize the CA and print the root fingerprint.
# 1) Crie o volume de dados da CA
docker volume create step-ca
# 2) Gere uma senha e inicialize a estrutura de diretórios
# (a senha protege as chaves privadas da CA)
docker run --rm -v step-ca:/home/step alpine sh -c '
set -e; mkdir -p /home/step/secrets;
head -c 16 /dev/urandom | base64 > /home/step/secrets/password
'
# 3) Inicialize a CA (nome, DNS interno e provisionador de login)
docker run --rm -it -v step-ca:/home/step smallstep/step-ca \
step ca init \
--deployment-type standalone \
--name "Local Dev CA" \
--dns "step-ca" \
--address ":9000" \
--provisioner "[email protected]" \
--password-file /home/step/secrets/password
# 4) Descubra o fingerprint da raiz (copie para usar no Compose)
docker run --rm -v step-ca:/home/step smallstep/step-ca \
step certificate fingerprint /home/step/certs/root_ca.crt
# Guarde o fingerprint impresso (ex.: 7f:ab:...): vamos usar como STEP_FINGERPRINT.
How to issue and auto-renew certificates on the backend (Nginx)?
The backend needs two things: (a) a server certificate issued by the CA and (b) Nginx configured to require client certificates. We use a custom image with the step CLI to issue and renew the certificate, reloading Nginx when files change.
Create the files below in ./app. Renewal runs in the background and triggers an Nginx reload without interrupting active connections.
# file: app/Dockerfile
FROM nginx:alpine
RUN apk add --no-cache curl tar ca-certificates bash \
&& update-ca-certificates
# Instala o step CLI (verifique a URL mais recente conforme sua política interna)
RUN curl -fsSL https://dl.smallstep.com/cli/releases/latest/step_linux_amd64.tar.gz \
| tar -xz -C /usr/local/bin --strip-components=1 'step_*/bin/step'
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
# file: app/nginx.conf
server {
listen 443 ssl http2;
server_name app.internal;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
# Exigir certificado de cliente (mTLS)
ssl_client_certificate /step/certs/root_ca.crt; # CA raiz confiável
ssl_verify_client on;
# End-point exemplo
location / {
add_header Content-Type text/plain;
return 200 'ok from app (mTLS required)\n';
}
}
# file: app/entrypoint.sh
#!/usr/bin/env bash
set -euo pipefail
: "${STEP_FINGERPRINT:?Defina STEP_FINGERPRINT no compose}"
: "${STEP_PROVISIONER:?Defina STEP_PROVISIONER no compose}"
# Bootstrap confiança na CA interna (via DNS step-ca na rede Docker)
step ca bootstrap --ca-url https://step-ca:9000 --fingerprint "$STEP_FINGERPRINT"
# Emite o certificado de servidor (SAN: app.internal)
mkdir -p /etc/nginx/certs
step ca certificate "app.internal" \
/etc/nginx/certs/server.crt /etc/nginx/certs/server.key \
--provisioner "$STEP_PROVISIONER" --no-password --san "app.internal"
# Renova automaticamente e recarrega o Nginx quando necessário
step ca renew /etc/nginx/certs/server.crt /etc/nginx/certs/server.key \
--daemon --exec "nginx -s reload"
# Inicia o Nginx em primeiro plano
exec nginx -g 'daemon off;'
How to configure Traefik for mTLS with backends?
Traefik needs: (a) a client certificate to present to backends and (b) trust in the root CA to validate the server certificate. We’ll use a sidecar with the step CLI to issue/renew the client certificate and a Traefik dynamic file to define a serversTransport with mTLS.
For gradual routing strategies, see examples of canary deployments in Canary releases with Traefik in Docker Compose.
- Traefik uses serversTransport for upstream mTLS.
- The router exposes app.localhost over HTTP and forwards to the backend via HTTPS+mTLS.
- The sidecar keeps the client certificate renewed; restarting Traefik applies the new cert.
# file: traefik/dynamic.yml
http:
serversTransports:
mtls-to-app:
serverName: app.internal
rootCAs:
- /step/certs/root_ca.crt
certificates:
- certFile: /certs/traefik-client.crt
keyFile: /certs/traefik-client.key
routers:
app:
rule: Host(`app.localhost`)
entryPoints: ["web"]
service: app
services:
app:
loadBalancer:
servers:
- url: https://app:443
serversTransport: mtls-to-app
Bring everything up with Docker Compose (Traefik, step-ca and backend)
The docker-compose.yml integrates the CA, Traefik, the sidecar that manages the client certificate and the Nginx backend. Before bringing everything up, replace STEP_FINGERPRINT with the fingerprint obtained when initializing the CA.
On first run, the sidecar and backend will issue certificates; renewal runs in the background. To apply renewed certificates in Traefik, restart the container when desired.
# file: docker-compose.yml
version: "3.9"
services:
step-ca:
image: smallstep/step-ca:latest
command: ["step-ca", "/home/step/config/ca.json"]
volumes:
- step-ca:/home/step
ports:
- "9000:9000" # somente para laboratório; evite expor em produção
networks: [internal]
traefik-client:
image: smallstep/step-ca:latest # contém o step CLI
depends_on:
- step-ca
environment:
STEP_FINGERPRINT: "COLE_AQUI_O_FINGERPRINT_DA_RAIZ"
STEP_PROVISIONER: "[email protected]"
volumes:
- step-ca:/step:ro
- traefik-certs:/certs
entrypoint: ["/bin/sh","-c"]
command: >-
step ca bootstrap --ca-url https://step-ca:9000 --fingerprint $STEP_FINGERPRINT &&
step ca certificate traefik-client /certs/traefik-client.crt /certs/traefik-client.key \
--provisioner $STEP_PROVISIONER --no-password --kty RSA &&
step ca renew /certs/traefik-client.crt /certs/traefik-client.key --daemon
networks: [internal]
traefik:
image: traefik:v2.10
depends_on:
- traefik-client
command:
- "--providers.docker=true"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/dynamic.yml:/etc/traefik/dynamic/dynamic.yml:ro
- traefik-certs:/certs:ro
- step-ca:/step:ro
networks: [internal]
app:
build: ./app
depends_on:
- step-ca
environment:
STEP_FINGERPRINT: "COLE_AQUI_O_FINGERPRINT_DA_RAIZ"
STEP_PROVISIONER: "[email protected]"
volumes:
- step-ca:/step:ro # para confiar na raiz da CA
networks: [internal]
volumes:
step-ca:
traefik-certs:
networks:
internal:
driver: bridge
How to confirm it worked? Quick verification tests
Verify Traefik responds over HTTP and forwards to the backend via HTTPS+mTLS. Then try to access the backend directly without a mTLS client to see the expected failure.
If something fails, check container logs and validate names (serverName, SAN app.internal) and the fingerprint.
- Request via Traefik should return 200 with the backend message.
- Direct access to the backend without a mTLS client should fail (handshake).
- Nginx logs show ssl client verify successful when requests pass through Traefik.
# 1) Suba o ambiente
docker compose up -d --build
# 2) Acesse via Traefik (host header para roteamento)
curl -sS -H 'Host: app.localhost' http://127.0.0.1/
# Esperado: ok from app (mTLS required)
# 3) Teste o backend sem cliente mTLS (deve falhar)
docker compose run --rm alpine sh -lc "apk add --no-cache curl >/dev/null && curl -ksS https://app:443 || echo 'falha esperada (mTLS)'"
# 4) Opcional: veja logs
docker compose logs -f app traefik
Production tips and certificate maintenance
In production, keep step-ca accessible only on a private network, back up CA keys and plan key rotations. Use short lifetimes and frequent renewals to reduce risk.
To apply renewed certificates in Traefik, schedule a controlled reload or restart. Nginx already reloads automatically in the renewal hook. Test renewals outside critical windows. For container log management, see practices in journald + logrotate + rsyslog.
- Isolate the CA (private network, firewall restrictions).
- SANs consistent with DNS/Service Discovery.
- Clock synced (NTP) to avoid validity issues.
- Automate Traefik reload in a safe maintenance window.
- Monitor certificate expiration (alerts).
Common mTLS errors and how to fix them
Common failures include incorrect SAN, wrong fingerprint in bootstrap, incomplete certificate chain and DNS issues. Use logs and direct tests to isolate the cause.
If the handshake fails, also verify Traefik is using the correct serversTransport and that SNI (serverName) matches the backend certificate SAN.
- SAN/SNI mismatch: adjust serverName and reissue the backend cert.
- Wrong fingerprint: redo bootstrap with the correct fingerprint.
- Incomplete chain: ensure the correct root is used in rootCAs/ssl_client_certificate.
- Wrong DNS/service name: confirm app.internal on the Docker network.
- Time (NTP) drift: sync clocks to avoid NotBefore/NotAfter issues.
Where to go deeper on alternatives and variations?
If you need to compare certificate issuance engines or see a variation with Nginx on the edge, check the related materials. For advanced Traefik routing strategies, see canary and weighted rules.
Container log retention and rotation best practices are also useful for day-to-day operations.
- Read the comparison smallstep vs HashiCorp Vault for mTLS in Docker Compose.
- See the Nginx variation: mTLS with Nginx and smallstep in Docker Compose.
- Deepen Traefik routing with Canary releases with Traefik in Docker Compose.
- Optimize container log retention/rotation with journald + logrotate + rsyslog.
Frequently asked questions
Do I need to expose the CA port (9000) to the internet?
No — keep step-ca accessible only on the Docker internal network or a VPN. Exposing the CA increases the attack surface. Services issue and renew certificates via internal DNS (step-ca) without external access.
How does Traefik present the client certificate to the backend?
Traefik uses a serversTransport with client certificates (certFile/keyFile) and the trusted root (rootCAs). It connects to the backend over HTTPS, presents the client certificate and validates the server certificate before forwarding traffic.
Can I use mTLS with multiple backend services?
Yes. Issue a server certificate for each service (e.g. api.internal, auth.internal) and create specific serversTransports when you need different SNI. You can reuse the same Traefik client certificate or issue distinct client certificates per service.
How do I renew certificates without downtime?
The step CLI renews in the background and can trigger a graceful reload (e.g. Nginx). For Traefik, schedule a controlled reload/restart of the container when certificates are updated, avoid critical windows and keep redundant instances where possible.
Can I use real public domains with this CA?
You can, provided DNS points correctly and clients trust your CA. Generally use an internal CA for internal traffic (Traefik→services) and a separate solution for public TLS, keeping scopes separated.
How to diagnose TLS handshake failures between containers?
Check Traefik and backend logs, confirm SAN/SNI and CA trust. Run test connections from a temporary container and validate certificate validity dates. Common errors are SAN mismatches and incorrect bootstrap fingerprint.
Want to bring mTLS to your Docker environment securely?
If this guide helped, you have the foundation to run mTLS between Traefik and services with automatic certificates. Need help adapting to multiple clusters, rotation and observability? Let’s talk.
Talk about mTLS in DockerSee also
- Tutorial: integrate Sentry into your GitHub Actions deploy pipeline — automatic source map upload & issue creation (PHP & JavaScript)
- Configure retention and compression for Docker container logs with journald + logrotate + rsyslog on a low-disk VPS
- Tutorial: Configure GitHub Actions for integration tests with PostgreSQL in ephemeral containers and automatic migration rollback — a practical guide
- Configure BuildKit garbage collection and systemd timers to auto‑clean Docker images and volumes: a practical tutorial