CoursesDocker in depthRecipe: NGINX web server & reverse proxy

Recipe: NGINX web server & reverse proxy

Serve static, proxy an app, terminate TLS.

Intermediate14 min · lesson 24 of 30

Every office building has a front desk. Visitors talk to the receptionist, never straight to the person they came to see. The desk works out which room handles you, carries the message over, and brings the answer back. NGINX (say it 'engine-x') is that front desk for your web application. It hands static files like HTML pages and images straight to the browser, forwards the requests that need real work to a backend program, and speaks HTTPS so your app never has to. That middle job has a name that sounds fancier than the idea behind it: reverse proxy. Your app is the person in the back room, and it never meets the public. One public entrypoint, one place to put TLS (Transport Layer Security, the thing that turns plain HTTP into encrypted HTTPS), and a backend that stays private. All three jobs are configuration rather than code, so the recipe comes down to this: get the right config file into the official image, mount it read-only, and run the container as an ordinary user instead of root.

One front door, three outcomes
NGINX
the only public ports: 80 and 443
port 80
301 redirect to HTTPS
no plaintext gets through
GET /
static file off disk
served from /usr/share/nginx/html
GET /api/
proxy_pass to api:80
private backend, no host port
One config file, one public entrypoint. Every request gets one of three answers: bounce it to HTTPS, hand back a file, or pass it to a backend the outside world can never reach directly.
Dockerfile
# bake config + static site into an immutable image for production
FROM nginxinc/nginx-unprivileged:1.27-alpine # runs as uid 101, listens on 8080
COPY default.conf /etc/nginx/conf.d/default.conf
COPY site/ /usr/share/nginx/html/

There are two ways to get that config into the container, and you will end up using both. The Dockerfile above bakes the config and your static files into the image. That is what you want for a production deploy, because the image then is the whole artifact and there is nothing to mount beside it. For local work, mount the files read-only instead, which is what the Compose file below does. Mounting read-only buys you a real security property as well: a container that gets compromised while running cannot rewrite its own config. The image here is nginxinc/nginx-unprivileged, a variant that runs as user id 101 and listens on 8080 rather than grabbing port 80, which on Linux only root is allowed to bind.

