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.svg to /var/www/portal/img/logo.svg — and sends it with the right Content-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:

Topology: a browser connects over HTTPS to a CDN edge server, which connects over HTTPS to a reverse proxy or load balancer in the site's own network. The reverse proxy terminates TLS, runs health checks and forwards requests over HTTP to three application servers, its upstream servers. your data center or cloud network public internet Browser 198.51.100.23 HTTPS CDN edge cache HTTPS Reverse proxy / load balancer TLS termination health checks HTTP app 1 :8080 app 2 :8080 app 3 :8080
A common layout: CDN at the edge, a reverse proxy or load balancer at the entrance of your own network, and several identical application servers behind it.

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) and X-Forwarded-Host carry 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 peer is 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 in X-Forwarded-For.
  • In request 2, the forged value 198.51.100.23 is 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 Gateway itself — 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"