Web Servers and Reverse Proxies
What sits on the server side: web servers such as Nginx and Apache, virtual hosts, reverse proxies and load balancers, content delivery networks, and how the client's address survives the trip.
What a web server does
A web server is a program that listens on a network port (usually TCP 80 and 443, plus UDP 443 for HTTP/3), accepts HTTP connections, and answers each request. It does that in one of two ways:
-
Static content: it maps the URL path to a file on
disk —
/img/logo.svgto/var/www/portal/img/logo.svg— and sends it with the rightContent-Type, caching headers and compression. Web servers are extremely good at this. - Dynamic content: it hands the request to an application that builds the response — PHP through PHP-FPM, a Python or Java application server, a Node.js process — and relays the answer back to the client.
Around that core, the web server does the work every site needs: TLS, logging every request (the access log) and every problem (the error log), redirects, limits on request size and time, and compression. The word "server" is used for both the program and the machine it runs on; on this page it means the program.
Common web servers
| Server | Current version (October 2026) | Typical use |
|---|---|---|
| Nginx | Stable 1.30.x, mainline 1.31.x | Static files, reverse proxy and load balancer in front of applications; very common on Linux and as a Kubernetes ingress |
| Apache HTTP Server (httpd) | 2.4.69 (2.4 is the only maintained branch) |
Classic all-rounder with modules; per-directory
.htaccess files on shared hosting
|
| Caddy | 2.11.x | Small, simple configuration; obtains and renews HTTPS certificates automatically with ACME |
| IIS (Internet Information Services) | 10.0, also on Windows Server 2025 | Windows servers, ASP.NET applications |
Many programming languages also include a minimal server for
development, such as Python's http.server, used in the
example below. The Python documentation states plainly that it is
not recommended for production: it implements only basic security
checks.
Virtual hosts
One server with one IP address usually hosts many sites. A
virtual host is one such site in
the server's configuration: a host name (or several) with its own
document root or application, certificate, logs and rules. Nginx
calls it a server block, Apache a
<VirtualHost>, IIS a site with bindings.
For plain HTTP the server picks the virtual host from the
Host request header, which every HTTP/1.1 request must
carry (in HTTP/2 and HTTP/3 it is the
:authority pseudo-header). That is why two names with
the same DNS record can show completely different sites.
# Two virtual hosts on the same address and port
server {
listen 443 ssl;
server_name status.example.com;
ssl_certificate /etc/ssl/status/fullchain.pem;
ssl_certificate_key /etc/ssl/status/privkey.pem;
root /var/www/status; # static files only
}
server {
listen 443 ssl;
server_name portal.example.com www.portal.example.com;
ssl_certificate /etc/ssl/portal/fullchain.pem;
ssl_certificate_key /etc/ssl/portal/privkey.pem;
root /var/www/portal;
}
# Plain HTTP: send everyone to HTTPS
server {
listen 80;
server_name status.example.com portal.example.com www.portal.example.com;
return 301 https://$host$request_uri;
}
If no server_name matches, Nginx uses the default
server for that address and port — by default the first one in the
configuration. A request for an unknown name (or for the bare IP
address) then lands on some site, which surprises people.
Many administrators add an explicit catch-all default server that
rejects such requests.
HTTPS and SNI
With HTTPS there is a chicken-and-egg problem: the server must
present the right certificate during the TLS handshake, before any
HTTP — and therefore before the Host header — is sent.
Server Name Indication (SNI) solves it: the client puts the host
name in its TLS ClientHello, and the server uses it to choose the
virtual host and certificate (see
the TLS handshake). Every current client sends SNI. A very old client or a script
that connects by IP address does not, and gets the default
certificate — which then produces a name mismatch error.
Reverse proxies and forward proxies
A proxy is an intermediary that receives a request and makes it again on the client's behalf. Which side it serves makes all the difference:
- A reverse proxy sits in front of your servers. Clients think they talk to the site; the proxy forwards each request to one of the servers behind it and relays the answer. It works for the site owner.
- A forward proxy sits in front of your users. Browsers in a company send their web traffic to it, and it fetches pages from the internet on their behalf, applying filtering and logging (see forward proxies and web filters). It works for the client's organization.
Large sites put several layers of reverse proxies in front of their applications:
In Kubernetes, the same role is played by an ingress controller (see Ingress); in the cloud, by a managed load balancer or an API gateway. The ideas on this page apply to all of them.
TLS termination
TLS termination means that the reverse proxy ends the client's TLS connection: it holds the certificate and private key, decrypts the request, and forwards it. That brings real advantages — certificates live in one place and renew in one place, the proxy can route by URL path or header, add a web application firewall (WAF), and the application servers do not spend time on encryption.
The traffic from the proxy to the servers behind it is then plain HTTP, or it is encrypted again with a separate, often internal, certificate. Inside one trusted network segment plain HTTP is common; across networks, between data centers or with sensitive data, re-encrypt. The alternative, TLS passthrough, forwards the encrypted bytes untouched (load balancing at the TCP level), so the application keeps the key but the proxy cannot see or route by anything inside the request.
Load balancing and upstream servers
A reverse proxy that spreads requests over several identical servers is a load balancer. The servers behind it are its upstream servers (also called backends, or targets in the cloud): the servers a proxy forwards requests to — "upstream" because the content flows down from them toward the client. Common ways to choose one:
- Round robin: each server in turn — the default in most products.
- Least connections: the server with the fewest open requests, good when requests take very different amounts of time.
- Hash of the client address or a cookie: the same client keeps going to the same server ("sticky sessions"). Needed only when the application keeps session state in memory; a stateless design avoids it.
The load balancer sends traffic only to healthy servers. With
health checks
it regularly requests a URL such as /healthz on every
upstream and takes a server out of rotation when it stops answering
correctly. A health check that only proves "the process is running"
while the database behind it is down sends users to a broken server;
a check that tests every dependency can take all servers out at once
when one shared dependency hiccups. Most teams check the application
itself plus what it cannot work without.
upstream portal_app {
least_conn;
server 192.0.2.11:8080;
server 192.0.2.12:8080;
server 192.0.2.13:8080;
}
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/ssl/portal/fullchain.pem;
ssl_certificate_key /etc/ssl/portal/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10m; # larger uploads get 413
location / {
proxy_pass http://portal_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s; # a slower upstream gets 504
}
}
Open-source Nginx marks an upstream as failed when real requests to it fail (passive checks); active health checks are a feature of commercial products and of most cloud load balancers.
Preserving the client address
Behind a reverse proxy, every TCP connection the application receives comes from the proxy. The application's own view of "the client's IP address" is therefore the proxy's address — useless for logs, rate limits, geolocation or security investigations. Proxies pass the original address on in a request header:
-
X-Forwarded-For is the de
facto header for this: a comma-separated list of addresses, to
which each proxy appends the address it received the connection
from.
X-Forwarded-Proto(http or https) andX-Forwarded-Hostcarry the original scheme and host name. These headers were never standardized, but almost every proxy and framework supports them. -
Forwarded(RFC 7239, 2014) is the standardized version of the same information in one header:Forwarded: for=198.51.100.23;proto=https;host=portal.example.com. It is less widely used.
A reverse proxy in a few lines of Python
This script starts two local servers: a backend that reports what it
received, and a reverse proxy in front of it that forwards each
request, keeps the Host header and appends the client's
address to X-Forwarded-For. The client then sends three
requests to the proxy: a normal one, one with a forged
X-Forwarded-For, and one after the backend has been
stopped.
import http.client
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Backend(BaseHTTPRequestHandler):
"""The application server: reports what it received."""
def do_GET(self):
seen = {
"path": self.path,
"Host": self.headers["Host"],
"X-Forwarded-For": self.headers["X-Forwarded-For"],
"X-Forwarded-Proto": self.headers["X-Forwarded-Proto"],
"TCP peer": self.client_address[0],
}
lines = [f" {name + ':':19}{value}" for name, value in seen.items()]
body = ("\n".join(lines) + "\n").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class ReverseProxy(BaseHTTPRequestHandler):
"""Forwards every GET to the backend, as a reverse proxy does."""
def do_GET(self):
client_ip = self.client_address[0]
previous = self.headers.get("X-Forwarded-For")
forwarded_for = f"{previous}, {client_ip}" if previous else client_ip
headers = {
"Host": self.headers["Host"], # keep the original host name
"X-Forwarded-For": forwarded_for, # append the address we saw
"X-Forwarded-Proto": "https", # pretend we terminated TLS
}
try:
upstream = http.client.HTTPConnection("127.0.0.1", BACKEND_PORT, timeout=5)
upstream.request("GET", self.path, headers=headers)
resp = upstream.getresponse()
status, body = resp.status, resp.read()
except ConnectionRefusedError:
status, body = 502, b" upstream server not answering\n"
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def start(handler):
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
backend = start(Backend)
BACKEND_PORT = backend.server_port
proxy = start(ReverseProxy)
def get(extra_headers=None):
req = urllib.request.Request(
f"http://127.0.0.1:{proxy.server_port}/api/outages",
headers={"Host": "portal.example.com", **(extra_headers or {})},
)
try:
with urllib.request.urlopen(req) as resp:
print(resp.status, resp.reason)
print(resp.read().decode(), end="")
except urllib.error.HTTPError as err:
print(err.code, err.reason)
print(err.read().decode(), end="")
print("--- 1. A normal request through the proxy")
get()
print("--- 2. A client that sends its own X-Forwarded-For")
get({"X-Forwarded-For": "198.51.100.23"})
print("--- 3. The backend is down")
backend.shutdown()
backend.server_close()
get()
proxy.shutdown()
--- 1. A normal request through the proxy 200 OK path: /api/outages Host: portal.example.com X-Forwarded-For: 127.0.0.1 X-Forwarded-Proto: https TCP peer: 127.0.0.1 --- 2. A client that sends its own X-Forwarded-For 200 OK path: /api/outages Host: portal.example.com X-Forwarded-For: 198.51.100.23, 127.0.0.1 X-Forwarded-Proto: https TCP peer: 127.0.0.1 --- 3. The backend is down 502 Bad Gateway upstream server not answering
What the output shows:
-
The backend's
TCP peeris the proxy (here everything runs on 127.0.0.1, but in production it would be the proxy's address, not the client's). The client's address arrives only inX-Forwarded-For. -
In request 2, the forged value
198.51.100.23is still there, on the left; the proxy only appended the address it actually saw. That rightmost entry is the only one this proxy vouches for. -
In request 3, the proxy cannot reach its upstream and answers
502 Bad Gatewayitself — the error comes from the proxy, about the server behind it.
Content delivery networks
A content delivery network (CDN) is a worldwide set of reverse proxies run by a provider, on edge locations close to users. DNS sends each user to a nearby edge server, which terminates TLS and answers from its cache if it can. Only cache misses travel to your own servers, which the CDN calls the origin — not to be confused with a web origin in the browser sense.
Edge caching makes static files (images, scripts, stylesheets) fast
everywhere and takes most of the load off your servers; many CDNs
also absorb denial-of-service attacks and offer a WAF. What the CDN
may cache and for how long is controlled by your
Cache-Control headers, explained in
HTTP Caching. Because the CDN is now
the client of your origin, everything above applies: the origin sees
the CDN's addresses, and the real client address arrives in a header
(each CDN documents its own, often next to
X-Forwarded-For).
Common errors and where to look
With a proxy in the path, an error page can come from the proxy or from the application behind it. The status code tells you which side to look at first.
| Status | Who says it | Typical cause | Look first at |
|---|---|---|---|
502 Bad Gateway |
Proxy | Upstream refused the connection (application not running, wrong port), crashed mid-request, or sent an invalid response | Proxy error log, then whether the application is running and listening |
503 Service Unavailable |
Proxy or application | No healthy upstream left (many load balancers; Nginx reports this as 502 "no live upstreams"), overload, maintenance mode | Health check status, application logs |
504 Gateway Timeout |
Proxy | Upstream accepted the request but did not answer within the proxy's timeout (slow query, deadlock, a call to another slow service) | Application logs and slow-query logs; the proxy's timeout setting |
413 Content Too Large |
Proxy (usually) |
The request body exceeds a size limit — in Nginx
client_max_body_size, 1 MB by default
|
Size limits in every layer: CDN, proxy, application |
500 Internal Server Error |
Application | A bug or unhandled error in the application; the proxy only relays it | Application logs |
Nginx's error log names the cause and the upstream it tried, which narrows a 502 or 504 down in seconds:
2026/10/01 09:14:07 [error] 1123#1123: *8812 connect() failed (111: Connection refused) while connecting to upstream, client: 198.51.100.23, server: portal.example.com, request: "GET /api/outages HTTP/2.0", upstream: "http://192.0.2.12:8080/api/outages", host: "portal.example.com" 2026/10/01 09:16:52 [error] 1123#1123: *8907 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 198.51.100.23, server: portal.example.com, request: "GET /api/usage HTTP/2.0", upstream: "http://192.0.2.11:8080/api/usage", host: "portal.example.com"