Let's Encrypt Docker Nginx: Free SSL Auto Renewal Setup 2026
SSL is like a sealed envelope for your mail. Without it, anyone can read your letters. Let's Encrypt is the post office that gives you free sealed envelopes, and Certbot is your assistant who automatically renews them before they expire.
What You Will Build
By the end of this tutorial, you will have:
- Free SSL certificates for all your domains (
api.yourdomain.com,app.yourdomain.com) - Automatic HTTPS redirection — every HTTP request redirects to HTTPS
- Auto-renewal — certificates renew automatically 30 days before expiry
- Modern TLS configuration — TLS 1.3, strong cipher suites, perfect forward secrecy
- Security headers — HSTS preload, preventing downgrade attacks
- A+ SSL rating on SSL Labs test
All running inside Docker Compose with zero manual intervention after initial setup.
Why SSL Is Non-Negotiable
Without SSL, every piece of data between your user and your server travels in plain text. Passwords, credit card numbers, session tokens, private messages — all visible to anyone on the same network.
Real-World Consequences of No SSL
| Attack | How It Works | Impact |
|---|---|---|
| Man-in-the-Middle | Attacker intercepts traffic on public Wi-Fi | Steals login credentials |
| Session Hijacking | Cookie transmitted in plain text | Attacker impersonates user |
| Content Injection | ISP injects ads or malware | Damages brand trust |
| SEO Penalty | Google ranks HTTP sites lower | Lost organic traffic |
| Browser Warnings | Chrome/Firefox show "Not Secure" | Users abandon site |
Google has required HTTPS for top search rankings since 2014. Chrome marks all HTTP sites as "Not Secure." Modern payment processors (Stripe, PayPal) refuse to process transactions over HTTP.
SSL is not optional. It is infrastructure.
How Let's Encrypt Works
The HTTP-01 Challenge
Let's Encrypt validates domain ownership using the HTTP-01 challenge:
- You request a certificate — Certbot asks Let's Encrypt for a certificate for
api.yourdomain.com - Let's Encrypt issues a challenge — It generates a random token and asks you to prove you control the domain
- You serve the token — Certbot writes the token to a file at
/.well-known/acme-challenge/<token> - Let's Encrypt verifies — It makes an HTTP request to
http://api.yourdomain.com/.well-known/acme-challenge/<token> - If the token matches, the certificate is issued — Let's Encrypt signs and returns your SSL certificate
Why this works: Only someone who controls the web server for api.yourdomain.com can serve a file at that exact path. This proves domain ownership without requiring DNS changes.
Certificate Lifecycle
| Phase | Timeline | Action |
|---|---|---|
| Initial issuance | Day 0 | Certbot requests certificate, passes HTTP-01 challenge |
| Valid period | Days 0–90 | Certificate is valid, Nginx serves HTTPS |
| Renewal window | Days 60–90 | Certbot attempts renewal (30 days before expiry) |
| Auto-renewal | Every 12 hours | Certbot checks if renewal is needed |
| Expiry | Day 90+ | If renewal failed, certificate expires, HTTPS breaks |
The key insight: Certbot must run continuously. It is not a one-time tool. It is a background service that checks and renews certificates forever.
Complete Docker Compose with Let's Encrypt
docker-compose.yml
1version: "3.9" 2 3services: 4 # ============================================ 5 # NGINX — Reverse Proxy with SSL 6 # ============================================ 7 nginx: 8 image: nginx:1.25-alpine 9 container_name: deploymart-proxy 10 restart: unless-stopped 11 ports: 12 - "80:80" 13 - "443:443" 14 volumes: 15 # Nginx configuration 16 - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro 17 - ./nginx/conf.d:/etc/nginx/conf.d:ro 18 19 # Static files from Django and Next.js 20 - django_static:/app/staticfiles:ro 21 - django_media:/app/mediafiles:ro 22 - nextjs_static:/app/.next/static:ro 23 - nextjs_public:/app/public:ro 24 25 # SSL certificates (shared with Certbot) 26 - certbot_data:/etc/letsencrypt:ro 27 - certbot_www:/var/www/certbot:ro 28 29 networks: 30 - frontend 31 32 depends_on: 33 django: 34 condition: service_healthy 35 nextjs: 36 condition: service_healthy 37 38 # ============================================ 39 # CERTBOT — SSL Certificate Management 40 # ============================================ 41 certbot: 42 image: certbot/certbot:latest 43 container_name: deploymart-certbot 44 restart: unless-stopped 45 volumes: 46 # Where certificates are stored (shared with Nginx) 47 - certbot_data:/etc/letsencrypt 48 # Where challenge files are served (shared with Nginx) 49 - certbot_www:/var/www/certbot 50 51 # Entrypoint: renew every 12 hours, wait for signals 52 entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" 53 54 # Certbot does not need a network for renewal 55 # It uses the host network for HTTP-01 challenges 56 network_mode: host 57 58 # ... other services (django, nextjs, postgres, redis) ... 59 60# ============================================ 61# VOLUMES 62# ============================================ 63volumes: 64 certbot_data: # SSL certificates (fullchain.pem, privkey.pem) 65 certbot_www: # ACME challenge files 66 # ... other volumes ...
Why Two Volumes?
| Volume | Mount Path | Purpose | Shared With |
|---|---|---|---|
certbot_data | /etc/letsencrypt | Stores issued certificates | Nginx (read-only), Certbot (read-write) |
certbot_www | /var/www/certbot | Serves ACME challenge files | Nginx (read-only), Certbot (read-write) |
certbot_data persists across container restarts. If you delete the Certbot container, your certificates survive. If you delete the volume, you must re-request certificates.
certbot_www is temporary. Challenge files are created during validation and deleted afterward. It only needs to exist during the HTTP-01 handshake.
Nginx Configuration for SSL
nginx/conf.d/default.conf
1# ============================================ 2# UPSTREAM DEFINITIONS 3# ============================================ 4upstream django_backend { 5 server django:8000; 6 keepalive 32; 7} 8 9upstream nextjs_frontend { 10 server nextjs:3000; 11 keepalive 32; 12} 13 14# ============================================ 15# HTTP → HTTPS REDIRECT (All Domains) 16# ============================================ 17# Every request on port 80 is permanently redirected to HTTPS. 18# The only exception is the ACME challenge path for Certbot. 19server { 20 listen 80; 21 listen [::]:80; 22 server_name api.yourdomain.com app.yourdomain.com www.yourdomain.com; 23 24 # Allow Let's Encrypt to validate domain ownership 25 # This path must be accessible over HTTP (not HTTPS) 26 location /.well-known/acme-challenge/ { 27 root /var/www/certbot; 28 try_files $uri =404; 29 } 30 31 # All other traffic: redirect to HTTPS 32 location / { 33 return 301 https://$host$request_uri; 34 } 35} 36 37# ============================================ 38# DJANGO API — api.yourdomain.com (HTTPS) 39# ============================================ 40server { 41 listen 443 ssl http2; 42 listen [::]:443 ssl http2; 43 server_name api.yourdomain.com; 44 45 # SSL Certificate Configuration 46 ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; 47 ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; 48 49 # Modern TLS Configuration 50 ssl_protocols TLSv1.2 TLSv1.3; # Only secure protocols 51 ssl_prefer_server_ciphers on; # Server chooses cipher 52 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; 53 ssl_session_timeout 1d; 54 ssl_session_cache shared:SSL:50m; 55 ssl_session_tickets off; 56 57 # OCSP Stapling — faster certificate validation 58 ssl_stapling on; 59 ssl_stapling_verify on; 60 ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem; 61 resolver 8.8.8.8 8.8.4.4 valid=300s; 62 resolver_timeout 5s; 63 64 # Diffie-Hellman parameter for perfect forward secrecy 65 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 66 67 # Security Headers 68 # HSTS: Force HTTPS for 2 years, include subdomains, preload list 69 add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 70 71 # Prevent clickjacking 72 add_header X-Frame-Options "SAMEORIGIN" always; 73 74 # Prevent MIME type sniffing 75 add_header X-Content-Type-Options "nosniff" always; 76 77 # XSS protection 78 add_header X-XSS-Protection "1; mode=block" always; 79 80 # Referrer policy 81 add_header Referrer-Policy "strict-origin-when-cross-origin" always; 82 83 # Content Security Policy (customize for your app) 84 add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.yourdomain.com;" always; 85 86 # Django static files 87 location /static/ { 88 alias /app/staticfiles/; 89 expires 6M; 90 access_log off; 91 add_header Cache-Control "public, max-age=15552000"; 92 } 93 94 # Django media files 95 location /media/ { 96 alias /app/mediafiles/; 97 expires 1M; 98 access_log off; 99 } 100 101 # API proxy to Django 102 location / { 103 proxy_pass http://django_backend; 104 proxy_set_header Host $host; 105 proxy_set_header X-Real-IP $remote_addr; 106 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 107 proxy_set_header X-Forwarded-Proto $scheme; 108 proxy_set_header X-Forwarded-Host $host; 109 proxy_set_header X-Forwarded-Port $server_port; 110 111 proxy_connect_timeout 60s; 112 proxy_send_timeout 60s; 113 proxy_read_timeout 60s; 114 115 proxy_http_version 1.1; 116 proxy_set_header Connection ""; 117 } 118} 119 120# ============================================ 121# NEXT.JS FRONTEND — app.yourdomain.com (HTTPS) 122# ============================================ 123server { 124 listen 443 ssl http2; 125 listen [::]:443 ssl http2; 126 server_name app.yourdomain.com; 127 128 ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; 129 ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; 130 131 # Same TLS configuration as above 132 ssl_protocols TLSv1.2 TLSv1.3; 133 ssl_prefer_server_ciphers on; 134 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; 135 ssl_session_timeout 1d; 136 ssl_session_cache shared:SSL:50m; 137 ssl_session_tickets off; 138 ssl_stapling on; 139 ssl_stapling_verify on; 140 ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem; 141 resolver 8.8.8.8 8.8.4.4 valid=300s; 142 resolver_timeout 5s; 143 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 144 145 # Security Headers 146 add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 147 add_header X-Frame-Options "SAMEORIGIN" always; 148 add_header X-Content-Type-Options "nosniff" always; 149 add_header X-XSS-Protection "1; mode=block" always; 150 add_header Referrer-Policy "strict-origin-when-cross-origin" always; 151 152 # Gzip compression 153 gzip on; 154 gzip_vary on; 155 gzip_min_length 1024; 156 gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; 157 158 # Next.js static files 159 location /_next/static/ { 160 alias /app/.next/static/; 161 expires 1y; 162 access_log off; 163 add_header Cache-Control "public, immutable"; 164 } 165 166 # Public assets 167 location /static/ { 168 alias /app/public/; 169 expires 1y; 170 access_log off; 171 add_header Cache-Control "public, max-age=31536000"; 172 } 173 174 # Frontend proxy to Next.js 175 location / { 176 proxy_pass http://nextjs_frontend; 177 proxy_set_header Host $host; 178 proxy_set_header X-Real-IP $remote_addr; 179 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 180 proxy_set_header X-Forwarded-Proto $scheme; 181 182 proxy_http_version 1.1; 183 proxy_set_header Upgrade $http_upgrade; 184 proxy_set_header Connection "upgrade"; 185 186 proxy_buffering off; 187 } 188}
Initial Certificate Request
Before Nginx can serve HTTPS, you need certificates. Run this one-time command:
1# Stop any running Nginx first (port 80 must be free) 2docker compose stop nginx 3 4# Request certificates for all domains 5docker run -it --rm -v deploymart_certbot_data:/etc/letsencrypt -v deploymart_certbot_www:/var/www/certbot -p 80:80 certbot/certbot certonly --standalone --agree-tos --no-eff-email --email admin@yourdomain.com -d api.yourdomain.com -d app.yourdomain.com -d www.yourdomain.com
What this does:
- Runs Certbot in standalone mode (its own temporary web server on port 80)
- Passes the HTTP-01 challenge for all three domains
- Saves certificates to the
certbot_datavolume - Exits after completion
After this succeeds, start Nginx:
1docker compose up -d nginx
Alternative: Using the Certbot Container Directly
If you prefer to use the Certbot service defined in docker-compose.yml:
1# Run a one-off Certbot command 2docker compose run --rm certbot certonly --webroot --webroot-path=/var/www/certbot --agree-tos --no-eff-email --email admin@yourdomain.com -d api.yourdomain.com -d app.yourdomain.com -d www.yourdomain.com
Difference: --webroot uses Nginx to serve challenge files (Nginx must be running). --standalone uses Certbot's built-in server (Nginx must be stopped).
Auto-Renewal Explained
The Certbot container runs this infinite loop:
1/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'
Line by line:
| Command | Purpose |
|---|---|
trap exit TERM | When Docker sends SIGTERM (stop command), exit cleanly |
while :; do | Infinite loop |
certbot renew | Check all certificates. Renew any expiring within 30 days. |
sleep 12h | Wait 12 hours before next check |
wait $${!} | Wait for the sleep process. If SIGTERM arrives during sleep, exit immediately. |
Why 12 hours? Let's Encrypt allows 5 failed validations per hour. Checking every 12 hours is frequent enough to catch expiring certificates but gentle enough to avoid rate limits.
What happens during renewal:
- Certbot checks certificate expiry dates
- If a certificate expires in less than 30 days, it initiates renewal
- New HTTP-01 challenge is performed
- New certificate is saved to
/etc/letsencrypt - Nginx automatically picks up the new certificate (no restart needed)
Security Headers Deep Dive
Strict-Transport-Security (HSTS)
1add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
| Directive | Value | Meaning |
|---|---|---|
max-age=63072000 | 2 years in seconds | Browser remembers to use HTTPS for 2 years |
includeSubDomains | — | Applies to ALL subdomains (*.yourdomain.com) |
preload | — | Browser vendors include your domain in hardcoded HSTS lists |
Warning: Once you set preload, you cannot easily undo it. Browsers will refuse to connect over HTTP for years. Only enable after thoroughly testing HTTPS.
To submit to preload list:
- Verify HSTS header is correct
- Visit
https://hstspreload.org/ - Submit your domain
- Wait for next Chrome/Firefox release (6–12 weeks)
X-Frame-Options
1add_header X-Frame-Options "SAMEORIGIN" always;
Prevents your site from being embedded in an <iframe> on another domain. Stops clickjacking attacks where an attacker tricks users into clicking hidden elements.
X-Content-Type-Options
1add_header X-Content-Type-Options "nosniff" always;
Prevents browsers from MIME-sniffing responses. Without this, a browser might execute a .jpg file as JavaScript if it contains script code.
Content-Security-Policy
1add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.yourdomain.com;" always;
The nuclear option for XSS prevention. Defines exactly what resources the browser is allowed to load.
| Directive | Value | Allows |
|---|---|---|
default-src | 'self' | Only same-origin by default |
script-src | 'self' 'unsafe-inline' | Same-origin scripts + inline scripts |
style-src | 'self' 'unsafe-inline' | Same-origin styles + inline styles |
img-src | 'self' data: https: | Same-origin, data URIs, any HTTPS |
font-src | 'self' | Only same-origin fonts |
connect-src | 'self' https://api... | Same-origin + your API domain |
Start with a permissive policy and tighten it. A too-strict CSP breaks your application. Use browser DevTools to see what gets blocked.
Hands-On Lab: Set Up SSL for DeployMart
Step 1: Prepare Your Domain
You need a real domain pointing to your server. For testing, use a subdomain:
1# Check your domain resolves 2dig +short api.yourdomain.com 3# Should return your server IP 4 5# Or use ping 6ping -c 1 api.yourdomain.com
Step 2: Create the Init Script
Create init-letsencrypt.sh:
1#!/bin/bash 2# init-letsencrypt.sh 3# One-time script to request initial SSL certificates 4 5# Configuration 6domains=("api.yourdomain.com" "app.yourdomain.com" "www.yourdomain.com") 7email="admin@yourdomain.com" # For renewal notifications 8staging=0 # Set to 1 for testing (avoids rate limits) 9 10# Paths 11data_path="./certbot" 12rsa_key_size=4096 13 14# Create directories 15mkdir -p "$data_path/conf/live/$domains" 16mkdir -p "$data_path/www" 17 18# Generate dummy certificates for Nginx startup 19# Nginx refuses to start if SSL certificates are missing 20if [ ! -f "$data_path/conf/options-ssl-nginx.conf" ]; then 21 echo "### Downloading recommended TLS parameters ..." 22 curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" 23 curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" 24fi 25 26# Create dummy certificate so Nginx can start 27if [ ! -f "$data_path/conf/live/$domains/fullchain.pem" ]; then 28 echo "### Creating dummy certificate for $domains ..." 29 openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1 -keyout "$data_path/conf/live/$domains/privkey.pem" -out "$data_path/conf/live/$domains/fullchain.pem" -subj "/CN=$domains" 30fi 31 32# Start Nginx 33echo "### Starting Nginx ..." 34docker compose up -d nginx 35 36# Delete dummy certificate 37echo "### Deleting dummy certificate ..." 38rm -rf "$data_path/conf/live/$domains" 39 40# Request real certificates 41echo "### Requesting Let's Encrypt certificate for $domains ..." 42 43# Select appropriate server 44if [ $staging != "0" ]; then 45 staging_arg="--staging" 46else 47 staging_arg="" 48fi 49 50docker compose run --rm --entrypoint " certbot certonly --webroot -w /var/www/certbot $staging_arg --register-unsafely-without-email --agree-tos --force-renewal -d ${domains[0]} -d ${domains[1]} -d ${domains[2]} " certbot 51 52# Reload Nginx 53echo "### Reloading Nginx ..." 54docker compose exec nginx nginx -s reload 55 56echo "### SSL setup complete!"
Make it executable:
1chmod +x init-letsencrypt.sh
Step 3: Run the Script
1./init-letsencrypt.sh
Expected output:
### Downloading recommended TLS parameters ...
### Creating dummy certificate for api.yourdomain.com ...
### Starting Nginx ...
[+] Running 1/1
✔ Container deploymart-proxy Started
### Deleting dummy certificate ...
### Requesting Let's Encrypt certificate for api.yourdomain.com ...
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for api.yourdomain.com and 2 more domains
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/yourdomain.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/yourdomain.com/privkey.pem
### Reloading Nginx ...
2024/01/15 10:30:45 [notice] 42#42: signal process started
### SSL setup complete!
Step 4: Verify HTTPS
1# Test HTTPS connection 2curl -I https://api.yourdomain.com/health/ 3# HTTP/2 200 4# strict-transport-security: max-age=63072000; includeSubDomains; preload 5 6# Test HTTP redirect 7curl -I http://api.yourdomain.com/health/ 8# HTTP/1.1 301 Moved Permanently 9# Location: https://api.yourdomain.com/health/ 10 11# SSL Labs test (A+ rating) 12# Visit: https://www.ssllabs.com/ssltest/analyze.html?d=api.yourdomain.com
Step 5: Verify Auto-Renewal
1# Simulate renewal (dry run) 2docker compose run --rm certbot renew --dry-run 3 4# Expected output: 5# Cert not due for renewal, but simulating renewal for dry run 6# Renewing an existing certificate 7# Congratulations, all renewals succeeded. 8 9# Check Certbot logs 10docker compose logs -f certbot 11# Every 12 hours: "Cert not yet due for renewal"
Common Mistakes and Solutions
Mistake 1: Blocking Port 80 After SSL Is Active
1# WRONG: Closing port 80 breaks renewal 2server { 3 listen 443 ssl; 4 # Port 80 is closed! 5}
Result: Certbot cannot perform the HTTP-01 challenge. Renewal fails. Certificate expires.
Fix: Always keep port 80 open for the ACME challenge path:
1server { 2 listen 80; 3 location /.well-known/acme-challenge/ { 4 root /var/www/certbot; 5 } 6 location / { 7 return 301 https://$host$request_uri; 8 } 9}
Mistake 2: Missing always in add_header
1# WRONG: Headers not sent on error responses 2add_header X-Frame-Options "SAMEORIGIN";
Result: If Nginx returns a 404 or 500 error, the security header is missing. Attackers exploit error pages.
Fix:
1add_header X-Frame-Options "SAMEORIGIN" always;
Mistake 3: Using ssl_certificate with Missing Files
1# WRONG: Certificates don't exist yet 2ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
Result: Nginx fails to start. Container enters restart loop.
Fix: Use the init script to create dummy certificates first, or use a conditional configuration:
1# Use a map to conditionally enable SSL 2map $host $ssl_enabled { 3 default 0; 4 api.yourdomain.com 1; 5 app.yourdomain.com 1; 6}
Mistake 4: Not Sharing Volumes Between Nginx and Certbot
1# WRONG: Separate volumes 2services: 3 nginx: 4 volumes: 5 - nginx_certs:/etc/letsencrypt # Different volume! 6 certbot: 7 volumes: 8 - certbot_certs:/etc/letsencrypt # Different volume!
Result: Certbot saves certificates to one volume. Nginx reads from another. Nginx never sees the certificates.
Fix: Use the same named volume:
1services: 2 nginx: 3 volumes: 4 - certbot_data:/etc/letsencrypt:ro 5 certbot: 6 volumes: 7 - certbot_data:/etc/letsencrypt
Mistake 5: Weak Cipher Suites
1# WRONG: Weak ciphers 2ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv2:+EXP;
Result: SSL Labs gives an F rating. Vulnerable to BEAST, POODLE, and other attacks.
Fix: Use only strong, modern ciphers:
1ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
Mistake 6: Missing HSTS on All Responses
1# WRONG: HSTS only on successful responses 2add_header Strict-Transport-Security "max-age=63072000";
Result: Error pages (404, 500) are served without HSTS. Attackers downgrade these to HTTP.
Fix:
1add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
SSL Labs A+ Rating Checklist
| Requirement | Configuration | Your Score |
|---|---|---|
| Certificate | Valid, trusted, correct hostname | ✓ |
| Protocol support | TLS 1.2 and 1.3 only | ✓ |
| Key exchange | ECDHE with strong parameters | ✓ |
| Cipher strength | AES-128-GCM or AES-256-GCM | ✓ |
| Forward secrecy | Supported with all browsers | ✓ |
| HSTS | max-age ≥ 180 days | ✓ |
| OCSP stapling | Enabled | ✓ |
Target: A+ rating on SSL Labs test.
Mini Project: Secure DeployMart with SSL
Requirements
-
SSL Certificates:
- Request certificates for
api.yourdomain.comandapp.yourdomain.com - Use the
init-letsencrypt.shscript - Verify with SSL Labs (target: A+)
- Request certificates for
-
Nginx Configuration:
- HTTP → HTTPS redirect for all traffic
- ACME challenge path accessible on port 80
- TLS 1.2 and 1.3 only
- Strong cipher suites
- OCSP stapling enabled
-
Security Headers:
- HSTS with
includeSubDomains; preload - X-Frame-Options
- X-Content-Type-Options
- X-XSS-Protection
- Referrer-Policy
- Content-Security-Policy (basic)
- HSTS with
-
Auto-Renewal:
- Certbot container running with 12-hour check interval
- Dry-run test passes
- Certificates persist across container restarts
-
Verification:
1# HTTPS works 2curl -I https://api.yourdomain.com/health/ 3# HTTP/2 200 4 5# HTTP redirects to HTTPS 6curl -I http://api.yourdomain.com/health/ 7# HTTP/1.1 301 → https://... 8 9# Security headers present 10curl -I https://api.yourdomain.com/health/ | grep -i "strict-transport" 11# strict-transport-security: max-age=63072000... 12 13# SSL Labs A+ rating 14# Visit: https://www.ssllabs.com/ssltest/
What You Learned
| Concept | What It Is | Why It Matters |
|---|---|---|
| Let's Encrypt | Free certificate authority | Eliminates cost barrier for HTTPS |
| HTTP-01 challenge | Domain validation via file serving | Proves domain ownership |
| Certbot | ACME client for certificate management | Automates issuance and renewal |
fullchain.pem | Certificate + intermediate CA chain | Browsers trust the certificate |
privkey.pem | Private key for the certificate | Must be kept secret |
| HSTS | Forces HTTPS in browser | Prevents downgrade attacks |
| OCSP stapling | Server-side certificate validation | Faster page loads |
| Forward secrecy | Unique session keys per connection | Past sessions safe even if key leaks |
| TLS 1.3 | Latest TLS protocol | Faster handshakes, stronger security |
| Cipher suites | Encryption algorithms negotiated | Weak ciphers enable attacks |
Next Module
In Module 8: Security Hardening, you will lock down the entire DeployMart stack. You will implement non-root containers, read-only filesystems, capability dropping, network policies, rate limiting, and vulnerability scanning — transforming your application from "working" to "production-hardened."