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.
| |
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 proxy | Reverse proxy | |
|---|---|---|
| Acts on behalf of | The client | The server |
| Client knows it exists | Usually yes (configured explicitly) | No (looks like the real server) |
| What it hides | The client’s identity from the server | The server’s identity from the client |
| Typical use | Corporate outbound filtering, bypassing geo-blocks, anonymizing browsing | Routing, 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 proxy | Load balancer | |
|---|---|---|
| Core question it answers | What 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 features | Caching, header rewriting, TLS termination, path routing | Health 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:
| |
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.
| |
| |
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.