Secure Docker Apps with Let’s Encrypt + Nginx: End-to-End HTTPS Setup & Debugging
This tutorial walks through an end-to-end, production-style HTTPS setup for Dockerized applications using Nginx as a reverse proxy and Let’s Encrypt certificates via Certbot. You’ll build a clean baseline, obtain and renew certificates, harden TLS, and learn how to debug the most common failures (DNS, ports, ACME challenges, Nginx misroutes, mixed content, redirect loops, and renewal issues).
The focus is on real commands, deep explanations, and repeatable steps.
Table of Contents
- Architecture Overview
- Prerequisites
- Step 1: Prepare DNS and Firewall
- Step 2: Create a Docker Network
- Step 3: Run Your App Container
- Step 4: Run Nginx as a Reverse Proxy
- Step 5: Obtain Let’s Encrypt Certificates (HTTP-01)
- Step 6: Enable HTTPS in Nginx (TLS + Redirects)
- Step 7: Validate the Setup
- Step 8: Set Up Automatic Renewal
- Step 9: Harden TLS and Security Headers
- Debugging Guide (Most Common Failures)
- Operational Tips
- Cleanup / Uninstall
Architecture Overview
You will run three main pieces:
- Your application container (example:
myapp) listening on an internal port (e.g.,3000). - Nginx container listening on public ports 80 and 443 on the host, acting as a reverse proxy to your app.
- Certbot container to request and renew Let’s Encrypt certificates.
Key ideas:
- Let’s Encrypt HTTP-01 challenge requires that the public Internet can reach
http://YOUR_DOMAIN/.well-known/acme-challenge/...on port 80. - Nginx must serve the ACME challenge files from a shared directory (a Docker volume).
- Certificates are stored in a shared volume so Nginx can read them.
- You’ll redirect all normal traffic from HTTP → HTTPS after certificates are issued, while still allowing the ACME challenge path over HTTP.
Prerequisites
- A Linux server with a public IP (Ubuntu/Debian examples used; other distros are similar).
- A domain name you control (e.g.,
example.com) with DNS A/AAAA records pointing to your server. - Docker and Docker Compose plugin installed.
Install Docker (Ubuntu example):
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
docker version
docker compose version
Step 1: Prepare DNS and Firewall
DNS
Create DNS records:
Arecord:example.com→YOUR_SERVER_IPV4- (Optional)
AAAArecord:example.com→YOUR_SERVER_IPV6
Confirm from your machine:
dig +short example.com A
dig +short example.com AAAA
If these return the wrong IP or nothing, Let’s Encrypt validation will fail.
Firewall / Security Groups
You must allow inbound:
- TCP 80 (HTTP)
- TCP 443 (HTTPS)
On Ubuntu with UFW:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status
On cloud providers (AWS Security Groups, GCP firewall rules, etc.), open the same ports there too.
Step 2: Create a Docker Network
A dedicated network makes service discovery simple (containers can reach each other by name).
docker network create web
Check:
docker network ls
Step 3: Run Your App Container
For demonstration, run a simple HTTP echo server. Replace this with your real app later.
Example using nginxdemos/hello (serves a basic page):
docker run -d \
--name myapp \
--network web \
--restart unless-stopped \
nginxdemos/hello
Test it internally (from the host):
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' myapp
Or curl it from another container on the same network:
docker run --rm --network web curlimages/curl:8.7.1 \
curl -sS http://myapp:80 | head
This confirms the app is reachable by the service name myapp inside the web network.
Step 4: Run Nginx as a Reverse Proxy
We’ll mount:
./nginx/conf.d→ Nginx virtual host configs./certbot/www→ ACME challenge webroot./certbot/conf→ Let’s Encrypt cert storage
Create directories:
mkdir -p nginx/conf.d
mkdir -p certbot/www
mkdir -p certbot/conf
Create an HTTP-only Nginx config (initial bootstrap)
Create nginx/conf.d/app.conf:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# ACME challenge files will be placed here by certbot (webroot method)
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
try_files $uri =404;
}
# Temporary: proxy everything else to the app over HTTP
location / {
proxy_pass http://myapp:80;
proxy_http_version 1.1;
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;
}
}
Why this matters:
- The
location ^~ /.well-known/acme-challenge/block ensures ACME requests are served from a filesystem path, not proxied to your app. root /var/www/certbot;means a request for/.well-known/acme-challenge/TOKENmaps to/var/www/certbot/.well-known/acme-challenge/TOKEN.- The proxy headers are essential for many frameworks to correctly generate absolute URLs and determine the original scheme.
Run Nginx container
docker run -d \
--name nginx-proxy \
--network web \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
-v "$PWD/nginx/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/certbot/www:/var/www/certbot:ro" \
-v "$PWD/certbot/conf:/etc/letsencrypt:ro" \
nginx:1.27-alpine
Check logs:
docker logs -n 50 nginx-proxy
Test HTTP:
curl -i http://example.com/
At this point, HTTP should work. HTTPS won’t yet (no certs).
Step 5: Obtain Let’s Encrypt Certificates (HTTP-01)
We’ll use Certbot’s webroot mode:
- Certbot writes challenge files into
certbot/www/.well-known/acme-challenge/ - Nginx serves them from
/.well-known/acme-challenge/
Dry run: confirm the challenge path is reachable
Create a test file:
mkdir -p certbot/www/.well-known/acme-challenge
echo "ok" > certbot/www/.well-known/acme-challenge/test.txt
Fetch it publicly:
curl -i http://example.com/.well-known/acme-challenge/test.txt
You must see 200 OK and body ok. If you get 404, fix Nginx routing before requesting a cert.
Request the certificate
Run Certbot in a one-off container:
docker run --rm \
-v "$PWD/certbot/www:/var/www/certbot" \
-v "$PWD/certbot/conf:/etc/letsencrypt" \
certbot/certbot:v2.11.0 certonly \
--webroot \
--webroot-path /var/www/certbot \
-d example.com -d www.example.com \
--email you@example.com \
--agree-tos \
--no-eff-email
What this does:
certonlyobtains certs without trying to auto-edit Nginx.--webrootuses the HTTP-01 challenge.- Certificates are placed under:
/etc/letsencrypt/live/example.com/fullchain.pem/etc/letsencrypt/live/example.com/privkey.pem
On the host, they will appear in certbot/conf/live/example.com/.
List them:
ls -la certbot/conf/live/example.com/
If Certbot fails, jump to the Debugging Guide.
Step 6: Enable HTTPS in Nginx (TLS + Redirects)
Now that certs exist, update Nginx config to:
- Serve the ACME challenge on port 80
- Redirect all other HTTP traffic to HTTPS
- Terminate TLS on 443 and proxy to the app
Replace nginx/conf.d/app.conf with:
# HTTP server: only for ACME + redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Reasonable baseline TLS settings (we'll harden later)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://myapp:80;
proxy_http_version 1.1;
# WebSocket support (safe even if you don't use it)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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 https;
}
}
Reload Nginx
Because the Nginx container mounted config as read-only, you can reload without recreating:
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reload
If nginx -t fails, read the error carefully—it will often point to a missing file path or syntax issue.
Step 7: Validate the Setup
Check HTTPS response
curl -I https://example.com/
Confirm HTTP redirects to HTTPS
curl -I http://example.com/
You should see 301 with a Location: https://....
Inspect certificate details
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -issuer -subject -dates
Confirm the full chain is served
Browsers require intermediates. Using fullchain.pem in ssl_certificate ensures Nginx serves the server cert + intermediate chain.
Step 8: Set Up Automatic Renewal
Let’s Encrypt certs expire roughly every 90 days. You should renew automatically and reload Nginx after renewal.
Test renewal (dry run)
docker run --rm \
-v "$PWD/certbot/www:/var/www/certbot" \
-v "$PWD/certbot/conf:/etc/letsencrypt" \
certbot/certbot:v2.11.0 renew \
--webroot -w /var/www/certbot \
--dry-run
If this fails, fix it now—renewal failures are common when people later add redirects or change routing and accidentally break the /.well-known/acme-challenge/ path.
Automate with cron (simple and effective)
Create a script renew.sh:
cat > renew.sh <<'EOF'
#!/usr/bin/env sh
set -eu
docker run --rm \
-v "$(pwd)/certbot/www:/var/www/certbot" \
-v "$(pwd)/certbot/conf:/etc/letsencrypt" \
certbot/certbot:v2.11.0 renew \
--webroot -w /var/www/certbot \
--quiet
# Reload nginx to pick up renewed certs
docker exec nginx-proxy nginx -s reload
EOF
chmod +x renew.sh
Run it manually once:
./renew.sh
Add to root’s crontab (runs daily at 03:17):
sudo crontab -e
Add:
17 3 * * * /bin/sh -lc 'cd /path/to/your/project && ./renew.sh >> renew.log 2>&1'
Why reload is needed:
- Nginx reads certificates at startup/reload. If Certbot renews the files but Nginx never reloads, it may continue serving the old cert until restarted.
Step 9: Harden TLS and Security Headers
The baseline config works, but production setups should add:
- Modern TLS versions/ciphers
- HSTS (careful: can lock you into HTTPS)
- Basic security headers
Update the HTTPS server block:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# TLS hardening (modern baseline)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# OCSP stapling (requires resolver)
resolver 1.1.1.1 1.0.0.1 valid=300s;
resolver_timeout 5s;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer-when-downgrade always;
# HSTS: enable only after confirming HTTPS works reliably
# Start with a small max-age, then increase.
add_header Strict-Transport-Security "max-age=86400" always;
location / {
proxy_pass http://myapp:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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 https;
}
}
Reload:
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reload
HSTS warning (important)
HSTS tells browsers: “Always use HTTPS for this domain.” If you misconfigure TLS later, users can get stuck unable to access the site until you fix it. That’s why you should:
- Verify HTTPS works for a while.
- Use a short
max-agefirst (like 1 day). - Increase gradually (e.g., 1 week → 1 month → 1 year).
- Only then consider
includeSubDomainsandpreload.
Debugging Guide (Most Common Failures)
This section is where most real-world time is spent. Use it to quickly isolate the layer that’s failing: DNS → network ports → Nginx routing → Certbot → application upstream.
1) DNS Not Pointing to Your Server
Symptoms:
- Certbot error:
NXDOMAINor “No valid IP addresses found” - You can’t reach
http://example.comat all
Checks:
dig +short example.com A
dig +short www.example.com A
Compare with your server’s public IP:
curl -sS ifconfig.me
If they differ, fix DNS records and wait for propagation.
Also check if you have an AAAA record pointing elsewhere; some clients will prefer IPv6:
dig +short example.com AAAA
If your server doesn’t support IPv6, remove the AAAA record or configure IPv6 properly.
2) Ports 80/443 Not Reachable
Symptoms:
- Timeouts when curling from outside
- Certbot fails with “Timeout during connect”
From your local machine (not the server), test:
curl -I http://example.com --max-time 10
curl -I https://example.com --max-time 10
On the server, confirm Nginx is listening:
sudo ss -lntp | grep -E ':80|:443'
Confirm Docker published ports:
docker ps --format 'table {{.Names}}\t{{.Ports}}'
If ports are not open:
- Fix UFW/security groups.
- Ensure no other service is bound to 80/443 (Apache, another Nginx, etc.).
3) ACME Challenge Fails (404/403)
Symptoms:
- Certbot:
Invalid response from http://example.com/.well-known/acme-challenge/...: 404
Root causes:
- Nginx proxies the challenge path to your app instead of serving from disk.
- Wrong
rootor volume mount. - Redirects interfering (e.g., redirecting challenge to HTTPS before cert exists).
How to debug:
-
Create a known file:
mkdir -p certbot/www/.well-known/acme-challenge echo "challenge-ok" > certbot/www/.well-known/acme-challenge/ping.txt -
Fetch it:
curl -i http://example.com/.well-known/acme-challenge/ping.txt -
If it’s not
200 OK, check Nginx config inside container:docker exec nginx-proxy nginx -T | sed -n '1,200p' -
Verify the file exists in the container:
docker exec nginx-proxy ls -la /var/www/certbot/.well-known/acme-challenge/
If you see the file on the host but not in the container, your volume mount is wrong.
4) Nginx Container Can’t Reach App Container
Symptoms:
- Nginx returns
502 Bad Gateway - Nginx error log shows
connect() failed (111: Connection refused)orhost not found in upstream
Checks:
-
Ensure both containers are on the same network:
docker inspect nginx-proxy --format '{{json .NetworkSettings.Networks}}' | jq docker inspect myapp --format '{{json .NetworkSettings.Networks}}' | jq
If you don’t have jq, omit it.
-
From inside Nginx, curl the upstream:
docker exec -it nginx-proxy sh -lc "apk add --no-cache curl >/dev/null 2>&1 || true; curl -I http://myapp:80" -
Check app container logs:
docker logs -n 100 myapp
Common fixes:
- Attach the app to the
webnetwork:docker network connect web myapp - Make sure your app is listening on the expected port and interface (inside container it must listen on
0.0.0.0, not127.0.0.1).
5) Redirect Loops (Too Many Redirects)
Symptoms:
- Browser says “too many redirects”
curl -Ishows repeated 301/302
Typical cause:
- Your app is also forcing HTTPS based on
X-Forwarded-Proto, but Nginx isn’t setting it correctly. - Or you have conflicting redirects in both Nginx and the app.
Check headers:
curl -I https://example.com/
In Nginx, ensure:
proxy_set_header X-Forwarded-Proto https;
In the HTTP server block, ensure you’re not redirecting the ACME challenge path.
If your app framework has a “trust proxy” setting (Express, Django, Rails, etc.), enable it appropriately so it respects X-Forwarded-* headers.
6) Mixed Content / WebSocket Issues
Symptoms:
- Site loads but browser console shows mixed content (HTTP assets on HTTPS page)
- WebSockets fail with 400/426 or disconnects
Fix mixed content:
- Ensure your app generates HTTPS URLs when behind proxy.
- Many frameworks need configuration:
- Base URL set to
https://example.com - Trust proxy enabled
- Correct
X-Forwarded-Proto
- Base URL set to
WebSockets:
Ensure Nginx includes:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Also ensure your app supports WS behind a proxy and isn’t enforcing origin incorrectly.
7) Renewal Fails
Symptoms:
certbot renewfails after months of working- Often happens after config changes
Run:
docker run --rm \
-v "$PWD/certbot/www:/var/www/certbot" \
-v "$PWD/certbot/conf:/etc/letsencrypt" \
certbot/certbot:v2.11.0 renew \
--webroot -w /var/www/certbot -v
Look for:
- Which domain is failing
- The exact URL Let’s Encrypt tried
- Whether it got 404/timeout/redirect
Most common renewal break:
- Someone changed the port 80 server block to always redirect and accidentally removed the ACME location.
- Someone removed port 80 exposure entirely. (You can do DNS-01 challenges instead, but that’s a different setup.)
Operational Tips
Keep Nginx config readable
As you add apps, create separate files like:
nginx/conf.d/app1.confnginx/conf.d/app2.conf
Avoid a single giant config.
Use nginx -T to see the effective config
Inside the container:
docker exec nginx-proxy nginx -T | less
This prints all included files and helps catch “wrong file mounted” issues.
Monitor certificate expiration
Quick check:
openssl x509 -in certbot/conf/live/example.com/fullchain.pem -noout -enddate
Consider rate limits
Let’s Encrypt has rate limits. If you repeatedly request new certs while debugging, you can hit limits. Use the staging environment for testing:
docker run --rm \
-v "$PWD/certbot/www:/var/www/certbot" \
-v "$PWD/certbot/conf:/etc/letsencrypt" \
certbot/certbot:v2.11.0 certonly \
--webroot -w /var/www/certbot \
-d example.com \
--email you@example.com --agree-tos --no-eff-email \
--staging
Staging certs are not trusted by browsers, but they’re perfect for validating the challenge flow without burning production issuance attempts.
Cleanup / Uninstall
Stop and remove containers:
docker rm -f nginx-proxy myapp 2>/dev/null || true
Remove volumes/directories (this deletes certs):
rm -rf nginx certbot renew.sh renew.log
Remove network:
docker network rm web
Where to Go Next
- Add multiple apps with different
server_namevalues and upstreams. - Add gzip/brotli, caching headers, and request size limits.
- Switch to DNS-01 challenges if you can’t expose port 80 (common behind certain firewalls/CDNs).
- Add access logs and structured logging for observability.
If you share your domain pattern (single domain vs many subdomains), whether you need WebSockets, and what app framework you’re using, I can provide a tailored Nginx config and a renewal strategy that matches your deployment style.