Step-by-step: configure mTLS between microservices in Docker Compose using an internal PKI
Redes & Segurança · By Kevin Marcondes · Published on · Updated on · 9 min read
This guide shows how to configure mTLS between microservices running in Docker Compose using an internal PKI. You will create a local CA and certificates with OpenSSL, configure an Nginx service to require client certificates, and validate secure container-to-container communication without exposing public ports.
Prerequisites and lab goals
You need Docker and Docker Compose installed and OpenSSL available on the host. We’ll create a local CA, issue certificates for two services, and require mutual authentication between them.
If you don't have Docker and Compose on your server yet, see the installation guide before continuing: Install Docker and Docker Compose on Linux.
The objective is to have service-a call service-b over internal HTTPS, with mandatory client-certificate verification.
- Docker and Docker Compose running on the host
- OpenSSL installed to generate keys and certificates
- Text editor to create configuration files
- Basic knowledge of Docker networking and volumes
| Resource | Purpose in this tutorial |
|---|---|
| Internal CA | Sign server and client certificates |
| Nginx | Terminate TLS and require client certificate |
| curl | Test requests with mTLS |
| Docker Compose | Orchestrate services and certificate volumes |
#!/usr/bin/env bash
set -euo pipefail
mkdir -p pki/{ca,csr,certs,private,ext}
# 1) CA raiz local
openssl genrsa -out pki/ca/ca.key.pem 4096
openssl req -x509 -new -key pki/ca/ca.key.pem -days 3650 -sha256 \
-subj "/CN=Dev Local Root CA" -out pki/ca/ca.cert.pem
# 2) Arquivos de extensão (SAN + usos)
cat > pki/ext/service-b.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical, digitalSignature, keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=service-b
EOF
cat > pki/ext/service-a.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical, digitalSignature, keyEncipherment
extendedKeyUsage=clientAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=service-a
EOF
# 3) Certificado de servidor (service-b)
openssl req -new -newkey rsa:2048 -nodes \
-keyout pki/private/service-b.key.pem \
-subj "/CN=service-b" -out pki/csr/service-b.csr.pem
openssl x509 -req -in pki/csr/service-b.csr.pem \
-CA pki/ca/ca.cert.pem -CAkey pki/ca/ca.key.pem -CAcreateserial \
-out pki/certs/service-b.cert.pem -days 825 -sha256 \
-extfile pki/ext/service-b.ext
# 4) Certificado de cliente (service-a)
openssl req -new -newkey rsa:2048 -nodes \
-keyout pki/private/service-a.key.pem \
-subj "/CN=service-a" -out pki/csr/service-a.csr.pem
openssl x509 -req -in pki/csr/service-a.csr.pem \
-CA pki/ca/ca.cert.pem -CAkey pki/ca/ca.key.pem -CAcreateserial \
-out pki/certs/service-a.cert.pem -days 825 -sha256 \
-extfile pki/ext/service-a.ext
chmod 600 pki/private/*.key.pem
echo "PKI pronta em ./pki"
Architecture: mTLS between containers on the same network
In Docker Compose, each service is resolved by its service name within the default network. We'll use that by issuing certificates with the DNS SAN matching the service name.
service-b will run Nginx on port 8443 with a server certificate signed by our CA. It will require a trusted client certificate to accept connections.
service-a will hold a client certificate signed by the same CA and use curl to authenticate to service-b.
If you plan to use a reverse proxy in production, see a practical comparison of reverse-proxy options: Traefik vs Nginx: Reverse Proxy for Docker.
- Server SAN: DNS:service-b
- Client SAN: DNS:service-a
- Network: default Compose network, no public ports
- Mutual authentication: both sides present trusted certificates
1) Create the internal PKI (CA, extensions and keys)
We'll build the directory layout, a local root CA, and extension files to issue server and client certificates with the appropriate SANs. Do NOT commit private keys to Git; keep them out of version control and restrict filesystem permissions.
| File/dir | Purpose |
|---|---|
| pki/ca/ca.key.pem | CA private key (secret) |
| pki/ca/ca.cert.pem | CA public certificate (shareable) |
| pki/private/*.key.pem | Services' private keys |
| pki/certs/*.cert.pem | Signed certificates for services |
| pki/ext/*.ext | Extensions: SAN and key usage |
#!/usr/bin/env bash
set -euo pipefail
mkdir -p pki/{ca,csr,certs,private,ext}
# 1) CA raiz local
openssl genrsa -out pki/ca/ca.key.pem 4096
openssl req -x509 -new -key pki/ca/ca.key.pem -days 3650 -sha256 \
-subj "/CN=Dev Local Root CA" -out pki/ca/ca.cert.pem
# 2) Arquivos de extensão (SAN + usos)
cat > pki/ext/service-b.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical, digitalSignature, keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=service-b
EOF
cat > pki/ext/service-a.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical, digitalSignature, keyEncipherment
extendedKeyUsage=clientAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=service-a
EOF
# 3) Certificado de servidor (service-b)
openssl req -new -newkey rsa:2048 -nodes \
-keyout pki/private/service-b.key.pem \
-subj "/CN=service-b" -out pki/csr/service-b.csr.pem
openssl x509 -req -in pki/csr/service-b.csr.pem \
-CA pki/ca/ca.cert.pem -CAkey pki/ca/ca.key.pem -CAcreateserial \
-out pki/certs/service-b.cert.pem -days 825 -sha256 \
-extfile pki/ext/service-b.ext
# 4) Certificado de cliente (service-a)
openssl req -new -newkey rsa:2048 -nodes \
-keyout pki/private/service-a.key.pem \
-subj "/CN=service-a" -out pki/csr/service-a.csr.pem
openssl x509 -req -in pki/csr/service-a.csr.pem \
-CA pki/ca/ca.cert.pem -CAkey pki/ca/ca.key.pem -CAcreateserial \
-out pki/certs/service-a.cert.pem -days 825 -sha256 \
-extfile pki/ext/service-a.ext
chmod 600 pki/private/*.key.pem
echo "PKI pronta em ./pki"
2) Configure service-b's Nginx to require mTLS
Nginx will act as the HTTPS server on service-b and validate client certificates against the local CA. For the lab we'll serve a simple response; in production you would proxy to an upstream.
If you prefer ready-made reverse proxies for containers, compare routing options like Traefik and Caddy: Traefik vs Caddy: HTTPS Routing in Docker.
events {}
http {
server {
listen 8443 ssl;
ssl_certificate /etc/nginx/certs/service-b.cert.pem;
ssl_certificate_key /etc/nginx/certs/service-b.key.pem;
ssl_client_certificate /etc/nginx/certs/ca.cert.pem;
ssl_verify_client on; # exige certificado de cliente
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
location / {
default_type text/plain;
return 200 "Hello from service-b. Client DN: $ssl_client_s_dn\n";
}
}
}
3) Declare services in Docker Compose and mount certificates
Now create the docker-compose.yml with two services. service-b runs Nginx with certificates mounted as read-only volumes. service-a uses a curl image and holds its client artifacts. No ports are published externally.
Keep private keys mounted with restricted permissions.
version: "3.8"
services:
service-b:
image: nginx:alpine
volumes:
- ./nginx/service-b.conf:/etc/nginx/nginx.conf:ro
- ./pki/certs/service-b.cert.pem:/etc/nginx/certs/service-b.cert.pem:ro
- ./pki/private/service-b.key.pem:/etc/nginx/certs/service-b.key.pem:ro
- ./pki/ca/ca.cert.pem:/etc/nginx/certs/ca.cert.pem:ro
expose:
- "8443"
service-a:
image: curlimages/curl:8.9.0
command: ["sleep", "infinity"]
volumes:
- ./pki/certs/service-a.cert.pem:/certs/service-a.cert.pem:ro
- ./pki/private/service-a.key.pem:/certs/service-a.key.pem:ro
- ./pki/ca/ca.cert.pem:/certs/ca.cert.pem:ro
4) Bring the environment up and verify basic configuration
Create the Nginx config directory, save the file and start the containers. Then confirm service-b is up and reachable on Compose's internal network.
If you need monitoring later, you can integrate metrics with Prometheus and Grafana: Guide: Prometheus & Grafana with Docker Compose.
# Salve o arquivo acima como nginx/service-b.conf
mkdir -p nginx
# Suba os serviços
docker compose up -d
# Verifique status
docker compose ps
# Opcional: inspecione logs do Nginx
docker compose logs -f service-b
5) mTLS tests: expected success and failures
We'll test requests from service-a. First, without a client certificate: the connection should fail. Then, with the correct artifacts: the response must include the client's DN. Make sure you use the correct SAN hostname: service-b.
# 1) Sem certificado de cliente: deve falhar com 400/495
docker compose exec service-a sh -lc 'curl -sv --cacert /certs/ca.cert.pem https://service-b:8443/'
# 2) Com mTLS: deve retornar 200 e DN do cliente
docker compose exec service-a sh -lc \
'curl -sS --cert /certs/service-a.cert.pem --key /certs/service-a.key.pem \
--cacert /certs/ca.cert.pem https://service-b:8443/'
Production best practices and key rotation
Avoid using the same CA for development and production. Prefer a chain with an offline root and an active intermediate.
Implement periodic certificate rotation with overlapping validity and gradual rollout.
Store keys in a secrets vault. In Compose, use read-only volumes with minimal permissions. Never expose internal TLS ports unless necessary.
If your mesh grows, consider gateways or TLS sidecars dedicated to certificate lifecycle and termination.
- Chain: offline root + online intermediate
- Short-lived certificates with automated rotation
- Read-only volume mounts for certificates
- Audit which processes mount each private key
Troubleshooting common issues
Unknown authority error: the client isn't using the correct CA. Mount ca.cert.pem and pass it with --cacert.
Invalid name in certificate: the SAN doesn't match the host service-b. Reissue with DNS:service-b.
Invalid date: host or container clock is out of sync. Fix NTP.
Permission denied for key: check chmod 600 on the host and read-only mount in the container.
Connection drops with no logs: verify Compose network and the service name.
- x509: certificate signed by unknown authority
- SSL: no required SSL certificate sent
- certificate verify failed: IP address mismatch
- permission denied: /etc/nginx/certs/*.key.pem
Frequently asked questions
What is mTLS and when should I use it between microservices?
mTLS authenticates both sides of a TLS connection: server and client. Use it when internal services need strong identity assurance and to reduce lateral movement risk. It's valuable in multi-tenant environments, for sensitive data, or on shared networks.
How do I choose the correct SAN for certificates in Docker Compose?
Use the DNS name the containers resolve to on the Compose network—typically the service name, e.g. service-b. If you use aliases, include all relevant DNS entries in the SAN to avoid verification failures.
Can I use Traefik or Caddy instead of Nginx?
Yes. Traefik and Caddy integrate well with Docker and support TLS. Requiring client certificates is also possible. Evaluate which fits your workflow and feature needs; see available comparisons to decide.
Does internal mTLS replace public certificates like Let's Encrypt?
No. Let's Encrypt is ideal for exposing services to the public internet with public trust. Internal PKI and mTLS address private service-to-service communication. You can combine both: public certificates at the edge and internal PKI for east‑west traffic.
How do I rotate certificates without downtime?
Generate replacement certificates before expiry, mount them at stable paths, and reload the process (for example, Nginx reload). Keep the CA stable; if you change the CA, implement a coexistence period where both old and new certificates are trusted.
Want to adapt this mTLS setup to your environment?
If you need to take this approach to production with automated rotation and observability, we can discuss your context and priorities.
Talk about mTLS in productionSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian