A reverse proxy is a server that receives client requests on behalf of one or more backend servers and forwards them, so clients see only the proxy. A forward proxy is the opposite: the clients choose it, and it forwards their requests to whatever servers they ask for.
Versions and defaults below are as documented in October 2026.
What does a reverse proxy do?
RFC 9110 defines a "gateway" (a.k.a. "reverse proxy") as an intermediary that "acts as an origin server for the outbound connection" and forwards requests to another server or servers. A forward proxy, in the same RFC, is chosen by the client, "usually via local configuration rules". MDN states the practical difference: forward proxies "can hide the identities of clients whereas reverse proxies can hide the identities of servers".

One proxy can do these jobs:
- TLS termination. The proxy holds the certificate and ends the HTTPS connection. Our TLS certificate guide covers certificates.
- Routing by host and path. One address can serve several sites, or send
/api/to one service and the rest to another. Routing some requests to new code and the rest to a legacy app is the strangler fig pattern. - Load balancing. Several upstream servers share one name. The NGINX upstream module uses weighted round robin by default and passes a failed request to the next server.
- Caching and compression. The proxy can answer repeat requests from its cache and compress responses. NGINX's gzip module "often helps to reduce the size of transmitted data by half or even more".
- Rate limiting. NGINX's
limit_reqlimits the request rate per key, such as a client IP address. - WAF and a hidden origin. AWS WAF monitors "the HTTP and HTTPS requests that are forwarded to your protected web application resources". Requests that reach the app directly skip it, so firewall the app to accept traffic only from the proxy. See our guides to DDoS attacks and website security.
- WebSockets. The proxy can relay the upgraded connection if you pass the upgrade headers on purpose.
How does a reverse proxy differ from a load balancer, an API gateway, a CDN and an ingress?
The labels overlap because the software does. NGINX's own site describes it as an "HTTP web server, reverse proxy, content cache, load balancer, TCP/UDP proxy server, and mail proxy server", and HAProxy calls itself "a free, very fast and reliable reverse-proxy offering high availability, load balancing, and proxying".
| Thing | Main job |
|---|---|
| Reverse proxy | Forwards client requests to backends; can also terminate TLS, cache, compress and route |
| Load balancer | Spreads traffic over several targets and routes it only to healthy ones |
| API gateway | One entry point for API clients: routing, authentication, rate limiting |
| CDN | Servers in many locations that hold copies of your content near users |
| Kubernetes Ingress | HTTP and HTTPS routes into cluster services, run by an ingress controller |
- An API gateway "acts as a reverse proxy and routes clients requests to the appropriate services", with authentication, SSL termination, mutual TLS and rate limiting on top. Microsoft says API Management "doesn't perform any load balancing", so pair it with a load balancer or a reverse proxy. microservices.io lists the costs, more complexity and an extra network hop, though it calls that extra round trip insignificant for most applications. See also our guide to enterprise API development.
- Kubernetes recommends Gateway instead of Ingress and has frozen the Ingress API, with no plans to remove it. The Kubernetes project's ingress-nginx controller was retired: its repository was archived on March 24, 2026, with no more releases or security fixes, though existing deployments keep working.
How do you set up NGINX as a reverse proxy?
This configuration serves one app from two instances. The addresses, host name, paths and numbers are examples. Put it in the http context. The three lines marked "older versions" are needed only before NGINX 1.29.7 (March 2026), which made upstream keepalive and HTTP/1.1 the defaults and stopped sending the Connection header to the upstream.
upstream app {
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
keepalive 32; # older versions
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/app.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/app.example.com/privkey.pem;
client_max_body_size 10m;
location / {
proxy_pass http://app;
proxy_http_version 1.1; # older versions
proxy_set_header Connection ""; # older versions
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_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
TLS ends at the listen 443 ssl block, so proxy_pass http://app; sends plain HTTP to the app. The proxy module also accepts an https address when that hop must be encrypted too. Our guide to man-in-the-middle attacks explains the risk on an untrusted network.

keepalive.keepalive 32caches up to 32 idle connections to the app per worker process. For HTTP, the upstream module saysproxy_http_versionshould be 1.1 (or 2) and theConnectionheader field cleared.Host. By default NGINX replaces the client'sHostwith$proxy_host, the name fromproxy_pass(apphere).$hostis the host name from the request line or theHostheader, falling back to the matching server name.- Forwarding headers.
$proxy_add_x_forwarded_foris the client'sX-Forwarded-Forwith$remote_addrappended, or just$remote_addrif the client sent none, so the app must read it from the right, as the client IP section below explains. The core module defines$schemeashttporhttps, so the app can tell how the client connected.X-Real-IPis what the realip module reads by default. - Timeouts.
proxy_read_timeoutandproxy_send_timeoutdefault to 60 seconds and apply only between two successive reads or writes, not to the whole response or request.proxy_connect_timeoutalso defaults to 60 seconds and "cannot usually exceed 75 seconds". client_max_body_size. The default is 1m. A larger body gets a 413 error, and the docs warn that "browsers cannot correctly display this error".0turns the check off.- Redirect.
return 301sends HTTP clients to HTTPS. Our website migration SEO guide covers redirect types.
WebSockets need their own handling. Upgrade and Connection are hop-by-hop headers, so NGINX does not pass them, and the WebSocket documentation says they "have to be passed explicitly". Give the path its own location and repeat the shared headers, because a location that sets any proxy_set_header inherits none from above:
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1; # older versions
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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 1h;
}
By default the connection closes when the app sends no data for 60 seconds. Raise proxy_read_timeout (the hour is an example) or have the app send WebSocket ping frames. If one path serves both kinds of request, use the map shown on the same page.
Check the file with nginx -t, which tests the syntax and then tries to open the files the configuration refers to, and apply it with nginx -s reload. If the new configuration fails, the master process, per the beginner's guide, "rolls back the changes and continues to work with the old configuration".
What does proxy_pass do with a trailing slash?
The proxy module documentation gives the rule. With a URI after the address, "the part of a normalized request URI matching the location is replaced by a URI specified in the directive". Without one, the request URI is passed "in the same form as sent by a client".

Applying the rule to a request for /api/users:
location | proxy_pass | The app receives |
|---|---|---|
/api/ | http://app | /api/users |
/api/ | http://app/ | /users |
/api/ | http://app/v2/ | /v2/users |
/api | http://app/ | //users |
The last row is the trap: the location has no trailing slash and the URI has one, so /api becomes / and the rest follows it. End both with a slash, or leave the URI off. Three cases change the rule. In a regular-expression or named location NGINX cannot tell which part to replace, so omit the URI. After rewrite ... break, the URI in proxy_pass is ignored and the rewritten URI is sent. With variables in proxy_pass, a URI is passed as is.
How do Caddy and Traefik compare with NGINX?
Caddy turns on HTTPS by itself, and Traefik discovers routes from running containers. The same route in Caddy:
app.example.com {
reverse_proxy 10.0.0.11:8080 10.0.0.12:8080
}
A public domain name as the site address turns on automatic HTTPS. Caddy gets a certificate from a public ACME CA "such as Let's Encrypt or ZeroSSL", keeps it renewed and redirects HTTP to HTTPS, provided DNS points at the server, ports 80 and 443 are open and the data directory is "writeable and persistent". By default reverse_proxy passes headers, including Host, to the backend unchanged, except that it sets or augments X-Forwarded-For and sets X-Forwarded-Proto and X-Forwarded-Host. It also supports WebSocket connections.
In Traefik, the routes are labels on the app container and the static options are flags. The names and paths are examples, based on Traefik's Docker example:
services:
traefik:
image: traefik:v3.7
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entryPoints.web.address=:80
- --entryPoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=admin@example.com
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
app:
image: example/app:1.0
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`app.example.com`)
- traefik.http.routers.app.entrypoints=websecure
- traefik.http.routers.app.tls.certresolver=le
- traefik.http.services.app.loadbalancer.server.port=8080
Traefik reads the labels through its Docker provider and discovers configuration with no restarts. Watch three things. exposedByDefault is true, so every container is routable unless you set it to false and opt in with traefik.enable=true. Traefik uses a container's first exposed port unless the loadbalancer.server.port label says otherwise, and a wrong port gives a 502. Unrestricted access to the Docker API is, in Traefik's words, "a security concern", and its docs list ways to restrict it, such as a Docker socket proxy. The ACME resolver needs the HTTP challenge entrypoint reachable on port 80.
| NGINX | Caddy | Traefik | |
|---|---|---|---|
| Routes are defined in | Config files, applied with a reload | A Caddyfile or native JSON | Container labels or files, no restart |
| Certificates | Bring your own, or the separate nginx-acme module | Automatic for public domain names | An ACME resolver you configure |
HAProxy fills the same role, and Envoy calls itself "an L7 proxy and communication bus", with optional active health checking and passive checking through outlier detection.
How does the app see the real client IP?
Behind a proxy, the app sees the proxy's address, so the proxy passes the client's in a header. X-Forwarded-For is the de facto standard, and RFC 7239 defines a standard Forwarded header that MDN calls "much less frequently used". Each proxy appends the address it saw. The rightmost entry is the most recent proxy and the leftmost is the original client, but only "assuming well-behaved client and proxies".

