Open this post in EchoOS →

Chapter 1.3 - Nginx in Docker: Static Sites, Reverse Proxy and HTTPS

Nginx is usually the first thing a request meets: it serves static files, forwards everything else to your application, and handles TLS so your app doesn’t have to. It’s also a very good first “real” container, because it shows the pattern almost every service follows: the image stays stock, and everything specific to you (config, content, certificates) is mounted in from the host.

We’ll build up in three steps: a static site, a reverse proxy in front of the app from Chapter 1.1, then HTTPS.

Step 0: stock Nginx

docker run -d --name web -p 8080:80 nginx:1.27
curl -I http://localhost:8080

HTTP/1.1 200 OK and the Nginx welcome page. Pin a version tag (nginx:1.27) rather than latest, so a routine pull doesn’t upgrade your web server under you.

Two things the official image does that you’ll rely on:

docker rm -f web when you’re done looking.

Step 1: serving a static site

Put your site in a directory, for example ~/site/index.html, and mount it over Nginx’s default web root:

docker run -d --name web \
  -p 8080:80 \
  -v ~/site:/usr/share/nginx/html:ro \
  nginx:1.27

Step 2: a reverse proxy in front of an app

Now the more common setup: Nginx in front, the application behind it, never exposed directly.

flowchart LR
  u([Browser]) -- ":80 / :443" --> n[nginx container]
  subgraph net["Docker network: web"]
    n -- "http://app:3000" --> a[app container<br/>hello-docker]
  end

Create a network and start the app on it, without publishing its port:

docker network create web
docker run -d --name app --network web hello-docker:1.0

On a user-defined network, containers reach each other by name through Docker’s built-in DNS: from the Nginx container, app resolves to the app container’s IP. Because the app publishes no port, the only way in is through Nginx. (Networks get a full chapter in 1.8.)

Write the proxy config to ~/nginx/conf.d/default.conf:

server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://app:3000;

        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;
    }
}

What each part does:

Run Nginx on the same network with that directory mounted over conf.d:

docker run -d --name proxy \
  --network web \
  -p 8080:80 \
  -v ~/nginx/conf.d:/etc/nginx/conf.d:ro \
  nginx:1.27

curl http://localhost:8080
# Hello from 9b1e4c3a0f12   (the app container's hostname)

Step 3: HTTPS with Let’s Encrypt

For a public site you need a domain pointing at your server and a certificate. Let’s Encrypt issues free certificates through certbot, which proves you control the domain by placing a file that Let’s Encrypt then fetches over plain HTTP at http://your-domain/.well-known/acme-challenge/....

The layout keeps everything on the host and mounts it into the container read-only:

flowchart TD
  u([Browser]) -- "http :80<br/>https :443" --> n
  le([Let's Encrypt]) -- "http :80<br/>/.well-known/acme-challenge" --> n
  subgraph C["nginx container"]
    n[nginx]
  end
  subgraph H["Host (mounted :ro)"]
    conf["~/nginx/conf.d"]
    acme["/srv/certbot<br/>challenge files"]
    certs["/etc/letsencrypt<br/>certificates, renewed by certbot"]
    conf ~~~ acme ~~~ certs
  end
  conf -. "/etc/nginx/conf.d" .-> n
  acme -. "/var/www/certbot" .-> n
  certs -. "/etc/letsencrypt" .-> n
  n -- "http://app:3000" --> a[app container]

3a. Serve the challenge directory over HTTP

Replace default.conf with a port-80 block that answers challenges and redirects everything else to HTTPS:

server {
    listen 80;
    server_name example.com www.example.com;

    # Let's Encrypt fetches proof-of-control files from here
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Everything else moves to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

Start Nginx with the challenge directory mounted:

sudo mkdir -p /srv/certbot
docker rm -f proxy
docker run -d --name proxy \
  --network web \
  --restart unless-stopped \
  -p 80:80 -p 443:443 \
  -v ~/nginx/conf.d:/etc/nginx/conf.d:ro \
  -v /srv/certbot:/var/www/certbot:ro \
  -v /etc/letsencrypt:/etc/letsencrypt:ro \
  nginx:1.27

Mount the whole /etc/letsencrypt directory, not just live/: the files in live/ are symlinks into archive/, and they’d dangle inside the container without it.

3b. Get the certificate

Install certbot on the host (sudo apt install certbot), then:

sudo certbot certonly --webroot -w /srv/certbot \
  -d example.com -d www.example.com \
  --email you@example.com --agree-tos

The certificate and key land in /etc/letsencrypt/live/example.com/, which the container can already see.

3c. Add the HTTPS server

Append a second block to default.conf:

server {
    listen 443 ssl;
    http2 on;
    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;
    ssl_protocols       TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://app:3000;
        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;
    }
}

Test the config, then reload it without dropping connections:

docker exec proxy nginx -t          # syntax check; fix anything it reports first
docker exec proxy nginx -s reload   # apply without restarting the container

3d. Renewal

Let’s Encrypt certificates last 90 days. The certbot package installs a timer that renews them automatically; Nginx only needs to reload afterwards to pick up the new files. Certbot runs every executable script in its deploy-hooks directory after a successful renewal, so add one:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
docker exec proxy nginx -s reload
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

sudo certbot renew --dry-run

Day-to-day commands

docker logs -f proxy                 # access and error log, live
docker exec proxy nginx -t           # validate config after editing
docker exec proxy nginx -s reload    # apply it
docker exec -it proxy sh             # look around inside (Debian-based image: bash works too)

Because the config, content and certificates all live on the host, upgrading Nginx is just a matter of pulling the new tag and recreating the container with the same docker run line. Nothing inside the old container needs saving. Typing those long docker run commands by hand gets old quickly, though; Chapter 1.9 moves this whole setup into a Compose file.

Chapter 1.4 runs a service with more moving parts: Jenkins, building and deploying projects inside containers of its own.