Docker Compose with Let's Encrypt SSL: Production Guide
Running containerized web applications in production requires robust TLS termination. Combining Docker Compose with Let's Encrypt gives you automated, free SSL certificates that renew themselves — all while maintaining the clean orchestration Docker Compose provides. This tutorial walks through every step needed to deploy a production-ready stack with HTTPS, from initial setup through ongoing maintenance.
What You Will Build
By the end of this guide, you will have a Docker Compose stack consisting of:
- A sample web application (or your own app) running in a container
- An Nginx reverse proxy that handles TLS termination and routes traffic
- Certbot, the official Let's Encrypt client, for certificate issuance and automated renewal
- A fully automated pipeline that obtains, installs, and renews certificates without downtime
Why Docker Compose + Let's Encrypt Matters
In production environments, you face several critical requirements simultaneously:
- TLS Everywhere: Browsers flag non-HTTPS sites as insecure. APIs without TLS are rejected by modern clients. TLS is no longer optional.
- Cost Efficiency: Commercial certificates cost money per domain. Let's Encrypt provides them free, indefinitely.
- Automated Renewal: Let's Encrypt certificates expire every 90 days. Manual renewal is unsustainable. Automation is mandatory.
- Containerized Deployment: Docker Compose simplifies multi-container orchestration with a single declarative YAML file.
- Zero-Downtime Operations: Certificates can be renewed and reloaded without dropping a single request.
Combining these technologies gives you a self-contained, reproducible, and secure deployment that costs nothing beyond your infrastructure.
Prerequisites
- A Linux server with a public IP address (Ubuntu 22.04 or 24.04 recommended)
- Docker Engine 24+ and Docker Compose v2 installed
- A domain name with DNS A record pointing to your server's IP
- Ports 80 and 443 open on your firewall
- Root or sudo access on the server
Project Structure
Start by creating a clean project directory. The structure we will build looks like this:
project/
├── docker-compose.yml
├── nginx/
│ └── nginx.conf
├── certbot/
│ ├── conf/ (generated at runtime)
│ └── www/ (challenge files, generated at runtime)
├── app/
│ └── index.html (your application lives here)
└── scripts/
└── init-letsencrypt.sh
Create this structure now:
mkdir -p ~/docker-le-ssl/{nginx,certbot/conf,certbot/www,app,scripts}
cd ~/docker-le-ssl
Step 1: Create a Sample Application
For demonstration, we will deploy a simple static site served by Nginx. In a real project, this could be a Node.js app, a Python service, or any containerized workload. Create a minimal page:
cat > app/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Docker + Let's Encrypt Demo</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 4rem auto; padding: 0 1rem; }
h1 { color: #2d3748; }
.badge { background: #48bb78; color: white; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; }
</style>
</head>
<body>
<h1>Hello from Docker Compose with Let's Encrypt</h1>
<p>This page is served over <span class="badge">HTTPS</span> with a free, auto-renewed certificate.</p>
<p>Server time: <span id="time"></span></p>
<script>
document.getElementById('time').textContent = new Date().toISOString();
</script>
</body>
</html>
EOF
Step 2: Write the Nginx Configuration
The Nginx reverse proxy will handle two distinct phases: the initial HTTP-only mode (used during certificate issuance) and the final HTTPS mode (used in production). We write a single configuration file that adapts to both phases.
cat > nginx/nginx.conf << 'EOF'
# nginx.conf — production reverse proxy with Let's Encrypt support
# Phase 1: HTTP only (certificate not yet obtained)
# Phase 2: HTTPS with HTTP redirect (certificate loaded)
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# Optimizations
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# --- Upstream definition for your application ---
upstream app_backend {
server app:80; # 'app' is the Docker Compose service name
}
# --- HTTP server (used for ACME challenge and initial redirect) ---
server {
listen 80;
server_name example.com www.example.com; # REPLACE with your domain
# Let's Encrypt HTTP-01 challenge location
location /.well-known/acme-challenge/ {
root /var/www/certbot;
# Allow dotfiles in challenge directory
location ~ /.well-known/acme-challenge/(.*) {
root /var/www/certbot;
}
default_type text/plain;
}
# Redirect everything else to HTTPS (once certificate exists)
location / {
return 301 https://$host$request_uri;
}
}
# --- HTTPS server (production) ---
server {
listen 443 ssl http2;
server_name example.com www.example.com; # REPLACE with your domain
# SSL certificate paths — these files are symlinked by Certbot
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Let's Encrypt challenge also accessible on HTTPS
location /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
}
# Proxy requests to the application backend
location / {
proxy_pass http://app_backend;
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# WebSocket support (if your app uses WebSockets)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
# Default catch-all for unrecognized hostnames
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
# Self-signed placeholder cert to avoid connection errors
ssl_certificate /etc/nginx/ssl/dummy.crt;
ssl_certificate_key /etc/nginx/ssl/dummy.key;
return 444; # Close connection silently
}
}
EOF
Important: Replace example.com and www.example.com with your actual domain throughout the configuration. There are four occurrences: in the server_name directives and in the ssl_certificate paths.
Step 3: Create the Docker Compose File
This is the heart of the setup. We define three services: nginx (reverse proxy), app (your application), and certbot (certificate manager).
cat > docker-compose.yml << 'EOF'
version: "3.9"
services:
# --- Nginx Reverse Proxy ---
nginx:
image: nginx:1.25-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
# Main configuration
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Certbot challenge files (shared with certbot container)
- certbot_www:/var/www/certbot:ro
# Live certificates (shared with certbot container)
- certbot_conf:/etc/letsencrypt:ro
# Dummy self-signed cert for catch-all server block
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
- frontend
# --- Your Application ---
app:
image: nginx:1.25-alpine
container_name: app-backend
restart: unless-stopped
volumes:
- ./app/index.html:/usr/share/nginx/html/index.html:ro
networks:
- frontend
# Application only listens internally on port 80 — no public ports exposed
# --- Certbot for Let's Encrypt ---
certbot:
image: certbot/certbot:latest
container_name: certbot-client
volumes:
# Shared volume for ACME challenge files
- certbot_www:/var/www/certbot:rw
# Shared volume for certificates and renewal config
- certbot_conf:/etc/letsencrypt:rw
# Certbot runs on-demand via command, not as a persistent service
entrypoint: ["/bin/sh", "-c"]
command: |
"trap : TERM INT; sleep infinity & wait"
networks:
- frontend
# --- Named Volumes for Certificate Data ---
volumes:
certbot_www:
driver: local
certbot_conf:
driver: local
# --- Network ---
networks:
frontend:
driver: bridge
EOF
Key design decisions in this compose file:
- Shared volumes:
certbot_wwwholds ACME challenge files;certbot_confholds the live certificates. Both are mounted into nginx (read-only) and certbot (read-write). - No exposed ports on app: The application container has no published ports. It is only reachable through the nginx reverse proxy via the internal Docker network.
- Certbot sleeps: The certbot container runs a sleep loop. We trigger certificate operations via
docker compose execcommands, which we will automate later. - Dummy SSL directory: We reference
./nginx/ssl/for a self-signed placeholder. We must create this before starting the stack.
Step 4: Generate Placeholder SSL Files
Before Nginx starts, it needs the dummy certificate referenced in the catch-all server block. Create these now:
mkdir -p nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout nginx/ssl/dummy.key \
-out nginx/ssl/dummy.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost"
Step 5: The Initialization Script
We need a bootstrap script that obtains the first certificate. This script runs the certbot container with the HTTP-01 challenge, verifies domain ownership, and obtains the certificate — all while Nginx is running in HTTP-only mode.
cat > scripts/init-letsencrypt.sh << 'EOF'
#!/bin/bash
# init-letsencrypt.sh — bootstrap Let's Encrypt certificates for the first time
# Usage: ./scripts/init-letsencrypt.sh example.com www.example.com
set -euo pipefail
# --- Configuration ---
DOMAIN="${1:-}"
ALTERNATE_DOMAINS="${2:-}"
if [ -z "$DOMAIN" ]; then
echo "ERROR: You must provide at least one domain name."
echo "Usage: $0 example.com [www.example.com]"
exit 1
fi
COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$COMPOSE_DIR"
# --- Pre-flight checks ---
echo "==> Pre-flight checks..."
if ! docker compose ps &>/dev/null; then
echo "ERROR: Docker Compose services not running. Start them first: docker compose up -d"
exit 1
fi
# --- Build certbot arguments ---
echo "==> Preparing certificate request for domain: $DOMAIN"
CERTBOT_ARGS="certonly --webroot -w /var/www/certbot"
CERTBOT_ARGS="$CERTBOT_ARGS --email admin@${DOMAIN} --agree-tos --no-eff-email"
CERTBOT_ARGS="$CERTBOT_ARGS -d $DOMAIN"
if [ -n "$ALTERNATE_DOMAINS" ]; then
for alt in $ALTERNATE_DOMAINS; do
CERTBOT_ARGS="$CERTBOT_ARGS -d $alt"
echo " Adding alternate domain: $alt"
done
fi
CERTBOT_ARGS="$CERTBOT_ARGS --rsa-key-size 4096"
# --- Request the certificate ---
echo "==> Requesting Let's Encrypt certificate..."
docker compose exec certbot certbot $CERTBOT_ARGS
# --- Verify certificate was obtained ---
echo "==> Verifying certificate..."
LIVE_PATH="/etc/letsencrypt/live/${DOMAIN}"
if docker compose exec certbot test -f "${LIVE_PATH}/fullchain.pem" 2>/dev/null; then
echo "SUCCESS: Certificate obtained and stored at ${LIVE_PATH}"
else
echo "ERROR: Certificate file not found. Check certbot logs above."
exit 1
fi
# --- Reload nginx to pick up the new certificate ---
echo "==> Reloading Nginx to enable HTTPS..."
docker compose exec nginx nginx -s reload
echo ""
echo "============================================"
echo " Certificate successfully installed!"
echo " Visit: https://${DOMAIN}"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Verify HTTPS works in your browser"
echo " 2. Test renewal with: docker compose exec certbot certbot renew --dry-run"
echo " 3. Set up automated renewal (see tutorial)"
EOF
chmod +x scripts/init-letsencrypt.sh
Step 6: Start the Stack and Obtain Certificates
With all pieces in place, we start the services and run the initialization script.
# Start all services in detached mode
docker compose up -d
# Verify services are healthy
docker compose ps
# Check Nginx is responding on port 80
curl -I http://your-domain.com
# Run the initialization script (REPLACE with your actual domain)
./scripts/init-letsencrypt.sh your-domain.com www.your-domain.com
What happens during initialization:
- The script runs
certbot certonly --webrootinside the certbot container - Certbot places a challenge file in
/var/www/certbot/.well-known/acme-challenge/ - Let's Encrypt's validation servers fetch that file via your domain on port 80 (served by Nginx)
- Upon successful validation, Certbot stores the certificate in
/etc/letsencrypt/live/your-domain.com/ - The script reloads Nginx, which now finds the real certificate and enables HTTPS
At this point, visiting https://your-domain.com should show your application served over a valid HTTPS connection with a padlock in the browser.
Step 7: Automated Certificate Renewal
Let's Encrypt certificates are valid for 90 days. Renewal must happen automatically. We set up a cron job on the host that runs the renewal command inside the certbot container twice daily (as recommended by Let's Encrypt).
# Create a renewal script
cat > scripts/renew-certificates.sh << 'EOF'
#!/bin/bash
# renew-certificates.sh — called by cron to renew certificates nearing expiry
set -euo pipefail
COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOGFILE="${COMPOSE_DIR}/logs/renewal.log"
mkdir -p "${COMPOSE_DIR}/logs"
echo "=== Renewal attempt at $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOGFILE"
cd "$COMPOSE_DIR"
# Run certbot renew — this only renews certificates that are due
if docker compose exec certbot certbot renew --quiet --deploy-hook "nginx -s reload" >> "$LOGFILE" 2>&1; then
echo "Renewal completed successfully." >> "$LOGFILE"
else
echo "ERROR: Renewal failed. Check certbot logs." >> "$LOGFILE"
fi
# Reload nginx regardless (safe operation)
docker compose exec nginx nginx -s reload >> "$LOGFILE" 2>&1 || true
echo "" >> "$LOGFILE"
EOF
chmod +x scripts/renew-certificates.sh
Now install the cron job on the host. Run it twice daily with a random minute offset to avoid thundering herd problems with Let's Encrypt servers:
# Edit the root crontab
sudo crontab -e
# Add these two lines (runs at 03:17 and 15:17 UTC daily)
17 3 * * * /home/youruser/docker-le-ssl/scripts/renew-certificates.sh
17 15 * * * /home/youruser/docker-le-ssl/scripts/renew-certificates.sh
You can test the renewal process immediately with a dry run:
docker compose exec certbot certbot renew --dry-run
A successful dry run output looks like:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
** DRY RUN: simulating 'certbot renew' close to cert expiry
** (The test cert below has not been saved.)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewal simulations succeeded.
Advanced: Using DNS-01 Challenge (Wildcard Certificates)
The HTTP-01 challenge works for most deployments, but if you need wildcard certificates (e.g., *.example.com) or your server cannot be reached on port 80, you must use the DNS-01 challenge. This requires a DNS provider plugin. Here is how to adapt the setup for Cloudflare:
# docker-compose.yml service override for DNS-based certbot
cat > docker-compose.dns.yml << 'EOF'
version: "3.9"
services:
certbot:
image: certbot/certbot:latest
container_name: certbot-client
volumes:
- certbot_conf:/etc/letsencrypt:rw
# Cloudflare credentials file
- ./certbot/cloudflare.ini:/etc/cloudflare.ini:ro
entrypoint: ["/bin/sh", "-c"]
command: |
"trap : TERM INT; sleep infinity & wait"
networks:
- frontend
EOF
Create the Cloudflare credentials file:
cat > certbot/cloudflare.ini << 'EOF'
# Cloudflare API credentials for certbot DNS plugin
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 certbot/cloudflare.ini
Then obtain a wildcard certificate:
docker compose exec certbot certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/cloudflare.ini \
--email admin@example.com \
--agree-tos \
--no-eff-email \
-d "*.example.com" \
-d "example.com" \
--rsa-key-size 4096
Note that DNS-01 challenges take longer to propagate, but they enable wildcard certificates that cover all subdomains with a single certificate.
Production Best Practices
1. Pin Image Versions
In production, always pin specific image tags rather than using latest. This prevents unexpected changes breaking your stack:
# In docker-compose.yml, use specific versions:
nginx:
image: nginx:1.25.3-alpine # pinned
certbot:
image: certbot/certbot:v2.7.4 # pinned
2. Isolate Secrets
Never commit API tokens or credentials to version control. Use Docker secrets (in Swarm) or environment files excluded from git:
# .gitignore additions
certbot/cloudflare.ini
.env
*.pem
*.key
nginx/ssl/
3. Monitor Certificate Expiry
Add a simple monitoring check that alerts you if renewal fails silently:
cat > scripts/check-expiry.sh << 'EOF'
#!/bin/bash
# Check certificate expiry and exit with non-zero if less than 30 days remain
DOMAIN="${1:-}"
THRESHOLD_DAYS=30
if [ -z "$DOMAIN" ]; then
echo "Usage: $0 example.com"
exit 2
fi
EXPIRY_DATE=$(docker compose exec certbot openssl x509 \
-in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \
-noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$EXPIRY_DATE" ]; then
echo "CRITICAL: Could not read certificate expiry for ${DOMAIN}"
exit 2
fi
EXPIRY_EPOCH=$(date -d "$EXPIRY_DATE" +%s)
NOW_EPOCH=$(date +%s)
DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "Certificate for ${DOMAIN} expires in ${DAYS_REMAINING} days (${EXPIRY_DATE})"
if [ "$DAYS_REMAINING" -lt "$THRESHOLD_DAYS" ]; then
echo "WARNING: Certificate expires soon. Renewal may have failed."
exit 1
fi
exit 0
EOF
chmod +x scripts/check-expiry.sh
Integrate this check into your monitoring system (Nagios, Prometheus, or a simple cron alert):
# Weekly expiry check with email alert
0 9 * * 1 /home/youruser/docker-le-ssl/scripts/check-expiry.sh your-domain.com || \
echo "Certificate expiring soon for your-domain.com" | mail -s "SSL Alert" alerts@example.com
4. Use a Staging Environment First
Let's Encrypt has rate limits. Before running against production, test your setup against their staging API:
# Test with staging (add --staging flag)
docker compose exec certbot certbot certonly --staging --webroot \
-w /var/www/certbot \
-d your-domain.com \
--email admin@your-domain.com \
--agree-tos \
--no-eff-email \
--rsa-key-size 4096
Staging certificates are not trusted by browsers but behave identically to production certificates. Once your workflow works on staging, remove the --staging flag and run against production.
5. Implement Graceful Nginx Reloads
Nginx supports hot reload via signals. Always use nginx -s reload rather than restarting the container. This reloads certificates without dropping active connections:
# Correct: hot reload (zero downtime)
docker compose exec nginx nginx -s reload
# Avoid: full container restart (drops connections)
docker compose restart nginx
6. Configure Log Rotation
Nginx and Certbot logs grow over time. Configure Docker's log driver or use host-level log rotation:
# In docker-compose.yml, under the nginx service:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
7. Secure the Host Firewall
Ensure only ports 80, 443, and your SSH port are open. Use ufw or your cloud provider's firewall:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw enable
8. Backup Certificate Data
The certbot_conf volume contains your live certificates and renewal configuration. Back it up regularly:
# Backup script
docker compose run --rm \
-v certbot_conf:/backup/source:ro \
-v $(pwd)/backups:/backup/dest:rw \
alpine tar czf /backup/dest/certbot-backup-$(date +%Y%m%d).tar.gz -C /backup/source .
Complete Production docker-compose.yml (Annotated)
Here is the final, production-ready compose file incorporating all best practices discussed:
version: "3.9"
services:
nginx:
image: nginx:1.25.3-alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_www:/var/www/certbot:ro
- certbot_conf:/etc/letsencrypt:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
app:
condition: service_started
networks:
- frontend
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
app:
image: nginx:1.25.3-alpine
container_name: app-backend
restart: unless-stopped
volumes:
- ./app/index.html:/usr/share/nginx/html/index.html:ro
networks:
- frontend
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
certbot:
image: certbot/certbot:v2.7.4
container_name: certbot-client
volumes:
- certbot_www:/var/www/certbot:rw
- certbot_conf:/etc/letsencrypt:rw
entrypoint: ["/bin/sh", "-c"]
command: |
"trap : TERM INT; sleep infinity & wait"
networks:
- frontend
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
certbot_www:
driver: local
certbot_conf:
driver: local
networks:
frontend:
driver: bridge
Troubleshooting Common Issues
Issue: "Connection refused" on port 443
Cause: Nginx cannot find the certificate files. Fix: Verify the symlinks in /etc/letsencrypt/live/your-domain.com/ point to valid files in the archive directory. Run docker compose exec certbot ls -la /etc/letsencrypt/live/your-domain.com/ to check.
Issue: "Too many certificates issued" error
Cause: You have hit Let's Encrypt's rate limit (5 certificates per domain per week). Fix: Use the staging environment for testing, or wait for the rate limit window to reset.
Issue: ACME challenge fails with 404
Cause: The challenge file is not being served correctly. Fix: Ensure the certbot_www volume is mounted in both nginx and certbot services. Test with: docker compose exec certbot touch /var/www/certbot/test.txt and then curl http://your-domain.com/.well-known/acme-challenge/test.txt.
Issue: Nginx starts but immediately exits
Cause: The dummy SSL files are missing or the configuration has syntax errors. Fix: Run docker compose exec nginx nginx -t to validate the configuration. Check that nginx/ssl/dummy.crt and nginx/ssl/dummy.key exist on the host.
Issue: Renewal cron job runs but certificates don't renew
Cause: Certbot's renewal configuration may have incorrect webroot path. Fix: Check /etc/letsencrypt/renewal/your-domain.com.conf inside the certbot container and ensure the webroot path matches the volume mount.
Security Considerations
- Private key isolation: The
privkey.pemfile never leaves the server. It exists only in the Docker volume. Never expose it via HTTP. - OCSP stapling: Enabled in the provided nginx.conf. This improves TLS handshake performance and privacy by having Nginx fetch OCSP responses, not the client.
- HSTS header: The
Strict-Transport-Securityheader instructs browsers to never visit the HTTP version. Once enabled, removal requires careful planning. - Minimal exposure: Only Nginx has published ports. Your application containers run on internal Docker networks only.
- Read-only mounts: Nginx mounts certificate volumes as
:ro(read-only), limiting the blast