Any client can send its own X-Forwarded-For. MDN says that if the server can be reached directly from the internet, "no part of the X-Forwarded-For IP list can be considered trustworthy", and that a security use "must only use IP addresses added by a trusted proxy". Count a fixed number of trusted proxies from the right, or walk from the right and skip the addresses of proxies you control. RFC 7239 says Forwarded "cannot be relied upon to be correct" either, because any node, "including the client making the request", can modify it. Each stack has its own trust setting:
| Where | Setting | Default and caveat |
|---|---|---|
| NGINX | The realip module: set_real_ip_from, real_ip_header X-Forwarded-For, real_ip_recursive on | Not built by default. Replaces the client address only for connections from the listed addresses |
| Caddy | A servers block with trusted_proxies static private_ranges and trusted_proxies_strict, in the global options | No proxies are trusted by default, so incoming X-Forwarded-* values are ignored. Headers are read left to right unless trusted_proxies_strict (v2.8+) is set, which Caddy recommends behind proxies that append on the right |
| Traefik | forwardedHeaders.trustedIPs on the entrypoint | insecure is false by default; the docs recommend it only for tests |
| Express | app.set('trust proxy', ...) in the Express guide | Default false. A subnet list is checked from the right until the first address that is not trusted |
Behind a cloud edge, follow the provider's documentation. An Application Load Balancer appends to X-Forwarded-For by default, and Cloudflare sends the visitor's address to the origin in CF-Connecting-IP.
Warning
$proxy_add_x_forwarded_for keeps whatever X-Forwarded-For the client sent and appends the address NGINX saw. On the proxy that faces the internet, the leftmost entry is attacker-controlled, so anyone who sets the header can bypass an IP allow-list or rate limit keyed on it. At that edge, set the header to $remote_addr, or read only the entries that proxies you run added.
How do health checks and retries work?
Open-source NGINX checks health passively, from real traffic. In the upstream module, max_fails (default 1) failed attempts within fail_timeout (default 10 seconds) make NGINX treat the server as unavailable for another fail_timeout. The proxy module sets what counts as a failure through proxy_next_upstream, which defaults to error timeout. Statuses 500, 502, 503, 504 and 429 count only if you list them, and 403 and 404 never count. A POST, LOCK or PATCH is not passed to the next server once sent, unless you add non_idempotent, and nothing can be retried after the response has started. Active checks with health_check are "available as part of our commercial subscription".

