Tutorial: set up mTLS between Traefik and Docker Compose services using smallstep CA and automatic certificates — step by step

Docker & Containers · By · Published on · 11 min read

close-up of a screen showing colorized source code in an editor with selective focus and highlighted syntax lines
Technical visual suitable for a tutorial on mTLS between Traefik and Docker services. Photo: Godfrey_atima / Pixabay

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.

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 Docker

See also