Reverse Proxies — The Receptionist¶
Once DNS has guided a browser to our server’s public IP
203.0.113.10, something has to answer on that machine and decide what to do
with each request. We don’t want the outside world talking directly to our
FastAPI backend or our database — we want a single, controlled front door. That
front door is a reverse proxy, and in this module it’s Nginx.
This chapter teaches what a reverse proxy is, how it differs from related ideas (forward proxy, load balancer), why we want one, and then walks our actual Nginx config line by line. Finally we meet Traefik, a more dynamic alternative, and compare the two.
1. What is a reverse proxy?¶
💡 Intuition: A reverse proxy is the receptionist at the front desk. Visitors don’t wander the building looking for the right office — they tell the receptionist what they want, and the receptionist routes them to the correct room, then carries the reply back. The visitor never learns the building’s internal layout.
Technically, a reverse proxy is a server that sits in front of one or more backend services. It accepts incoming requests from clients, forwards each one to the appropriate backend, receives the backend’s response, and returns it to the client. From the client’s point of view, the reverse proxy is the website — clients never see the backends behind it.
The word “reverse” is best understood by contrast with its mirror image.
Reverse proxy vs forward proxy¶
A forward proxy sits in front of clients and represents them to the outside world. A company might route all employee web traffic through a forward proxy to filter or log it; the websites see the proxy, not the individual employees.
A reverse proxy sits in front of servers and represents them to the outside world. Clients see the proxy, not the individual backend services.
In one line: a forward proxy hides who is asking; a reverse proxy hides who is answering.
Reverse proxy vs load balancer¶
A load balancer spreads incoming requests across multiple identical copies of a service so no single instance is overwhelmed. The overlap with reverse proxies is real: most reverse proxies (Nginx included) can load-balance, and most load balancers are a kind of reverse proxy.
The useful distinction is intent:
A reverse proxy focuses on routing and gatekeeping — sending
/api/to one service and/to another, terminating TLS, hiding internals.A load balancer focuses on distributing load across replicas of the same service for capacity and resilience.
In our small stack, Nginx acts as a reverse proxy (routing by path). If we later
ran three copies of the backend, the same Nginx could also load-balance across
them by listing several servers in an upstream block.
2. Why we need one¶
Without a reverse proxy you’d have to expose each container on its own port and
ask users to remember them — :8000 for the API, :80 for the frontend — and
you’d have no clean place to add TLS. A reverse proxy gives us, in one place:
A single entry point on the standard ports
:80(HTTP) and:443(HTTPS). Users just typeexample.com.Path/host routing.
example.com/goes to the frontend;example.com/api/...goes to the backend. Same domain, same origin, no CORS headaches in the browser.TLS termination. HTTPS is decrypted once, at the proxy. Backends speak plain HTTP internally (see https.md). One certificate to manage, not one per service.
Hiding internal services. The backend (
:8000) and database (:5432) are never published to the internet. Only the proxy is reachable from outside.Compression, caching, buffering, rate limiting. Cross-cutting concerns live in the proxy instead of being reimplemented in every app.
Here is where the reverse proxy sits in our stack:
flowchart TD
U[Browser] --> DNS[(DNS lookup)]
DNS --> IP[203.0.113.10]
IP --> RP[Nginx reverse proxy :80/:443]
RP -->|path /| FE[frontend container :80]
RP -->|path /api/| BE[backend container :8000]
BE --> DB[(Postgres database)]
And here is the routing decision it makes on every request:
flowchart LR
R[Incoming request to example.com] --> D{Path starts with /api/?}
D -->|yes| B[backend_upstream -> backend:8000]
D -->|no| F[frontend_upstream -> frontend:80]
Notice that only nginx publishes a port to the host (80:80 in our
docker-compose.yml). The backend, frontend,
and db services have no published ports — they’re reachable only on the
internal appnet network, and only the proxy can reach them. That is the “hiding
internal services” benefit made concrete.
3. Nginx, line by line¶
Nginx (pronounced “engine-x”) is a fast, battle-tested web server and reverse
proxy. Below is the exact configuration we ship, quoted verbatim from
example-app/nginx/default.conf. We’ll then
explain every line.
# Upstreams name the internal containers. Docker's DNS resolves these
# service names ("frontend", "backend") to container IPs on the appnet network.
upstream frontend_upstream {
server frontend:80;
}
upstream backend_upstream {
server backend:8000;
}
server {
listen 80;
server_name _; # match any hostname for now (see https.md for TLS)
# API traffic -> FastAPI backend. The trailing slashes keep the /api/ prefix.
location /api/ {
proxy_pass http://backend_upstream;
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;
}
# Everything else -> static frontend.
location / {
proxy_pass http://frontend_upstream;
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;
}
}
The upstream blocks¶
upstream frontend_upstream {
server frontend:80;
}
upstream backend_upstream {
server backend:8000;
}
An upstream block defines a named group of backend servers that Nginx can
forward to. We define two: frontend_upstream and backend_upstream.
server frontend:80;— the destination is the hostfrontendon port80. Where does the namefrontendcome from? It’s the service name in our Docker Compose file. Docker runs an internal DNS server on theappnetnetwork, sofrontendautomatically resolves to that container’s current IP. This is the same name→IP idea from dns.md, but scoped to the private Docker network instead of the public internet.server backend:8000;— likewise, thebackendservice on port8000, which is where our FastAPI app (via Uvicorn) listens.
Naming the upstreams (rather than writing proxy_pass http://backend:8000
inline) keeps the location blocks clean and makes it trivial to add more servers
later for load balancing — you’d just list additional server lines inside the
block.
The server block¶
server {
listen 80;
server_name _;
A server block is one virtual host — a complete description of how to handle
requests arriving on a given port/hostname.
listen 80;— accept connections on TCP port 80, the standard HTTP port. (In https.md we add a secondlisten 443 ssl;for HTTPS.)server_name _;— which hostnames this block answers for. The underscore_is a catch-all that matches any hostname. That’s fine while we’re learning and serving a single site. Once we add TLS we’ll pin this toexample.com www.example.comso the right certificate is selected.
location /api/ vs location / — routing by path¶
A location block matches a URL path prefix and decides what to do with it.
Nginx picks the most specific matching location, so a request to
/api/health matches location /api/ (more specific) rather than location /
(the catch-all).
location /api/ {
proxy_pass http://backend_upstream;
...
}
location / {
proxy_pass http://frontend_upstream;
...
}
Requests whose path starts with
/api/(e.g./api/health,/api/message,/api/visits) are sent to the backend.Everything else —
/,/index.html, assets — is sent to the frontend.
This is exactly the routing our frontend index.html
relies on: the browser calls /api/message on the same origin, and Nginx
quietly forwards that to backend:8000. The browser never talks to the backend
directly, so there’s no cross-origin call to worry about.
proxy_pass and the all-important trailing slash¶
proxy_pass http://backend_upstream;
proxy_pass is the instruction that actually forwards the request to an
upstream. The subtle part is how Nginx rewrites the path, which depends on whether
proxy_pass ends with a slash:
No trailing slash (our case):
proxy_pass http://backend_upstream;. Nginx forwards the original, full path unchanged. A request for/api/healtharrives at the backend as/api/health. This is exactly what we want, because our FastAPI routes are defined with the/api/prefix (@app.get("/api/health")).With a trailing slash:
proxy_pass http://backend_upstream/;. Nginx would strip the matchedlocationprefix (/api/) and replace it, so/api/healthwould arrive at the backend as/health— which our FastAPI app does not define, yielding404 Not Found.
So the rule for our app is: location /api/ + proxy_pass http://backend_upstream;
(no trailing slash) preserves the /api/ prefix. The comment in the file —
“The trailing slashes keep the /api/ prefix” — refers to the trailing slash on the
location /api/ matcher; combined with the slash-less proxy_pass, the prefix is
carried through to the backend intact.
⚠️ Common mistake: Adding a trailing slash to
proxy_pass(http://backend_upstream/) and then getting 404s because the/api/prefix was stripped and your FastAPI routes (which include/api/) no longer match. Decide deliberately whether the backend expects the prefix, and makeproxy_passagree.
The four proxy_set_header lines — and why backends need them¶
When Nginx forwards a request, the backend would otherwise see the connection as coming from Nginx, not from the real visitor — Nginx is the receptionist relaying the message. These headers restore the lost context so the backend knows who really called and how.
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;
Host $host;— forwards the original hostname the visitor requested (e.g.example.com) instead of the upstream’s internal name. Backends use theHostheader to build absolute URLs (redirects, links, cookies). Drop it and redirects can point at the wrong place.X-Real-IP $remote_addr;—$remote_addris the IP of whoever connected to Nginx, i.e. the visitor’s IP. Without this, the backend sees only Nginx’s IP. Apps and logs useX-Real-IPto record the true client.X-Forwarded-For $proxy_add_x_forwarded_for;— the standard chain of client IPs as a request passes through proxies.$proxy_add_x_forwarded_forappends the current client to any existing list, preserving the full path through multiple proxies.X-Forwarded-Proto $scheme;— tells the backend whether the original request washttporhttps. This matters a lot once TLS terminates at the proxy: internally the backend is reached over plain HTTP, but it needs to know the user arrived over HTTPS so it buildshttps://links and setsSecurecookies. See https.md.
⚠️ Common mistake: Missing
X-Forwarded-Proto. After you add HTTPS, the backend (only seeing internal HTTP) thinks the user is on HTTP and issueshttp://redirects — which the browser then upgrades or blocks, causing redirect loops or “mixed content” warnings. Always forward the original scheme.
⚠️ Common mistake: A 502 Bad Gateway. This is Nginx saying “I tried to reach the upstream and couldn’t.” Usually the backend container is down, still starting, crashed, or listening on a different port than the
upstreamblock names. Checkdocker compose ps, the backend logs, and thatserver backend:8000;matches the port Uvicorn actually binds.
✅ Production best practices:
Forward all four headers on every proxied location so logging, redirects, and HTTPS detection work.
Keep backends unpublished — only the proxy listens on
:80/:443.Add a health check location or rely on the container
HEALTHCHECKso a sick backend is noticed before users are.Pin Nginx to a specific image tag (we use
nginx:1.27-alpine) for reproducible builds.
4. Traefik — the dynamic alternative¶
Nginx is static: you write a config file, and you reload Nginx when it changes. Traefik takes a different approach designed for the container era.
💡 Intuition: If Nginx is a receptionist working from a printed directory you update by hand, Traefik is a receptionist who watches the building and updates the directory automatically as offices open and close.
Traefik is a reverse proxy and load balancer that builds its routing rules dynamically by watching your infrastructure. Its standout features:
Label-based dynamic configuration. Instead of a central config file, each service describes how it wants to be routed using Docker labels right in the Compose file.
Automatic service discovery. Traefik connects to the Docker API and notices when containers start or stop, adding and removing routes with no reload.
Automatic Let’s Encrypt. Traefik can obtain and renew TLS certificates by itself via the ACME protocol (see https.md) — you just declare which domain a service answers for.
Here’s a short Traefik setup that routes example.com/api/ to a backend, with
automatic HTTPS:
services:
traefik:
image: traefik:v3.1
command:
# Watch Docker and only expose containers that opt in via labels
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
# Entry points: HTTP on 80, HTTPS on 443
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# ACME / Let's Encrypt: HTTP-01 challenge, store certs in a file
- "--certificatesresolvers.le.acme.httpchallenge=true"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.le.acme.email=admin@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
# Traefik needs the Docker socket to discover services
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks: [appnet]
backend:
image: ghcr.io/chrisw09/example-app-backend:latest
labels:
- "traefik.enable=true"
# Route example.com/api/* to this service over HTTPS
- "traefik.http.routers.backend.rule=Host(`example.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.backend.entrypoints=websecure"
- "traefik.http.routers.backend.tls.certresolver=le"
# Tell Traefik which container port to forward to
- "traefik.http.services.backend.loadbalancer.server.port=8000"
networks: [appnet]
volumes:
letsencrypt:
networks:
appnet:
Line notes:
--providers.docker=trueturns on Docker service discovery;exposedbydefault=falsemeans a container is ignored unless it setstraefik.enable=true— safe by default.The
entrypointsdefine listening ports:web=:80,websecure=:443, mirroring Nginx’slisten 80/listen 443.The
certificatesresolvers.le.acme...lines configure automatic Let’s Encrypt via the HTTP-01 challenge, storing issued certs inacme.json.The
/var/run/docker.sockvolume lets Traefik read the Docker API to watch containers. (Mount it read-only,:ro.)The backend’s labels declare its route: the
rulematchesHost(example.com) && PathPrefix(/api)— the Traefik equivalent of Nginx’sserver_name+location /api/. Theserver.port=8000label is Traefik’s version ofserver backend:8000;. Thetls.certresolver=leline is all it takes to get HTTPS on this route.
The headline difference: with Nginx you’d edit default.conf and reload; with
Traefik you add labels to the service and Traefik reconfigures itself the moment
the container starts.
5. Nginx vs Traefik¶
Dimension |
Nginx |
Traefik |
|---|---|---|
Config style |
Static config file ( |
Dynamic, via Docker labels on each service |
TLS automation |
Manual, or via Certbot companion (see https.md) |
Built-in ACME / Let’s Encrypt, auto-renew |
Dynamic discovery |
None — you edit config when services change |
Automatic — watches the Docker API, no reload |
Learning curve |
Gentle config, but TLS + reloads are manual steps |
More moving parts up front, less ongoing toil |
Maturity / ecosystem |
Extremely mature, ubiquitous, vast documentation |
Modern, container-native, growing |
Best when… |
Stable topology; you want explicit, auditable config; serving static files |
Containers come and go frequently; you want zero-touch routing and automatic certs |
When to pick which: Choose Nginx when your set of services is stable, you value an explicit config file you can read and review, or you’re already serving static content with it — which is exactly our situation in this module. Choose Traefik when services scale up and down dynamically and you’d rather not touch config or manage certificates by hand. Both are excellent; this module uses Nginx so we can teach every line of the routing explicitly.
6. Exercises¶
Exercise 1 — Trace a request.
A browser requests https://example.com/api/health. Using our shipped
default.conf, describe which location matches, which upstream and container
receive the request, and the exact path the backend sees.
Answer
The path /api/health matches the more specific location /api/ block. That
block has proxy_pass http://backend_upstream;, so the request goes to the
backend_upstream group → server backend:8000; → the backend
container’s Uvicorn on port 8000. Because proxy_pass has no trailing slash,
the original path is preserved, so the backend sees exactly /api/health —
which matches FastAPI’s @app.get("/api/health").
Exercise 2 — The trailing-slash trap.
A teammate changes the API location to proxy_pass http://backend_upstream/;
(note the added trailing slash). Suddenly /api/health returns 404. Explain why,
and give two ways to fix it.
Answer
With a trailing slash on proxy_pass, Nginx strips the matched /api/
prefix, so the backend receives /health — a route FastAPI never defined,
hence 404. Fixes: (1) remove the trailing slash so the full /api/health
path is forwarded unchanged (our shipped behavior); or (2) keep the slash but
change the FastAPI routes to not include the /api prefix (e.g. /health),
relying on Nginx to add/strip it. The first is simpler and matches what we ship.
Exercise 3 — Diagnose a 502.
After a deploy, every page returns 502 Bad Gateway. docker compose ps shows
the backend container is “restarting.” What does the 502 mean, what’s the likely
cause, and where do you look?
Answer
502 Bad Gateway means Nginx received the request but could not get a valid
response from the upstream — here, backend:8000 is unreachable because the
container keeps crashing/restarting. Look at docker compose logs backend to
find the crash (bad env var, failed import, port mismatch), confirm Uvicorn is
binding 0.0.0.0:8000 to match server backend:8000;, and check that the
backend joined the appnet network. Nginx itself is fine — the fault is the
upstream.