Lesson 4 of 6

Reverse Proxy & Load Balancing

Your app — Node, Python, Go, whatever — listens on localhost:3000. Users arrive on port 80 or 443. The bridge between those two facts is proxy_pass: Nginx accepts the raw internet, then forwards clean requests to your app. This is the most common Nginx job in existence.

💡 The Bottom Line

proxy_pass forwards a location's requests to a backend. Add the proxy_set_header lines so the backend sees the real client instead of Nginx. Need more than one backend? Declare an upstream group and pass to that — load balancing included.

The standard proxy block

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

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

Those four header lines matter because, from the backend's viewpoint, every request now comes from Nginx (127.0.0.1). The headers restore the truth: which hostname was requested (Host), the visitor's real IP (X-Real-IP / X-Forwarded-For — logs and rate limiting break without it), and whether the client used HTTPS (X-Forwarded-Proto — apps use this to build correct redirect URLs). Copy this block whole; every omitted line is a subtle production bug later.

Load balancing: the upstream block

upstream api_backend {
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
    server 10.0.0.13:3000 backup;
}

server {
    listen 80;
    location / {
        proxy_pass http://api_backend;
    }
}

Requests round-robin across the listed servers; a backend that stops answering is temporarily dropped, and backup servers only receive traffic when the others are down. Two useful variants: least_conn; (send to the least-busy backend) and ip_hash; (pin each client IP to one backend, for apps with in-memory sessions).

Mixing hats: static + proxy in one server

    location / {
        root /var/www/frontend;
        try_files $uri /index.html;
    }
    location /api/ {
        proxy_pass http://localhost:3000;
        # + the proxy_set_header lines
    }

The frontend served from disk at Nginx speed, /api/ proxied to the app — the standard production layout for a web app, in eight lines.

🙋 There Are No Dumb Questions

Q: Why not just let users hit my Node app on port 3000 directly?
A: You'd be re-implementing Nginx badly inside your app: TLS termination, slow-client buffering (a user on bad Wi-Fi ties up your app for the whole download without a proxy in front), static file performance, one entry point for many services, and restarting your app without dropping connections. App servers are good at running your code; Nginx is good at everything between your code and the internet.

⚠️ Watch Out

Trailing slashes in proxy_pass change the forwarded path. proxy_pass http://localhost:3000; (no path) forwards /api/users as-is; proxy_pass http://localhost:3000/; (with slash) strips the matched location prefix, forwarding /users. Both are useful; mixing them up produces mysterious 404s that "work with curl on the backend." Lesson 6 revisits this — it's gotcha royalty.

🔧 Try It Yourself

Proxy to a tiny backend using Docker Compose (both concepts from the Docker track):

services:
  backend:
    image: hashicorp/http-echo
    command: ["-text=hello from the backend"]
  nginx:
    image: nginx:1.27
    ports: ["8082:80"]
    volumes:
      - ./proxy.conf:/etc/nginx/conf.d/default.conf:ro

With proxy.conf containing a server block that does proxy_pass http://backend:5678;. Then docker compose up -d and visit http://localhost:8082 — Nginx in front, app behind, the production pattern in miniature.

✅ Quick Check

Why does a proxied backend need the X-Forwarded-For header?


Next up: the missing piece between users and either hat — HTTPS: certificates, the 443 server block, and Let's Encrypt.

← Back to
Next →