# Load Balancers & Reverse Proxies (nginx)

> What a reverse proxy actually is, why you put nginx in front of your app, how load balancing spreads traffic across instances, and what nginx really does in production - TLS, gzip, caching, and rate limiting.


---

# Load Balancers & Reverse Proxies (nginx)

You've got an app running. It listens on some port - `3000`, `8080`, whatever your framework picked - and
on your laptop you hit `localhost:3000` and it works. Then someone says "put it behind nginx" or "we need a
load balancer," and suddenly there's a second piece of software in front of your app that you don't really
understand. It has its own config language, it terminates your HTTPS, and when it breaks, your whole site is
down even though your app is fine.

This guide makes that front-door piece knowable. By the end you'll understand what a reverse proxy *is*
(it's simpler than it looks), why nearly every production app has one, how it spreads traffic across multiple
copies of your app, and what nginx is actually doing for you when it sits out front.

## How to read this

- **Just need the mental model fast?** Read [Phase 1](01-what-a-reverse-proxy-is.md) - that's the whole
  idea in one picture, with a working config.
- **Want it to finally make sense?** Read in order. Each phase builds on the last: what a proxy is, then
  what happens when you need more than one app instance, then what nginx does for you day to day.

## The phases

1. **[What a Reverse Proxy Is](01-what-a-reverse-proxy-is.md)** - a server that sits in front of your app
   and forwards requests to it. Why you want one (TLS, static files, one public entry point), the
   receptionist mental model, and an annotated minimal nginx config.
2. **[Load Balancing](02-load-balancing.md)** - when one instance of your app isn't enough, the proxy
   spreads requests across a pool. Health checks, round-robin vs least-connections, and why your app needs
   to be stateless first.
3. **[What nginx Does in Practice](03-what-nginx-does-in-practice.md)** - TLS termination, gzip, caching
   static assets, rate limiting, and the day-to-day skill of editing and reloading config safely without
   taking the site down.

> Deep nginx tuning - module compilation, fine-grained `proxy_cache` zones, Lua scripting, and the
> differences between nginx and alternatives like HAProxy, Caddy, or Traefik - is deliberately left out.
> This guide is the working understanding you need to run a normal app behind nginx with confidence.


---

# What a Reverse Proxy Is

Here's the situation that trips people up. Your app runs fine on its own port. But you can't just point the
whole internet at `your-app:3000` and call it done - you need HTTPS, you need to serve images and CSS without
waking up your app for every one of them, and you'd really rather the world didn't know which framework you're
running or what port it's on. So you put another server in front. That front server is a **reverse proxy**,
and nginx is the one most people reach for.

The good news: once you see what it's doing, it stops being scary. It's not magic. It's a forwarder.

## The mental model: a receptionist

**What it actually is.** A reverse proxy is a server that receives every incoming request, then *forwards* it
to your actual app and passes the app's response back to the visitor. The visitor never talks to your app
directly - they only ever talk to the proxy.

Picture an office with a receptionist at the front desk. Visitors don't wander the building looking for the
right person. They tell the receptionist what they need; the receptionist walks it back to the right office,
gets the answer, and hands it back. Visitors only ever see the front desk. That's nginx.

```mermaid
flowchart TD
  Net["internet"]
  Nginx["nginx (reverse proxy)<br/>the receptionist - public, port 443"]
  App["your app (listens on :3000)<br/>private, not exposed to the internet"]
  Net -->|"https://yoursite.com"| Nginx
  Nginx -->|"http://127.0.0.1:3000"| App
```

📝 **Terminology - reverse vs forward proxy.** A *forward* proxy sits in front of *clients* and forwards their
requests out to the internet (think a corporate proxy that all the employees' browsers go through). A
*reverse* proxy sits in front of *servers* and forwards requests in to them. "Reverse" just means it's on the
server's side of the conversation, not the client's. We only care about reverse proxies here.

## Why you actually want one

You could, technically, expose your app to the internet directly. People do, in development. Here's what the
proxy buys you that makes it nearly universal in production:

- **TLS termination - HTTPS handled in one place.** The proxy holds your TLS certificate and does the
  encryption/decryption. Your app speaks plain HTTP on a private port and never has to know about
  certificates. One place to renew the cert, one place to configure it. (More on this in
  [Phase 3](03-what-nginx-does-in-practice.md).)
- **Serving static files cheaply.** Images, CSS, JavaScript, fonts - nginx can read those straight off disk
  and send them, fast, without ever bothering your app. Your app's job is the dynamic stuff; let the front
  desk hand out the brochures.
- **One public entry point.** Everything arrives at port 443 on one machine. Your app, your admin panel, your
  API can all live behind that single door on different internal ports, routed by URL path or hostname.
- **Hiding and centralizing.** The outside world sees nginx, not your framework, your port, or how many copies
  of your app are running. And cross-cutting concerns - logging, rate limiting, compression - live in one
  place instead of being reimplemented in every app.

💡 **Key point.** The proxy is where all the *infrastructure* concerns live - encryption, compression,
routing, throttling - so your *application* can stay focused on application logic. That separation is the
whole reason the pattern exists.

## A real, minimal nginx config

Here's the smallest config that does the core job: take HTTP requests on port 80 and forward them to an app
running locally on port 3000. Every line earns its place; read the comments.

```nginx
# A "server" block is one virtual host - one site nginx answers for.
server {
    listen 80;                       # accept plain HTTP on port 80
    server_name yoursite.com;        # only handle requests for this hostname

    # A "location" block matches a URL path. "/" matches everything.
    location / {
        # The heart of a reverse proxy: forward this request to your app.
        proxy_pass http://127.0.0.1:3000;

        # Pass along facts about the original request that would otherwise
        # be lost once nginx forwards it (see the gotcha below).
        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;
    }
}
```

*What just happened:* You told nginx: "When a request for `yoursite.com` arrives on port 80, forward it to the
app at `127.0.0.1:3000` and wait for its reply." The `proxy_set_header` lines attach extra information about
the *original* visitor - their IP address, the hostname they asked for, whether they came in over HTTP or
HTTPS - so your app can still see who's really calling, even though, as far as the network is concerned, the
request now comes from nginx.

📝 **Terminology - `127.0.0.1` / `localhost`.** That address means "this same machine." So `proxy_pass
http://127.0.0.1:3000` means "hand the request to whatever is listening on port 3000 right here on this box."
Your app and nginx are usually neighbors on the same server.

⚠️ **The gotcha that bites everyone: the real client IP.** Once nginx forwards a request, your app sees the
connection as coming from nginx (`127.0.0.1`), not from the actual visitor. If you log "client IP" without
help, every single request looks like it came from your own server. Those `X-Forwarded-For` and `X-Real-IP`
headers are how nginx tells your app "the request *looks* like it's from me, but the real human was at *this*
address." Your app (or framework) has to be configured to *trust and read* those headers - otherwise rate
limits, geolocation, and audit logs all see one fake IP. We come back to this in detail in
[Phase 3](03-what-nginx-does-in-practice.md), because it's the single most common reverse-proxy mistake.

**Why this saves you later.** When you understand that nginx is a forwarder that *replaces the connection*,
two confusing things suddenly make sense: why your logs show `127.0.0.1` for every user, and why your app
doesn't need to know anything about HTTPS even though your site is clearly served over HTTPS. The proxy
absorbed both. That mental model carries you through everything else in this guide.

## Recap

1. A **reverse proxy** receives every request and forwards it to your app - the receptionist at the front
   desk. Visitors only ever talk to the proxy.
2. You want one because it handles **TLS** in one place, **serves static files** cheaply, gives you **one
   public entry point**, and **hides and centralizes** infrastructure concerns.
3. The core directive is **`proxy_pass`** inside a `location` block inside a `server` block.
4. Forwarding **replaces the connection**, so you must pass headers like **`X-Forwarded-For`** for your app to
   see the real visitor - the classic gotcha.

Next: what happens when one copy of your app isn't enough, and the proxy starts spreading traffic across
several.


---

# Load Balancing

So far the receptionist has been walking requests to one office. But what happens when one person can't keep
up? On a busy day, a single copy of your app maxes out its CPU, requests start queuing, and pages get slow.
You can buy a bigger machine for a while - but eventually the answer is to run *several* copies of your app
and have the proxy spread requests across them. That's load balancing: the same receptionist doing a
slightly smarter job.

## The mental model: one front desk, several offices

**What it actually is.** A load balancer is a reverse proxy that, instead of forwarding to one app, forwards
to a *pool* of identical app instances - deciding, per request, which instance to send it to. Same
receptionist, but now five identical offices can all answer the same question, and the receptionist picks
one each time.

```mermaid
flowchart TD
  Net["internet"]
  Nginx["nginx (load balancer)<br/>one public front door"]
  A1["app copy #1 :3001"]
  A2["app copy #2 :3002"]
  A3["app copy #3 :3003"]
  Net --> Nginx
  Nginx -->|"picks one per request"| A1
  Nginx --> A2
  Nginx --> A3
```

*The "upstream" pool - interchangeable instances.*

📝 **Terminology - "upstream."** In nginx, the pool of backend instances is called an **upstream**. It's the
group of servers that requests flow *up to* after passing through the proxy. You'll see it as a named block in
the config.

## The config: an upstream pool

You declare the pool once, then point `proxy_pass` at it by name:

```nginx
# Define the pool of identical app instances.
upstream my_app {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

server {
    listen 80;
    server_name yoursite.com;

    location / {
        proxy_pass http://my_app;     # forward to the pool, not one instance
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
```

*What just happened:* you named a pool `my_app` with three instances, then told nginx to forward to
`http://my_app`. nginx now distributes incoming requests across those three. By default it uses
**round-robin** - request one goes to `:3001`, request two to `:3002`, request three to `:3003`, request four
back to `:3001`, and so on.

## How nginx decides who gets the next request

The rule nginx uses to pick an instance is the **load-balancing strategy**. The two you'll meet first:

- **Round-robin (the default).** Hand requests out in rotation, evenly. Works well when every request costs
  about the same and every instance is about equally powerful.
- **Least-connections (`least_conn`).** Send the next request to whichever instance has the *fewest* active
  connections. Better when request times vary a lot, so a slow one doesn't bury one instance while others sit
  idle.

You switch strategy by naming it at the top of the upstream block:

```nginx
upstream my_app {
    least_conn;                       # use least-connections instead of round-robin
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}
```

*What just happened:* adding `least_conn` told nginx to stop rotating blindly and always pick the least-busy
instance. Nothing else about the pool changed.

💡 **Key point.** Start with round-robin - it's the default for a reason and right for most workloads. Reach
for `least_conn` only when you see one instance getting hammered while others are quiet.

## Health checks: skipping the dead office

A pool is only useful if the receptionist stops sending visitors to an office where nobody's home. If one of
your instances crashes or hangs, you don't want a third of your traffic getting errors.

**What it actually does.** With the open-source nginx that ships in package managers, health checking is
*passive*: nginx watches the results of the real requests it forwards. If an instance fails or times out
enough times in a row, nginx marks it temporarily unavailable and routes around it, then retries after a
cooldown. Tune this per-server:

```nginx
upstream my_app {
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3003 max_fails=3 fail_timeout=30s;
}
```

*What just happened:* you told nginx: "if an instance fails 3 times within 30 seconds, treat it as down for
the next 30 seconds and send no traffic there. After that, try again." A crashed instance quietly drops out
of rotation and rejoins when it recovers - visitors keep getting served by the healthy ones.

⚠️ **Gotcha - passive isn't the same as active.** Passive health checks only notice a sick instance *because
a real user's request just failed against it* - a handful of visitors eat the failure before nginx routes
around it. *Active* health checks - nginx probing each instance on a schedule, before sending real traffic -
are a feature of the commercial nginx Plus, not the free open-source nginx
(source: <https://docs.nginx.com/nginx/admin-guide/load-balancer/http-health-check/>). If you need active
probing on open-source nginx, that's typically handled at a layer above (your orchestrator or platform).

## The prerequisite nobody mentions: your app must be stateless

Here's the part that surprises people. Load balancing only works cleanly if **any instance can handle any
request** - the proxy might send a given user to a different instance on every click. If instance #1 quietly
stored your shopping cart in its own memory, and your next request lands on instance #2, your cart is gone.

The fix is to make your app **stateless**: keep no per-user data in a single instance's memory. Push shared
state out to a place all instances can reach - a database, a cache like Redis, a shared session store. Then
every instance is genuinely interchangeable, and the receptionist can hand your request to anyone.

> This is exactly the discipline covered in [Designing for Scale](/guides/designing-for-scale) -
> statelessness is the foundation that makes horizontal scaling actually work.

**Why this saves you later.** The day your traffic doubles, you want the fix to be "run two more instances
and add two lines to the upstream block" - not "rearchitect the app under pressure." Building stateless from
the start is what makes scaling a config change instead of a crisis.

## Recap

1. A **load balancer** is a reverse proxy that forwards to a **pool (upstream)** of identical instances and
   picks one per request.
2. The default strategy is **round-robin** (even rotation); **`least_conn`** sends to the least-busy instance,
   better when request times vary a lot.
3. **Passive health checks** (`max_fails` / `fail_timeout`) let nginx route around a failing instance;
   *active* probing is an nginx Plus feature.
4. Load balancing assumes your app is **stateless** - shared state belongs in a database or cache, not in one
   instance's memory. See [Designing for Scale](/guides/designing-for-scale).

Next: the day-to-day reality of running nginx - TLS, compression, caching, rate limiting, and how to change
the config without taking your site down.

Send traffic and watch how each algorithm spreads it across the backends:

```playground-lb
```

Watch it animated: [load balancing](/explainers/LoadBalancing.dc.html)


---

# What nginx Does in Practice

You've got the mental model: nginx is the receptionist out front, optionally spreading work across a pool.
Now let's make it concrete. In a real deployment, nginx isn't just blindly forwarding - it's quietly doing a
handful of jobs that would be painful to build into your app. This phase walks through the four you'll meet
most, then the operational skill that matters more than any of them: changing config without breaking the
site.

## The "what nginx is doing for me" cheat-card

> **Want to know what each piece of your config buys you? Find it here, then read the section.**

| Job | What it does | Directive you'll see |
|---|---|---|
| TLS termination | Handles HTTPS so your app speaks plain HTTP (§1) | `listen 443 ssl;` `ssl_certificate` |
| Compression | Shrinks text responses before sending (§2) | `gzip on;` |
| Static caching | Serves & caches CSS/JS/images without touching your app (§3) | `location` + `expires` / `root` |
| Rate limiting | Caps how fast a client can hammer you (§4) | `limit_req_zone` / `limit_req` |
| Safe reload | Apply config changes with no dropped connections (§5) | `nginx -t` then `nginx -s reload` |

---

## 1. TLS termination - HTTPS stops here

**What it actually is.** TLS termination means nginx is where the encrypted HTTPS connection ends. The
visitor's browser and nginx do the encrypted handshake; nginx decrypts the request and forwards it to your
app over plain, unencrypted HTTP on a private local port. On the way back, nginx encrypts the response again.

The point: **your app never deals with certificates or encryption.** It speaks ordinary HTTP, as if it were
still on your laptop, while the public connection is fully HTTPS.

```nginx
server {
    listen 443 ssl;                                  # listen for HTTPS
    server_name yoursite.com;

    ssl_certificate     /etc/letsencrypt/live/yoursite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yoursite.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;            # plain HTTP to the app
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;  # tell the app it was HTTPS
    }
}

# A second tiny server block to send plain HTTP visitors to HTTPS.
server {
    listen 80;
    server_name yoursite.com;
    return 301 https://$host$request_uri;            # permanent redirect to HTTPS
}
```

*What just happened:* the first block accepts HTTPS on port 443 using your certificate files, then forwards
decrypted requests to the app on plain HTTP. `X-Forwarded-Proto $scheme` tells your app "the original request
came in over HTTPS" - the connection otherwise looks like plain HTTP to the app. The second block catches
anyone who typed `http://` and redirects them to the secure version.

📝 **Terminology - `301`.** The HTTP status code for "moved permanently." Browsers remember it and go
straight to HTTPS next time, instead of asking for the HTTP version again.

⚠️ **Gotcha - the redirect loop from a missing `X-Forwarded-Proto`.** If your app does its own "force HTTPS"
redirect but can't tell it's *already* on HTTPS (nginx forwarded plain HTTP and you didn't pass the header),
the app keeps redirecting, nginx keeps forwarding as HTTP, and the browser spins in an endless loop. Passing
that header - and configuring your framework to trust it - is the cure. Same family of problem as the
real-client-IP gotcha from [Phase 1](01-what-a-reverse-proxy-is.md): behind a proxy, your app only knows
about the original request through headers you choose to forward.

## 2. gzip - making responses smaller

**What it actually is.** gzip is compression. nginx squeezes text-based responses - HTML, CSS, JavaScript,
JSON - down to a fraction of their size before sending them, and the browser unpacks them on arrival. Less
data on the wire means faster page loads, especially on slow connections.

```nginx
gzip on;
gzip_types text/css application/javascript application/json text/plain;
```

*What just happened:* You turned compression on and told nginx which content types are worth compressing.
nginx now compresses matching responses on the fly before sending them.

💡 **Key point.** Compress text, not media. Images, video, and most fonts are *already* compressed - running
gzip over them wastes CPU for no real gain, which is why you list specific text types rather than "everything."

## 3. Caching and serving static assets

**What it actually is.** Your CSS, JS, images, and fonts don't change between requests - no reason to wake up
your app to hand out the same logo a thousand times. nginx can serve those files straight from disk, and tell
browsers to *cache* them so they don't even re-request them next time.

```nginx
location /static/ {
    root /var/www/yoursite;          # serve files from /var/www/yoursite/static/
    expires 30d;                     # tell browsers to cache for 30 days
}
```

*What just happened:* requests starting with `/static/` are served by nginx directly off disk - your app is
never involved. `expires 30d` adds a caching header so a returning visitor's browser reuses its saved copy
for 30 days instead of asking again. Two wins: your app does less work, and repeat visits feel instant.

⚠️ **Gotcha - caching and updates fight each other.** Tell browsers to cache `app.js` for 30 days, then ship
a new version, and returning visitors keep the old one for up to 30 days. The fix is *cache-busting*: change
the filename when the content changes (e.g. `app.a1b2c3.js`), so a new version is a new URL the browser
hasn't cached. Most build tools do this for you - long cache times and changing files only coexist when the
filename changes with the content.

## 4. Rate limiting - capping the firehose

**What it actually is.** Rate limiting caps how many requests a single client can make in a window of time -
protecting your app from being overwhelmed, whether by a misbehaving script, a scraper, or a brute-force
login attempt, by turning away the excess before it reaches your app.

```nginx
# Define a shared limit: 10 requests per second per client IP.
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=mylimit burst=20 nodelay;     # apply it to the API
        proxy_pass http://127.0.0.1:3000;
    }
}
```

*What just happened:* `limit_req_zone` defines a limit of 10 requests/second, tracked per client IP, using a
shared 10-megabyte memory area named `mylimit` to remember everyone's recent rate. Applied to `/api/`, a
client going faster gets turned away with an error instead of reaching your app. `burst=20` allows a short
spike of up to 20 queued requests so normal bursty traffic isn't punished, and `nodelay` lets that burst
through immediately rather than dribbling it out.

⚠️ **Gotcha - rate limiting by IP needs the *real* IP.** `$binary_remote_addr` is the address of whoever
opened the connection. If there's *another* proxy or CDN in front of nginx, that's the upstream proxy's
address, not the visitor's - so every visitor shares one rate limit, or one visitor can dodge it. The
real-client-IP problem from [Phase 1](01-what-a-reverse-proxy-is.md) again, one layer up: behind another
proxy, teach nginx to read the forwarded IP (the `realip` module) before rate limiting behaves as expected.

## 5. The skill that matters most: testing and reloading config safely

Everything above lives in config files. The operational reality: **a typo in your nginx config can take your
entire site down.** So the single most valuable habit is checking before you apply, and applying without
dropping connections.

**Always test first.** Before applying any change, ask nginx to parse the config and confirm it's valid:

```console
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```

*What just happened:* `nginx -t` ("test") parsed your config without touching the running server and found no
errors, so it's safe to apply. A typo would print the file and line number instead, so you fix it *before*
anything went live. Running `-t` every time is a cheap habit that prevents the worst kind of outage.

**Reload, don't restart.** Once the test passes, apply the change:

```console
$ sudo nginx -s reload
```

*What just happened:* `reload` tells nginx to re-read its config and gracefully hand over to new worker
processes. In-flight requests on the old workers finish normally; new requests use the new config. **No
connections are dropped.** A *restart*, by contrast, stops nginx entirely and starts it again - a brief
window where nothing is listening and visitors get connection errors.

⚠️ **Gotcha - reload vs restart.** Reach for **reload** for config changes; it's seamless. Save **restart**
for the rare cases that genuinely need it (certain startup-level settings, or recovering a wedged process).
Never run `reload` without `nginx -t` first - reloading a broken config can leave the server in a bad state.
Test, then reload, every time.

**Why this saves you later.** The difference between a calm config change and a 2am outage is almost always
this discipline. Build the habit now, while nothing's on fire, and it'll be automatic when something is.

## Recap

1. **TLS termination** ends HTTPS at nginx; your app speaks plain HTTP. Pass `X-Forwarded-Proto` so it knows
   the request was secure, or risk a redirect loop.
2. **gzip** compresses text responses (not already-compressed media) for faster loads.
3. **Static caching** lets nginx serve assets off disk and tells browsers to cache them - pair long cache
   times with cache-busting filenames.
4. **Rate limiting** caps per-client request rate before traffic hits your app, and depends on nginx seeing
   the real client IP.
5. **Test then reload** (`nginx -t`, then `nginx -s reload`) applies config with no dropped connections.
   Reload for config; restart only when you must.

That's the working picture: a reverse proxy is a forwarder, a load balancer is a forwarder with a pool, and
nginx in practice is where all your infrastructure concerns live. Natural next step: putting one in front of
a real server you control.

> Ready to stand this up? [Deploying to a VPS](/guides/deploying-to-a-vps) walks through getting an app onto
> a server with nginx in front of it, end to end - this guide is the "why" behind the front-door piece you'll
> configure there.
