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 the upstream block — 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 the app service instead of the 1 implied by compose.yaml, so nginx's upstream pool 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

  1. (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 .
    
  2. (WSL2 Ubuntu terminal) Write an nginx config that reverse-proxies to that app, and a static index.html it 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
    
  3. (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/hello
    

    Expect the static HTML from the first curl, and the Flask JSON response (with a pid) from the second — proving nginx served one itself and forwarded the other.

  4. (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 -t
    

    Expect nginx -t to 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 reload
    

    Expect nginx -t to pass this time (syntax is ok / test is successful), and the reload to apply without dropping the container.

  5. (WSL2 Ubuntu terminal) Turn on load balancing — scale the backend and switch the config from a single target to an upstream pool:

    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-recreate
    

    Compose's default networking means app now resolves to multiple containers, but a plain upstream backend { server app:8000; } with one line only balances if you list each replica explicitly, since Compose doesn't auto-populate upstream blocks. 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 the upstream block 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 ps output above if they differ from the example names.)

  6. (WSL2 Ubuntu terminal) Prove round-robin balancing by watching the pid field rotate across repeated requests:

    for i in 1 2 3 4 5 6; do curl -s http://localhost:8080/api/hello; echo; done
    

    Expect the pid value 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.

  7. (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;
        }
    }
    EOF
    

    Update compose.yaml to 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/hello
    

    Expect the same JSON response, now served over HTTPS; -k is required because the certificate is self-signed, not issued by a trusted authority.

  8. (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 the Host header (redirects, generated links) breaks or points at the wrong hostname.
  • Assuming Compose scaling auto-populates upstream blocks. docker compose up --scale app=3 creates three containers, but a hand-written upstream 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 single server 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's upstream gives you none of nginx's balancing-method choices like least_conn).
  • Editing nginx.conf and expecting the change to apply immediately. Nginx reads its config once at startup/reload; you must either docker 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 -t before 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 curl from inside the same container/network. A config can look correct from docker exec but 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 real curl against localhost:<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
  1. What's the difference between a forward proxy and a reverse proxy?
  2. Why do you need proxy_set_header X-Forwarded-For ...; if you want your backend app to know the real client IP address?
  3. What does an upstream block let you do that a single proxy_pass http://one-host:port; doesn't?
  4. When would you choose ip_hash over the default round-robin balancing method, and what's the tradeoff?
  5. What does "TLS termination" actually mean, and what protocol carries traffic onward from nginx to your backend container?
  6. Why does serving a static file directly through nginx's root/ try_files outperform proxying that same request to your app server?
  7. What's the point of running nginx -t before nginx -s reload?
Show answers
  1. 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.
  2. 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 with X-Real-IP) carries the original client's address forward as an explicit header the backend can read instead.
  3. 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.
  4. ip_hash when 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).
  5. 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.
  6. 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.
  7. nginx -t validates 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

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.