default.conf
server { # plain HTTP: push everyone to HTTPS
listen 8080;
return 301 https://$host$request_uri;
}
server {
listen 8443 ssl;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / { # static files straight off disk
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ { # dynamic requests: private backend
proxy_pass http://api/; # trailing slash strips the /api/ prefix
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;
}
}

Read that file from the top. The first server block does exactly one thing: any request that arrives unencrypted gets sent back a 301 (the HTTP status code meaning 'moved permanently') pointing at the HTTPS address. The second block is where the work happens. location / serves files straight off disk, and try_files falls back to index.html so a single-page app (a site where the browser redraws the page itself instead of fetching a new one per link) does not get 404s on its own routes. Slow down at location /api/. proxy_pass forwards anything under /api/ to the container reachable as 'api', and notice you never typed an IP (Internet Protocol) address anywhere. Docker runs a small DNS (Domain Name System) resolver on the user network, so the name 'api' turns into whatever address that container happens to hold right now. The trailing slash on proxy_pass http://api/ strips the /api/ prefix, so a request for /api/time lands on the backend as plain /time. Those four proxy_set_header lines earn their space. Without them the backend sees every caller as NGINX, loses the real client address, and has no idea whether the original request was encrypted. X-Forwarded-For carries the client's IP. X-Forwarded-Proto tells the app the edge was HTTPS even though this last hop inside Docker is plain HTTP.

Stand it up and prove it works

compose.yaml
services:
web:
image: nginxinc/nginx-unprivileged:1.27-alpine
ports:
- "80:8080"
- "443:8443"
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
- ./site:/usr/share/nginx/html:ro
- ./certs:/etc/nginx/certs:ro
depends_on: [api]
api:
image: traefik/whoami # tiny backend on :80; no ports, so it stays private

Here is a stack you can run as-is. The web service is NGINX with your config, your static site and your certificates all mounted read-only. The backend is traefik/whoami, a tiny image that answers on port 80 and echoes back whatever request it received, which makes it ideal for proving the proxy hop really happened. Now look at what the api service does not have: a ports entry. No host mapping at all, so the only road to it from your laptop runs through NGINX.

terminal
$ mkdir -p certs site
$ openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout certs/privkey.pem -out certs/fullchain.pem -subj "/CN=localhost"
$ printf '<!doctype html>\n<html lang="en">\n<head><title>Recipe app</title></head>\n' > site/index.html
$ docker compose up -d
$ docker compose ps
output
NAME IMAGE STATUS PORTS
api traefik/whoami Up 5 seconds 80/tcp
web nginxinc/nginx-unprivileged:1.27-alpine Up 5 seconds 0.0.0.0:80->8080/tcp, 0.0.0.0:443->8443/tcp
terminal
$ curl -skI http://localhost/ | grep -i location
$ curl -sk https://localhost/ | head -3
$ curl -sk https://localhost/api/
output
Location: https://localhost/
<!doctype html>
<html lang="en">
<head><title>Recipe app</title></head>
Hostname: 6b1f3a9c2d7e
IP: 127.0.0.1
IP: 172.19.0.3
RemoteAddr: 172.19.0.2:41522
GET / HTTP/1.0
Host: localhost
User-Agent: curl/8.9.1
Accept: */*
X-Forwarded-For: 172.19.0.1
X-Forwarded-Proto: https
X-Real-Ip: 172.19.0.1

Three commands, three things proven. The first shows plain HTTP answering with a 301 to the HTTPS address, so nothing travels in the clear. The second pulls index.html off disk over TLS. The third is the one that matters: a request to /api/ comes back with whoami's reply even though whoami has no published port. Now read what whoami actually received. The path is /, because the trailing slash stripped the prefix. Host is still localhost, so the backend can tell which site was asked for. X-Forwarded-Proto is https, so the app knows the original request was encrypted even though NGINX spoke plain HTTP to it across the internal network. Your proxy_set_header lines did their job. And back in that ps output, whoami lists 80/tcp with no 0.0.0.0 mapping beside it, which is your proof that it is reachable only through the front door.

Terminating TLS at the edge

The certificate and its private key never belong inside the image. Bake them in and everyone who pulls that image gets your key, and it sits there in the layer history even after a later step appears to 'delete' it. Mount them read-only instead, or hand them in as a Docker secret, so no rebuild can capture them. Pin the protocol to modern TLS, versions 1.2 and 1.3, and drop the older ones. The app behind the proxy carries on speaking plain HTTP over the internal Docker network, which never leaves the host, so encryption gets terminated once at the edge and everything inside stays simple. For a real domain you would swap the self-signed certificate for one from Let's Encrypt. The config itself does not change.

NGINX pins the backend's IP at startup
Write proxy_pass http://api:80 the obvious way and NGINX looks 'api' up exactly once, at startup, then holds on to that address forever. Recreate the backend (a redeploy, a crash, a scale event) and it comes back with a different IP, but NGINX keeps posting letters to the old one, so every /api call answers 502 Bad Gateway, the proxy's way of saying it got no reply from the thing behind it. That lasts until you reload. The Docker-specific fix has three parts: add resolver 127.0.0.11 valid=10s; inside the server block (that address is Docker's embedded DNS), put the hostname into a variable, then use the variable in proxy_pass. NGINX re-resolves the name on a timer after that and follows the container wherever it lands.

Running this pattern for real

In practice an NGINX container does one of two jobs, or both at once: serving static assets, and reverse-proxying to app containers on a user-defined network. Terminate TLS at NGINX with mounted certificates, point proxy_pass at http://app:3000 or whatever port the app actually listens on, and leave the upstream unpublished. A slim base image and a read-only config both help. Take care when you rotate certificates: NGINX picks up new certificate files only on a reload, so make that a planned step rather than something you discover at 2am.

Terminating TLS in your own container versus handing that job to a cloud load balancer is a trade between portability and managed certificates. Do it yourself and the same stack runs on a laptop, a bare virtual machine, or any cloud. Hand it to the load balancer and someone else renews the certificate for you. Either way, if NGINX is already your edge, do not also make the app container a public TLS endpoint. And set the proxy headers properly, or your app will log NGINX's address as the client on every single request and you will lose the ability to trace anything back to a real user.

Be deliberate about what your healthcheck actually tests. Point it at a local URL that NGINX answers by itself and you are probing the proxy alone, so a dead backend still leaves the web container marked healthy. Include the upstream in the check and a failing backend marks the proxy unhealthy too, which is what you want if you would rather the orchestrator replace the task. Pick one on purpose and write down which one you picked.

When you roll this into a real environment, keep a short record: the image digest you replaced and the one you deployed, the exact default.conf that shipped with it, the host you ran the commands on, and how you would put the old config back if the new one misbehaves. For NGINX that rollback is usually two moves, restore the previous config file and reload, but it only works if somebody kept the previous file. Run nginx -t before every reload, and note the curl status codes you saw on a healthy system so the next person knows what normal looks like.

Try this

Run these on a lab engine (Docker 24 or newer is fine). Read the sample output first so you know what success looks like before you lean on the command in production.

terminal
$ docker run -d --name edge --network appnet -p 8080:80 -v $PWD/nginx.conf:/etc/nginx/nginx.conf:ro nginx:1.27-alpine
$ curl -s -o /dev/null -w '%{http_code}
' http://127.0.0.1:8080/healthz
200
$ docker exec edge nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# STATUS: READY — config ok; healthz 200

Make the edge a decision, not an accident

Treat the edge as something you chose. NGINX gets the host ports, the certificates arrive as a read-only mount, and the app containers stay off any public bind while the proxy owns the way in. Before you call it done, run nginx -t against the exact config that will ship, check that docker compose ps shows no 0.0.0.0 mapping next to your backend, and confirm your healthcheck fails for the reason you actually want it to fail.

Quick check
01Your api container crashes and Compose brings it back with a fresh IP address. Every /api request now answers 502 until you reload NGINX. What happened?
Correct — With a plain proxy_pass, the name gets resolved at boot and never again. Add a resolver line and put the hostname in a variable so NGINX looks it up on a timer.
Incorrect — No. Docker refreshes that record the moment the container comes back. The stale address is being held inside NGINX, not by Docker.
Incorrect — No. Service names work fine. The catch is that the name gets resolved a single time and then cached.
Incorrect — No. Publishing it would hand it to the whole internet. The proxy already reaches it by name on the internal network, which is the point of the pattern.
02In location /api/ { proxy_pass http://api/; }, what does that trailing slash on http://api/ do to an incoming request for /api/time?
Incorrect — No. The trailing slash removes the matched prefix rather than doubling it.
Incorrect — No. The slash affects the path, not the protocol. The internal hop here stays plain HTTP.
Incorrect — No. Load balancing is a separate feature. The slash rewrites the path handed to the backend.
Correct — With the trailing slash, NGINX swaps the matched /api/ for /, so the backend sees /time.
03docker compose ps shows the api (whoami) container as 80/tcp with no 0.0.0.0 mapping, yet curl https://localhost/api/ comes back with whoami's reply. How is the backend being reached?
Incorrect — No. depends_on controls start order and nothing else. Nothing mapped whoami to a host port.
Incorrect — No. With no host mapping there is no route from curl to whoami. That request travelled through NGINX.
Correct — The 80/tcp is whoami's own port on the private network, and NGINX is the single front door to it.
Incorrect — No. A bare 80/tcp with no 0.0.0.0-> mapping means the port is internal only and was never published to the host.

Takeaway

The trap worth remembering here: nGINX pins the backend's IP at startup. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related