With a single server in a group, max_fails and fail_timeout are ignored and the server is never considered unavailable, so passive checks need two servers. A failed request moves on until every working server has been tried, so cap it:
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
Both proxy_next_upstream_tries and proxy_next_upstream_timeout default to 0, which means no limit. Caddy documents active checks, which need health_uri or health_port and run every 30 seconds by default, and passive checks, which stay off until you set fail_duration. Its retries are off by default. Once on, a failed connection is always retried, but after a connection that succeeded only GET requests are, unless lb_retry_match says otherwise. Traefik offers a healthcheck and a passiveHealthCheck on a service.
How do you cache through a reverse proxy without leaking data?
A shared cache serves one stored response to many users, so the dangerous bug is storing something personal. RFC 9111 gives the origin the controls. With an unqualified private, "a shared cache MUST NOT store the response". With no-store, "a cache MUST NOT store any part of either the immediate request or the response". A Vary: * response never matches a later request, and a shared cache must not reuse a response to a request with an Authorization header unless the response allows it with must-revalidate, public or s-maxage.

In NGINX, Cache-Control, Expires, X-Accel-Expires, Set-Cookie and Vary from the app all set the caching parameters, unless proxy_ignore_headers switches that off. A response with Set-Cookie or Vary: * is not cached. The default cache key, $scheme$proxy_host$request_uri, holds no cookie or user, and proxy_cache is off until you enable it.
Warning
Cache specific paths of public content, not /. On a site with logins, never use proxy_ignore_headers for Cache-Control, Set-Cookie or Vary, and skip the cache for authenticated requests. A page built for one user is served to the next visitor with the same key.
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=assets:10m max_size=1g inactive=60m;
location /assets/ {
proxy_pass http://app;
proxy_cache assets;
proxy_cache_valid 200 10m;
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
add_header X-Cache-Status $upstream_cache_status;
}
$upstream_cache_status reports MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED or HIT. proxy_cache_purge is "available as part of our commercial subscription", so in open-source NGINX rely on short lifetimes and file names that change with the content. Caching at a CDN edge can lower time to first byte, though web.dev says that is not appropriate for personalized content. Our Core Web Vitals guide covers what to measure.
Why do 502, 504, 413, redirect loops and WebSocket failures happen?
RFC 9110 defines 502 as an invalid response from an inbound server and 504 as no timely response from an upstream server. Start with the proxy's error log and the app's log for the same minute.
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests fail after a container restart | By default NGINX resolves the host name in proxy_pass or an upstream server once, at start or reload (F5), so it keeps sending requests to the old IP | Reload after the address changes, or add a resolver and a variable in proxy_pass, or in an upstream with a zone, the resolve parameter (open source since 1.27.3) |
| 502 on Caddy after idle time | Caddy's upstream keepalive (2 minutes by default) can outlast an HTTP/1.1 upstream's keepalive timeout, so a reused connection is reset. Idempotent requests are retried, others get a 502 | Keep Caddy's keepalive shorter than the upstream's |
| 504 | NGINX waits proxy_read_timeout (60 seconds by default) between two reads of the upstream's response | Fix the slow endpoint, or raise proxy_read_timeout for that location |
| 413 | The body is larger than client_max_body_size (1m by default) | Raise it for the upload location, for example to 50m |
| Redirect loop | The app sees plain HTTP from the proxy and keeps redirecting to HTTPS. Django says this "probably means your site is running behind a proxy and can't tell which requests are secure" | Send X-Forwarded-Proto $scheme and make the app trust it, but only if you control the proxy. In Django: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| Mixed content | An HTTPS page loads http:// resources, for example because the app builds URLs from the scheme it sees | Same fix. MDN says the best strategy is to serve all content as HTTPS |
| WebSocket fails, or drops after 60 seconds | Upgrade and Connection are not passed by default, and an idle connection closes after 60 seconds | Pass both headers, then raise proxy_read_timeout or send ping frames |
When do you not need a reverse proxy?
A managed platform may already do the job. Azure Container Apps HTTP ingress terminates TLS, supports WebSocket, redirects HTTP to HTTPS by default and has a 240-second request timeout. Cloud Run redirects all HTTP requests to HTTPS but terminates TLS before they reach your service. You still own the headers: Azure provides only the rightmost IP in X-Forwarded-For and says other values "must be validated by the user to prevent IP spoofing".
A proxy you do not need adds a hop and a component to patch. On May 13, 2026, NGINX 1.30.1 and 1.31.0 fixed an HTTP/2 request injection in ngx_http_proxy_module (CVE-2026-42926), and four more stable releases with security fixes followed, up to 1.30.5 on September 15, 2026. Keep the configuration in version control, as in our guide to infrastructure as code, and put the proxy on your website maintenance patch list.
A checklist for putting an app behind a reverse proxy
- Decide that you need a proxy at all, then pick NGINX, Caddy or Traefik by how you deploy.
- Keep the app on a private address and firewall it so only the proxy or your CDN can reach it.
- End TLS at the proxy, redirect port 80 to 443 and send
X-Forwarded-Proto. - Pass
Hostand set trusted proxies in the proxy, then in the app. - Set timeouts and the body-size limit for your slowest normal request, and give WebSocket paths their own location.
- Add health checks (passive in NGINX, active in Caddy or Traefik), cap retries and retry only requests that are safe to repeat.
- Cache only public paths and watch
X-Cache-Status. - Test from outside: send your own
X-Forwarded-For, upload past the limit, hold a WebSocket idle for 90 seconds and restart one backend. A penetration test can also try forged headers. - Run
nginx -tbefore each reload, and track the proxy's version.
If you would rather hand this over, our hosting and maintenance work includes CDN, WAF, TLS and security header configuration, and we work with NGINX, Caddy, HAProxy and Traefik.


