Recipe: NGINX web server & reverse proxy
Serve static, proxy an app, terminate TLS.
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.
# bake config + static site into an immutable image for productionFROM nginxinc/nginx-unprivileged:1.27-alpine # runs as uid 101, listens on 8080COPY default.conf /etc/nginx/conf.d/default.confCOPY 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.
server { # plain HTTP: push everyone to HTTPSlisten 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 diskroot /usr/share/nginx/html;try_files $uri $uri/ /index.html;}location /api/ { # dynamic requests: private backendproxy_pass http://api/; # trailing slash strips the /api/ prefixproxy_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
services:web:image: nginxinc/nginx-unprivileged:1.27-alpineports:- "80:8080"- "443:8443"volumes:- ./default.conf:/etc/nginx/conf.d/default.conf:ro- ./site:/usr/share/nginx/html:ro- ./certs:/etc/nginx/certs:rodepends_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.
$ 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
NAME IMAGE STATUS PORTSapi traefik/whoami Up 5 seconds 80/tcpweb nginxinc/nginx-unprivileged:1.27-alpine Up 5 seconds 0.0.0.0:80->8080/tcp, 0.0.0.0:443->8443/tcp
$ curl -skI http://localhost/ | grep -i location$ curl -sk https://localhost/ | head -3$ curl -sk https://localhost/api/
Location: https://localhost/<!doctype html><html lang="en"><head><title>Recipe app</title></head>Hostname: 6b1f3a9c2d7eIP: 127.0.0.1IP: 172.19.0.3RemoteAddr: 172.19.0.2:41522GET / HTTP/1.0Host: localhostUser-Agent: curl/8.9.1Accept: */*X-Forwarded-For: 172.19.0.1X-Forwarded-Proto: httpsX-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.
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.
$ 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/healthz200$ docker exec edge nginx -tnginx: 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.
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.