TLS in Docker: Securing Containers Without Losing Your Mind

When I first started running services in Docker, TLS felt like an afterthought. Everything was on localhost or an internal network, so I figured it didn’t matter much. Then I started exposing services, connecting containers to external databases, and dealing with client certificate authentication — and suddenly TLS was everywhere.

Here’s what I’ve actually learned from doing this, not from reading perfect documentation.

The Two Common Setups

Before getting into the details, it helps to know there are really two ways people handle TLS with Docker:

  1. Terminate TLS at a reverse proxy (Nginx, Traefik, Caddy) and let containers communicate over plain HTTP on the internal Docker network
  2. TLS all the way in, where containers themselves handle TLS — this is more common with databases, gRPC services, and mTLS setups

For most web applications, option 1 is simpler and perfectly secure. For databases and service-to-service communication, option 2 is often necessary.

Option 1: Nginx as a TLS Reverse Proxy in Docker

The most common pattern I use:

# docker-compose.yml
services:
  nginx:
    image: nginx:alpine
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app

  app:
    image: my-app:latest
    expose:
      - "8080"

And the nginx config:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The certificates live in ./certs on the host and are mounted read-only into the Nginx container. The app container never sees TLS at all — it just gets plain HTTP from the Nginx proxy, which is fine because they’re on the same Docker network and traffic never leaves the host.

Certificates for Local Development

This is where things get annoying. Browsers don’t like self-signed certificates, and localhost certs require special treatment.

I’ve had good results with mkcert, which creates locally trusted certificates:

# Install mkcert
brew install mkcert  # macOS
# or
apt install mkcert   # some Linux distros

# Install the local CA
mkcert -install

# Generate a cert for localhost and local IPs
mkcert localhost 127.0.0.1 ::1 myapp.local

This puts localhost+3.pem and localhost+3-key.pem in the current directory. Mount those into your Nginx container and your browser will trust them without warnings.

The catch: mkcert creates a local root CA that only your machine trusts. It works great for local dev but you can’t share it with teammates without also sharing the root CA.

For team development, I’ve seen setups where everyone runs their own mkcert -install, or where a shared internal CA is set up and distributed to team members. The latter is more work but more consistent.

Using Let’s Encrypt Certificates with Docker

For production or staging environments that are publicly accessible, Let’s Encrypt with certbot is the way to go. The certbot Docker image makes this manageable:

services:
  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro

The nginx config needs an HTTP server block for the ACME challenge:

server {
    listen 80;
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

Get the initial certificate:

docker compose run --rm certbot certonly --webroot \
  --webroot-path=/var/www/certbot \
  --email admin@example.com \
  --agree-tos \
  --no-eff-email \
  -d example.com

This setup renews automatically every 12 hours (certbot only actually renews when the cert is within 30 days of expiry).

TLS for Databases in Docker

When I connect to PostgreSQL or MySQL in Docker from another container, the traffic stays on the Docker bridge network. But if the database is on a different host or if you’re dealing with compliance requirements, TLS matters.

For PostgreSQL with TLS:

services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - ./postgres-certs:/certs:ro
    command: >
      postgres
      -c ssl=on
      -c ssl_cert_file=/certs/server.crt
      -c ssl_key_file=/certs/server.key

The key file needs to be owned by the postgres user or ssl_key_file won’t load. This trips me up every time — you can fix it with:

chmod 600 postgres-certs/server.key
chown 999:999 postgres-certs/server.key  # 999 is the postgres user UID in the official image

Secrets Management: Don’t Bake Certs into Images

One mistake I see in Dockerfiles fairly often: copying certificates or private keys directly into the image. Don’t do this. Even if the image is “private,” keys embedded in a Docker layer can be extracted with docker history and docker save.

The right way is to mount certs at runtime via volumes or Docker secrets:

services:
  app:
    image: my-app:latest
    secrets:
      - tls_key
      - tls_cert

secrets:
  tls_key:
    file: ./certs/server.key
  tls_cert:
    file: ./certs/server.crt

With Docker secrets, files are available at /run/secrets/tls_key and /run/secrets/tls_cert inside the container. This is cleaner than environment variables (which show up in process listings and docker inspect) and doesn’t bake anything into the image.

Traefik as an Alternative to Nginx

I want to mention Traefik briefly because it handles TLS in a more automated way. It can detect containers by Docker labels and provision Let’s Encrypt certificates automatically:

services:
  traefik:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.email=admin@example.com"
      - "--certificatesresolvers.le.acme.storage=/acme.json"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
    ports:
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./acme.json:/acme.json

  app:
    image: my-app:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`example.com`)"
      - "traefik.http.routers.app.tls.certresolver=le"

Traefik reads the Docker socket and auto-configures routes and certs based on labels. Less configuration, but also less transparency — I sometimes prefer the explicitness of Nginx for production setups.

Debugging TLS Issues in Docker

When something isn’t working, I find these steps useful:

  1. Check if the cert files are actually mounted: docker exec <container> ls -la /etc/nginx/certs
  2. Check file permissions: private key should be readable by the process user
  3. Test the TLS endpoint from inside the container: docker exec <nginx-container> openssl s_client -connect localhost:443
  4. Check Nginx logs: docker logs <nginx-container>

One thing I ran into: if you regenerate certificates and restart the container without a volume remount, the container still has the old files cached. Sometimes you need to force-recreate: docker compose up --force-recreate nginx.


TLS in Docker is manageable once you understand the patterns. The main thing is to decide early whether you’re terminating TLS at the proxy or inside the application, and stick with that consistently across your services.

Scroll to Top