← Back to Tutorials

Secure Docker Apps with Let’s Encrypt + Nginx: End-to-End HTTPS Setup & Debugging

dockerdocker composenginxlet's encrypthttpsreverse proxyssl/tlscertbotacmedevopsweb securitytroubleshooting

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

You will run three main pieces:

  1. Your application container (example: myapp) listening on an internal port (e.g., 3000).
  2. Nginx container listening on public ports 80 and 443 on the host, acting as a reverse proxy to your app.
  3. Certbot container to request and renew Let’s Encrypt certificates.

Key ideas:


Prerequisites

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:

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:

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:

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:

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:

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:

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:

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:


Step 9: Harden TLS and Security Headers

The baseline config works, but production setups should add:

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:

  1. Verify HTTPS works for a while.
  2. Use a short max-age first (like 1 day).
  3. Increase gradually (e.g., 1 week → 1 month → 1 year).
  4. Only then consider includeSubDomains and preload.

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:

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:

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:


3) ACME Challenge Fails (404/403)

Symptoms:

Root causes:

How to debug:

  1. Create a known file:

    mkdir -p certbot/www/.well-known/acme-challenge
    echo "challenge-ok" > certbot/www/.well-known/acme-challenge/ping.txt
  2. Fetch it:

    curl -i http://example.com/.well-known/acme-challenge/ping.txt
  3. If it’s not 200 OK, check Nginx config inside container:

    docker exec nginx-proxy nginx -T | sed -n '1,200p'
  4. 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:

Checks:

  1. 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.

  1. 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"
  2. Check app container logs:

    docker logs -n 100 myapp

Common fixes:


5) Redirect Loops (Too Many Redirects)

Symptoms:

Typical cause:

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:

Fix mixed content:

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:

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:

Most common renewal break:


Operational Tips

Keep Nginx config readable

As you add apps, create separate files like:

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

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.