What sits between a browser and your app

Point a browser at example.com and the request never touches the process running your code. It hits something else first: a server that reads the request, decides which backend should answer, forwards it there, and sends the response back as if it had produced that response itself. That server is a reverse proxy.

Nginx, Caddy, Traefik and HAProxy all do this job, and so does the load balancer sitting in front of a Kubernetes cluster. The arrangement never changes: clients talk to the proxy and to nothing else. The real servers stay behind it, however many there are and wherever they run.

How a reverse proxy handles a request

A client opens a connection to the reverse proxy’s IP and port, same as it would to any server. The proxy terminates that connection, reads the request line and headers, and matches them against its own rules — usually the Host header and the URL path. Based on that match it opens a second, separate connection to a backend, forwards the request over it, waits for the response, and relays that response back over the client’s original connection.

Two independent connections, stitched together. From the client’s side this is indistinguishable from talking to the backend directly. From the backend’s side, every request appears to come from a single machine, the proxy, instead of from the internet at large.

1
2
client  --- request --->  reverse proxy  --- request --->  backend server
client  <-- response ---  reverse proxy  <-- response ---  backend server

Because the proxy sees every request before the backend does, it can inspect, modify, cache or reject anything passing through. That position is why it ends up owning the work nobody wants inside the application: terminating TLS, compressing responses, caching static content, rate-limiting abusive clients, and sending different paths to different services — /api to one backend, / to another, /static straight to disk.

Reverse proxy vs forward proxy

Both sit in the middle of a connection. Which side they work for is the difference.

Forward proxyReverse proxy
Acts on behalf ofThe clientThe server
Client knows it existsUsually yes (configured explicitly)No (looks like the real server)
What it hidesThe client’s identity from the serverThe server’s identity from the client
Typical useCorporate outbound filtering, bypassing geo-blocks, anonymizing browsingRouting, TLS termination, caching, protecting origin servers

A forward proxy is the box your employer puts in the office so all outbound traffic leaves through one filtered chokepoint: the site you visit logs the proxy’s address, not your laptop’s. A reverse proxy is run by the people who own the site, and it hides the far end instead. From outside, you cannot tell whether example.com is one server or forty.

Reverse proxy vs load balancer

The two terms get used interchangeably, mostly because one Nginx or HAProxy process usually does both jobs at once.

Reverse proxyLoad balancer
Core question it answersWhat should happen to this request?Which server should handle this request?
Works with one backend?Yes, still useful (TLS, caching, routing by path)No, needs at least two to balance across
Typical extra featuresCaching, header rewriting, TLS termination, path routingHealth checks, weighted distribution, session affinity

A reverse proxy in front of a single backend still earns its keep: it terminates TLS so your app doesn’t have to, hides the backend’s real address, adds gzip. A load balancer has nothing to do until there are at least two backends to pick between. So the label you use describes the config you wrote, not the software you installed.

How to configure Nginx as a reverse proxy

Listen on a port, match a hostname, forward to a backend. That is the entire minimum:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
server {
    listen 80;
    server_name api.example.com;

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

proxy_pass is the line that matters: everything matched by location goes to http://127.0.0.1:3000. The four proxy_set_header lines exist because the backend otherwise sees the proxy as the client, and every request looks like it came from 127.0.0.1. X-Real-IP and X-Forwarded-For carry the real client address through. X-Forwarded-Proto tells the backend whether the original request was HTTPS, since TLS was terminated at the proxy and the hop behind it is plain HTTP.

One detail breaks setups constantly: a trailing slash on the proxy_pass URI changes the forwarded path. proxy_pass http://backend; (no path) forwards the original URI unchanged. proxy_pass http://backend/; (trailing slash) strips the part of the URI that location matched before forwarding. With location /api/ and proxy_pass http://backend/;, a request to /api/users reaches the backend as /users. Get it backwards and every route 404s on the backend while curl against the proxy looks fine.

Running a reverse proxy in front of a Dockerized app

This is where the pattern shows up most in day-to-day work: Nginx in one container, your app in another, both on the same Docker network so Nginx can reach the app by container name — the DNS behavior covered in what a Docker network gives you.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# nginx.conf
server {
    listen 80;

    location / {
        proxy_pass http://app: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;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# compose.yaml
services:
  app:
    build: .
    expose:
      - "3000"

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

app exposes port 3000 to the Compose network but publishes nothing to the host. Only Nginx does, on port 80. The name app:3000 in the Nginx config resolves through Docker’s internal DNS, the same wiring described in the Docker Compose guide. Run docker compose up and requests to localhost land on Nginx, which forwards them into the app container. The app is never reachable from outside the host on its own, which is as much the point as the routing is.

What a reverse proxy costs you

It adds a network hop and one more place for a request to fail. A misconfigured proxy_pass or a dropped header shows up as a bug in the app when the fault is one layer upstream of it. It is also a single point of failure by default: kill that one Nginx process and every backend behind it becomes unreachable. Production setups either run it redundantly or hand the job to something that already assumes failure — a cloud load balancer, or a Kubernetes Ingress controller backed by the Deployments and Pods underneath it.

A reverse proxy also does not make an app faster or more scalable on its own. It can cache and compress, but a slow backend stays slow; the proxy forwards that slowness one hop later. And it is not authentication and not input validation. Blocking obviously malformed requests at the edge is a bonus, never a reason for the app to stop checking what it receives.

Taking this config to production

Take the compose.yaml above, swap app for a real service, and add ssl_certificate and ssl_certificate_key to the Nginx server block once you have a certificate. That is the difference between a local exercise and something you would put in front of real traffic. If you’re routing to more than one backend, replace the bare address with an upstream block holding several server lines and point proxy_pass at that block: the same config is now a load balancer.