Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
Redes & Segurança · By Kevin Marcondes · Published on · 9 min read
In this guide you'll learn how to implement mTLS between Nginx and microservices using a smallstep CA in Docker Compose. We'll build a small internal CA with step‑ca, issue certificates with step‑cli, and configure Nginx to perform mutual authentication with the upstream service. The setup is compact, automatable and ready to evolve.
Prerequisites and tutorial scenario
You need a host with Docker and Docker Compose installed. The goal is for Nginx, acting as a reverse proxy, to connect to the microservice via HTTPS presenting a client certificate. The microservice will accept only clients issued by our smallstep CA.
If you don't have the container environment ready, follow this guide: install Docker and Docker Compose on Linux. Do not store private keys in Git repositories.
To compare approaches, see mTLS with a manual PKI: mTLS in Docker Compose (manual PKI). For dynamic issuance based on secrets, check the Vault tutorial: mTLS between Nginx and Node.js using Vault.
- Docker and Docker Compose installed
- Shell access to the host
- Text editor
- Available ports: 8080 (Nginx), 9000 (CA), 8443 (app)
| Service | Role | Port | Certificates used |
|---|---|---|---|
| step-ca | smallstep Certificate Authority | 9000 | root_ca.crt (root) |
| step-cli | Certificate issuance/management | - | uses root_ca.crt for bootstrap |
| nginx | Reverse proxy to app via mTLS | 8080 (external) | nginx-client.crt/key + root_ca.crt |
| app | HTTPS microservice requiring client cert | 8443 | app.crt/key + root_ca.crt |
mTLS architecture and flow
External traffic reaches Nginx over plain HTTP so we can focus on internal mTLS. Between Nginx and the app we use mutual TLS: both sides validate certificates signed by our CA. For production dynamic routing alternatives, compare Traefik vs Nginx as reverse proxy.
- The external client calls Nginx over HTTP.
- Nginx forwards to the app over HTTPS, presenting a client certificate.
- The app validates Nginx's certificate and presents its own. Nginx validates the app's chain.
Step 1 — Base Compose and working volumes
Create a project directory and subfolders for the CA and the issued certificates. We'll define a Docker Compose with four services: step-ca, step-cli (utility), app and nginx. Files under ./step contain the CA: protect and back them up securely.
version: '3.8'
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
ports:
- "9000:9000"
volumes:
- ./step:/home/step
command: ["step-ca","/home/step/config/ca.json","--password-file","/home/step/password.txt"]
step-cli:
image: smallstep/step-cli:latest
container_name: step-cli
environment:
- STEPPATH=/home/step
volumes:
- ./step:/home/step
- ./pki:/pki
entrypoint: ["sleep","infinity"]
app:
build: ./app
container_name: app
volumes:
- ./pki/app:/certs:ro
depends_on:
- step-ca
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./pki/nginx:/certs:ro
depends_on:
- app
Step 2 — Initialize the smallstep CA (step-ca)
We'll initialize the CA in the ./step volume using step-cli. This creates the root, the intermediate and ca.json. Then start step-ca. Store the password securely. For this lab you can reuse the same password for the CA and the provisioner JWK.
# 1) Estrutura de diretórios
mkdir -p step pki/app pki/nginx
# 2) Crie um arquivo de senha forte (use um gerenciador)
# AVISO: arquivo em texto puro. Restrinja permissões no host.
echo "mude-esta-senha-forte" > step/password.txt
chmod 600 step/password.txt
# 3) Inicialize a CA dentro do volume ./step
docker compose run --rm \
-e STEPPATH=/home/step step-cli \
step ca init \
--deployment-type=standalone \
--name "Dev CA" \
--provisioner "[email protected]" \
--dns "step-ca" \
--address ":9000" \
--password-file /home/step/password.txt \
--provisioner-password-file /home/step/password.txt
# 4) Inicie o step-ca
docker compose up -d step-ca
docker compose ps
Step 3 — Bootstrap trust and issue certificates
With the CA running, bootstrap trust in step-cli and issue: a server certificate for the app and a client certificate for Nginx. We include Docker hostname SANs for server verification. Also copy the CA root into the Nginx and app folders.
# 1) Obtenha o fingerprint da raiz da CA
FINGERPRINT=$(docker compose exec -T step-cli \
step certificate fingerprint /home/step/certs/root_ca.crt)
echo $FINGERPRINT
# 2) Bootstrap de confiança
docker compose exec -T step-cli \
step ca bootstrap --ca-url https://step-ca:9000 --fingerprint $FINGERPRINT
# 3) Emita certificados (app: servidor; nginx: cliente)
docker compose exec -T step-cli bash -lc '
mkdir -p /pki/app /pki/nginx && \
step ca certificate "app" /pki/app/app.crt /pki/app/app.key \
--san "app" --san "app.local" && \
step ca certificate "nginx" /pki/nginx/nginx-client.crt /pki/nginx/nginx-client.key && \
cp /home/step/certs/root_ca.crt /pki/app/root_ca.crt && \
cp /home/step/certs/root_ca.crt /pki/nginx/root_ca.crt'
# Verifique os arquivos gerados
ls -lah pki/app pki/nginx
Step 4 — Example microservice (Node.js HTTPS requiring client cert)
The app exposes HTTPS on port 8443, requires a client certificate and validates it against our CA root. For teaching purposes the server returns JSON with the mutual authentication status. In production keep keys out of the image and mount them as secrets or volumes.
# app/app.js
const fs = require('fs');
const https = require('https');
const options = {
key: fs.readFileSync('/certs/app.key'),
cert: fs.readFileSync('/certs/app.crt'),
ca: fs.readFileSync('/certs/root_ca.crt'),
requestCert: true,
rejectUnauthorized: true,
};
const server = https.createServer(options, (req, res) => {
const peer = req.socket.getPeerCertificate();
const authorized = req.socket.authorized;
res.writeHead(200, {'Content-Type':'application/json'});
res.end(JSON.stringify({ ok: true, authorized, subject: peer.subject }));
});
server.listen(8443, () => console.log('app listening on 8443'));
# app/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY app.js .
EXPOSE 8443
CMD ["node", "app.js"]
Step 5 — Nginx as a reverse proxy with mTLS to the upstream
Nginx will proxy to the app over HTTPS with server verification while presenting a client certificate. We enable SNI, server verification and modern protocol limits. Ensure mounted file paths are correct.
# nginx.conf (montado em /etc/nginx/conf.d/default.conf)
upstream app_tls {
server app:8443;
}
server {
listen 80;
location / {
proxy_pass https://app_tls;
# Verificação do servidor (app) e SNI
proxy_ssl_server_name on;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /certs/root_ca.crt;
proxy_ssl_verify_depth 2;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
# Certificado de cliente apresentado ao app
proxy_ssl_certificate /certs/nginx-client.crt;
proxy_ssl_certificate_key /certs/nginx-client.key;
# Cabeçalhos padrão
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Step 6 — Bring up the environment and test end-to-end mTLS
Build the app image, start everything and test via Nginx. If the internal mTLS is correct the app should respond with authorized=true. If it fails, check logs and the troubleshooting section. This is the final test after implementing mTLS between Nginx and microservices using smallstep CA in Docker Compose.
# 1) Construa o app e suba serviços necessários
docker compose up -d --build app nginx
# 2) Teste via Nginx (HTTP externo simples)
curl -i http://localhost:8080/
# 3) Verifique logs do app (deve mostrar conexões autorizadas)
docker compose logs --tail=100 app
# 4) Opcional: inspecione o certificado do app visto pelo Nginx
# (dentro do container nginx)
docker compose exec -T nginx sh -lc 'apk add --no-cache openssl >/dev/null && \
echo | openssl s_client -connect app:8443 -servername app -CAfile /certs/root_ca.crt 2>/dev/null | openssl x509 -noout -subject -issuer -dates'
Troubleshooting and production tips
If you see x509: certificate signed by unknown authority, verify that Nginx points to the correct root. For hostname mismatch ensure the SAN matches the hostname Nginx uses. Use short lived certificates and automate renewal. Reload Nginx and the app after renewal. Keep host time synced with NTP.
- Enable verbose error_log in Nginx (error_log debug) to debug handshakes.
- Adjust verify_depth if you have intermediate chains.
- Keep private keys out of the repo; use volumes or orchestrator secrets.
- Automate rotation with step-cli + timers and healthchecks.
- Standardize names/SANs across environments to avoid breakage.
Frequently asked questions
What is smallstep CA (step-ca) and why use it here?
It’s a lightweight Certificate Authority with a server and CLI designed for certificate automation. Here it makes issuing and renewing mTLS certificates between services easier than running a manual PKI.
Can I add more microservices that require mTLS?
Yes. Issue a server certificate for each service with the correct SANs. For every client that must call the service, issue a client certificate and configure the client to present it during the TLS handshake.
Can I use public HTTPS on Nginx and mTLS internally at the same time?
Yes. Keep public TLS at the front with a publicly trusted certificate and use mTLS between the proxy and backend services. The layers are independent and complementary.
How do I renew certificates automatically?
Use step-cli with a scheduler (cron or systemd timer) to request new certificates before expiration. After renewal reload Nginx and dependent services. Monitor expiry dates and configure alerts.
What if the container hostname changes?
The certificate SAN must match the hostname the client verifies. Standardize names in Compose or Kubernetes and generate certificates with consistent SANs. If the hostname changes, reissue certificates with the new SANs.
Can I run the CA without exposing it externally?
Yes. Restrict network access to the Compose internal network and use a firewall. The CA only needs to be reachable for issuance and renewal by authorized clients. Expose ports externally only when necessary.
Take mTLS with smallstep further in your stack?
This tutorial builds the foundation: internal CA, issuance and validation in Nginx. Need help adapting it for multiple services, automating renewal in production or integrating with your CI/CD? Let’s talk.
Discuss mTLS for my projectSee 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)
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian
- Automate LVM snapshots and incremental Restic uploads to S3 with systemd timers on Debian