Nginx: Reverse Proxy and Load Balancing
Why this matters
Every deployment module so far (10, 11) has pointed traffic straight at
one container. Real production setups almost never do that: they put a
reverse proxy in front — something that receives the actual internet
traffic and forwards it to one or more backend containers. Nginx is the
default choice for this job, and you've already met it as a black box:
ingress-nginx in a Kubernetes cluster (a topic in a later track) is
just nginx, configured and run for you. This module opens that black box.
By the end you'll be able to write an nginx config from scratch to
terminate TLS, serve static files directly (never touching your app
process), and spread traffic across several backend containers.
Concepts
A reverse proxy sits between the internet and your app
A forward proxy sits in front of clients (e.g. a corporate proxy that all employees' browsers go through to reach the internet). A reverse proxy is the mirror image: it sits in front of servers, so from the outside the proxy looks like the whole application, and the real backend containers are never directly reachable.
client (browser)
│
▼
┌───────────────┐
│ nginx │ ← reverse proxy: the only thing the internet sees
└───────────────┘
│
▼
┌───────────────┐
│ app container │ ← never exposed directly
└───────────────┘
This buys you three things at once, all covered in this module: TLS termination (nginx handles HTTPS once, your app only ever speaks plain HTTP), load balancing (nginx picks one of several identical backend containers per request), and static-file offloading (nginx serves files straight off disk, never bothering your app process for them).
server and location blocks are nginx's routing unit
An nginx config file is built from nested blocks. A server block
defines one virtual host (what hostname/port it answers for); location
blocks inside it route by URL path:
server {
listen 80;
server_name example.com;
location /api/ {
proxy_pass http://backend;
}
location / {
root /usr/share/nginx/html;
}
}
Requests to /api/... get proxied to backend; everything else is
served as a static file from /usr/share/nginx/html. This is the same
"route by path to a different handler" idea as an API framework's
router, just one layer earlier in the request's journey.
proxy_pass forwards a request and needs its headers rebuilt
proxy_pass http://backend; forwards the request, but by default the
backend sees nginx's connection, not the original client's — it
doesn't automatically know the real client IP or which host/scheme the
client originally used. The standard fix is to forward the missing
information as headers:
location /api/ {
proxy_pass http://backend;
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;
}
Your app then reads X-Forwarded-For instead of the raw socket address
for "who is this request really from" — the same header
backend/01-request-response-fundamentals describes any app sitting
behind a proxy needing to trust (only from a proxy you control).
upstream names a pool of backends — that's load balancing
An upstream block groups multiple backend addresses under one name;
proxy_pass then targets the pool instead of a single container, and
nginx picks one member per request:
upstream backend {
server app1:8000;
server app2:8000;
server app3:8000;
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
Default behavior is round robin (requests cycle through the list in order). Two other built-in strategies matter:
least_conn;inside theupstreamblock — sends the next request to whichever backend currently has the fewest open connections, better than round robin when requests take uneven amounts of time.ip_hash;— routes the same client IP to the same backend consistently, useful when a backend keeps per-client state in memory (session affinity / "sticky sessions") rather than in a shared store.
round robin least_conn ip_hash
req1 → app1 req1 → app1(0 conns) client A → always app1
req2 → app2 req2 → app2(0 conns) client B → always app2
req3 → app3 req3 → app1(1, busy) client A → always app1
req4 → app1 req4 → app3(0 conns) client C → always app3
(strict rotation) (fewest busy wins) (same client, same backend)
See also: Load balancing concepts for L4 vs L7, health checks and session persistence independent of nginx.
TLS termination: HTTPS ends at nginx, HTTP continues inward
"Terminating" TLS means nginx is the one holding the certificate and decrypting the HTTPS connection; traffic from nginx to your backend containers travels as plain HTTP over the private Docker network, which is fine because that traffic never leaves the host's internal networking (module 05's bridge network, not the public internet).
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://backend;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
The second server block is the standard HTTP → HTTPS redirect:
anything arriving on port 80 gets a 301 sending the client to the same
path over HTTPS instead.
Static files never need to reach your app process at all
backend/01-request-response-fundamentals already described the
request path as client → reverse proxy → app server. For static assets
(images, CSS, a built frontend bundle), nginx can skip the second hop
entirely and serve the file straight from disk with root/try_files:
location /static/ {
root /usr/share/nginx/html;
try_files $uri =404;
}
This is faster (no process hand-off, no application framework overhead per file) and reduces load on your app containers, which is why static assets are one of the first things offloaded to nginx in a real deployment.
nginx -t validates config before you reload it
Nginx will refuse to (re)start on a broken config, but you don't have to
wait for that to find out — nginx -t checks syntax and reports the
first error with a file and line number, the same "catch it before it's
live" habit as docker build's layer-by-layer failures.
Command reference
| Command | What it does | Example |
|---|---|---|
nginx -t |
Tests a config file for syntax errors without starting/reloading | docker exec proxy nginx -t |
nginx -s reload |
Reloads config without dropping existing connections | docker exec proxy nginx -s reload |
docker exec <container> nginx -t |
Runs the config test inside a running nginx container | docker exec proxy nginx -t |
docker compose up -d --scale <service>=<N> |
Starts N replicas of one Compose service (for load-balancing exercises) | docker compose up -d --scale app=3 |
curl -I <url> |
Fetches only response headers — useful for confirming redirects/proxy headers | curl -I http://localhost:8080 |
curl -k <url> |
Fetches over HTTPS while skipping certificate validation (for self-signed certs) | curl -k https://localhost:8443 |
Flag breakdown for docker compose up -d --scale app=3:
--scale app=3— starts 3 containers for theappservice instead of the 1 implied bycompose.yaml, so nginx'supstreampool actually has multiple real backends to balance across.
Flag breakdown for curl -k https://localhost:8443:
-k— skips certificate hostname/trust validation, needed because the exercises below use a self-signed certificate that no real certificate authority has vouched for.
Hands-on exercises
(WSL2 Ubuntu terminal) Build a tiny backend app to proxy to, reusing the pattern from earlier modules:
mkdir -p ~/learn-docker/nginx-lab/app && cd ~/learn-docker/nginx-lab/app cat > app.py <<'EOF' import os from flask import Flask app = Flask(__name__) @app.get("/api/hello") def hello(): return {"message": "hello from the backend", "pid": os.getpid()} if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) EOF cat > requirements.txt <<'EOF' flask==3.0.3 EOF cat > Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"] EOF docker build -t nginxlab-app .(WSL2 Ubuntu terminal) Write an nginx config that reverse-proxies to that app, and a static
index.htmlit can serve directly:cd ~/learn-docker/nginx-lab mkdir -p static cat > static/index.html <<'EOF' <h1>Served directly by nginx — never touched the Flask app</h1> EOF cat > nginx.conf <<'EOF' server { listen 80; location /api/ { proxy_pass http://app:8000/api/; 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; } location / { root /usr/share/nginx/html; try_files $uri =404; } } EOF cat > compose.yaml <<'EOF' services: app: image: nginxlab-app proxy: image: nginx:1.27 ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./static:/usr/share/nginx/html:ro depends_on: - app EOF docker compose up -d(WSL2 Ubuntu terminal) Confirm both paths work — the proxied API and the directly-served static file:
curl http://localhost:8080/ curl http://localhost:8080/api/helloExpect the static HTML from the first
curl, and the Flask JSON response (with apid) from the second — proving nginx served one itself and forwarded the other.(WSL2 Ubuntu terminal) Validate config before making it live — deliberately break it first:
cp nginx.conf nginx.conf.bak sed -i 's/listen 80;/listen 80/' nginx.conf # removes the semicolon docker compose cp nginx.conf proxy:/etc/nginx/conf.d/default.conf docker exec $(docker compose ps -q proxy) nginx -tExpect
nginx -tto fail and print the exact line number of the missing semicolon. Restore the good config:cp nginx.conf.bak nginx.conf docker compose cp nginx.conf proxy:/etc/nginx/conf.d/default.conf docker exec $(docker compose ps -q proxy) nginx -t docker exec $(docker compose ps -q proxy) nginx -s reloadExpect
nginx -tto pass this time (syntax is ok/test is successful), and the reload to apply without dropping the container.(WSL2 Ubuntu terminal) Turn on load balancing — scale the backend and switch the config from a single target to an
upstreampool:cat > nginx.conf <<'EOF' upstream backend { server app:8000; } server { listen 80; location /api/ { proxy_pass http://backend/api/; 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; } location / { root /usr/share/nginx/html; try_files $uri =404; } } EOF docker compose up -d --scale app=3 --no-recreateCompose's default networking means
appnow resolves to multiple containers, but a plainupstream backend { server app:8000; }with one line only balances if you list each replica explicitly, since Compose doesn't auto-populateupstreamblocks. List all three by their Compose-assigned hostnames:docker compose ps --format '{{.Name}}'Note the three container names (e.g.
nginx-lab-app-1,nginx-lab-app-2,nginx-lab-app-3), then update theupstreamblock to list all three explicitly:cat > nginx.conf <<'EOF' upstream backend { server nginx-lab-app-1:8000; server nginx-lab-app-2:8000; server nginx-lab-app-3:8000; } server { listen 80; location /api/ { proxy_pass http://backend/api/; proxy_set_header Host $host; } location / { root /usr/share/nginx/html; try_files $uri =404; } } EOF docker compose cp nginx.conf proxy:/etc/nginx/conf.d/default.conf docker exec $(docker compose ps -q proxy) nginx -s reload(Substitute your actual container names from the
psoutput above if they differ from the example names.)(WSL2 Ubuntu terminal) Prove round-robin balancing by watching the
pidfield rotate across repeated requests:for i in 1 2 3 4 5 6; do curl -s http://localhost:8080/api/hello; echo; doneExpect the
pidvalue to cycle through three distinct numbers in order, repeating — one per backend container, confirming requests are actually being spread across all three rather than always hitting one.(WSL2 Ubuntu terminal) Add TLS termination with a self-signed certificate:
mkdir -p certs openssl req -x509 -nodes -newkey rsa:2048 \ -keyout certs/privkey.pem -out certs/fullchain.pem \ -days 365 -subj "/CN=localhost" cat >> nginx.conf <<'EOF' server { listen 443 ssl; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; location /api/ { proxy_pass http://backend/api/; proxy_set_header Host $host; } location / { root /usr/share/nginx/html; try_files $uri =404; } } EOFUpdate
compose.yamlto publish 443 and mount the certs directory, then recreate:sed -i 's|"8080:80"|"8080:80"\n - "8443:443"|' compose.yaml sed -i 's|./static:/usr/share/nginx/html:ro|./static:/usr/share/nginx/html:ro\n - ./certs:/etc/nginx/certs:ro|' compose.yaml docker compose up -d --force-recreate proxy curl -k https://localhost:8443/api/helloExpect the same JSON response, now served over HTTPS;
-kis required because the certificate is self-signed, not issued by a trusted authority.(WSL2 Ubuntu terminal) Clean up:
docker compose down docker rmi nginxlab-app cd ~ && rm -rf ~/learn-docker/nginx-lab
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Build a Compose stack with two different backend
applications (not replicas of the same one) — say one that returns JSON
and one that returns plain text — and write an nginx config that routes
/service-a/ to the first and /service-b/ to the second, each behind
its own upstream pool with at least two replicas. Add the HTTP → HTTPS
redirect from the Concepts section so port 80 always forwards to 443. Prove
the routing is correct (each path reaches the right service), prove each
pool actually load-balances (vary a response field to show which replica
answered, the same way the pid field did in this module), and prove the
redirect works (curl -I on port 80 should show a 301 to the https://
URL).
Stuck? One hint
Two location blocks, each proxy_passing to its own upstream block;
list every replica's Compose-assigned container name inside the matching
upstream; the redirect is the same bare server { listen 80; return 301 https://$host$request_uri; } block shown in the Concepts section,
just with no location blocks of its own.
Common mistakes & troubleshooting
- Forgetting
proxy_set_header Host $host;. Without it, your backend app sees nginx's own request, and anything the app does with theHostheader (redirects, generated links) breaks or points at the wrong hostname. - Assuming Compose scaling auto-populates
upstreamblocks.docker compose up --scale app=3creates three containers, but a hand-writtenupstream backend { server app:8000; }still only names one hostname unless you either list every replica explicitly (as in exercise 5) or use Compose's internal DNS round robin (a singleserver app:8000;entry does get load-balanced by Docker's embedded DNS across scaled replicas at the network level in newer Compose versions — but relying on that instead of nginx'supstreamgives you none of nginx's balancing-method choices likeleast_conn). - Editing
nginx.confand expecting the change to apply immediately. Nginx reads its config once at startup/reload; you must eitherdocker exec ... nginx -s reload(or recreate the container) for a config change to take effect — the same "config changes need a restart or reload" rule as most long-running server processes. - Skipping
nginx -tbefore a reload. A syntax error in a reload attempt can leave nginx running the old config while logging an error, which looks like "nothing happened" rather than an obvious failure — always test first. - Confusing TLS termination with end-to-end encryption. Terminating TLS at nginx means traffic inside your Docker network (nginx → backend) is plain HTTP. That's normal and fine for traffic that never leaves the host, but it is not the same guarantee as encryption all the way to the application process — don't describe it as "the whole path is encrypted" in a design discussion.
- Only testing with
curlfrom inside the same container/network. A config can look correct fromdocker execbut still fail for real external clients if the wrong port is published or the container's network isn't attached the way you assumed — always test from outside (a realcurlagainstlocalhost:<published-port>, as this module's exercises do) rather than only from inside the proxy container.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What's the difference between a forward proxy and a reverse proxy?
- Why do you need
proxy_set_header X-Forwarded-For ...;if you want your backend app to know the real client IP address? - What does an
upstreamblock let you do that a singleproxy_pass http://one-host:port;doesn't? - When would you choose
ip_hashover the default round-robin balancing method, and what's the tradeoff? - What does "TLS termination" actually mean, and what protocol carries traffic onward from nginx to your backend container?
- Why does serving a static file directly through nginx's
root/try_filesoutperform proxying that same request to your app server? - What's the point of running
nginx -tbeforenginx -s reload?
Show answers
- A forward proxy sits in front of clients, forwarding their outbound requests to the wider internet (e.g. a corporate proxy). A reverse proxy sits in front of servers, so the outside world only ever talks to the proxy and never reaches the real backend directly.
- Without it, the backend only sees the TCP connection from nginx
itself, so
remote_addr-style lookups on the backend would report nginx's own container IP for every request.X-Forwarded-For(along withX-Real-IP) carries the original client's address forward as an explicit header the backend can read instead. - It lets you name a pool of two or more backend addresses under one
name and have nginx distribute requests across all of them
(round-robin by default, or
least_conn/ip_hash), instead of every request always going to the exact same single host. ip_hashwhen a backend keeps per-client state in its own memory (session affinity) rather than a shared store, so the same client must keep landing on the same backend. The tradeoff is uneven load if client IPs aren't evenly distributed, and it breaks if a client's apparent IP changes mid-session (e.g. behind a mobile carrier's NAT).- TLS termination means nginx is the endpoint that holds the certificate and decrypts the HTTPS connection; from nginx onward to the backend container, traffic normally continues as plain HTTP over the internal Docker network.
- Static-file serving lets nginx read straight from disk and send bytes back, with no process hand-off to an application framework and no application-level request handling overhead for that file — cheaper per request and it keeps that load off your app containers entirely.
nginx -tvalidates the config's syntax and reports the exact file and line of any error before you apply it, so a broken config is caught immediately instead of silently failing to take effect (or crashing nginx) during a reload.
Further reading & sources
- Nginx: Reverse proxy guide - the official walkthrough this module's
proxy_pass/header setup is based on. - Nginx: Load balancing - round robin,
least_conn, andip_hashcovered in more depth, straight from the source. - Nginx: Configuring HTTPS servers - TLS termination configuration reference, including certificate/key directives used in this module.
- Mozilla: proxy headers (X-Forwarded-For, X-Forwarded-Proto) - what each forwarded header means and how a backend should treat it.
- Docker Hub: official nginx image - the image used throughout this module's exercises, including its default config file locations.
Next
Continue to 13-capstone-project to combine everything from this track — a multi-stage build, Compose, ACR, nginx in front of it, and a real Azure deployment — into one project you design yourself.