# Reverse proxy: how it works and how to set one up in NGINX, Caddy or Traefik

> A reverse proxy forwards client requests to your servers. NGINX, Caddy and Traefik setups, the real client IP, safe caching, and fixes for 502 and 504 errors.

- URL: https://computese.com/reverse-proxy/
- Author: Duong Quan Nguyen, CEO, Computese
- Published: 2026-10-21
- Updated: 2026-10-21
- Topics: Hosting, Web development

## In short
- A reverse proxy receives client requests on behalf of your backend servers and forwards them, so clients see only the proxy. A forward proxy is the opposite: clients choose it to reach servers.
- In NGINX, send Host, X-Forwarded-For and X-Forwarded-Proto yourself, because by default NGINX replaces the client's Host with the name from proxy_pass. Use an upstream block with keepalive, and pass Upgrade and Connection explicitly for WebSockets.
- Behind a proxy the app sees the proxy's address. Trust X-Forwarded-For only from proxies you control and read it from the right, because any client can send its own value. The standard Forwarded header (RFC 7239) is used less often.
- Open-source NGINX checks health passively (max_fails and fail_timeout). Active health_check and cache purging are commercial features, while Caddy and Traefik document active checks.
- For a 504, a 413 or a redirect loop, check proxy_read_timeout (60 seconds by default), client_max_body_size (1m by default) and whether the app trusts the forwarded-protocol header.

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](https://www.rfc-editor.org/rfc/rfc9110.html) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Proxy_servers_and_tunneling) states the practical difference: forward proxies "can hide the identities of clients whereas reverse proxies can hide the identities of servers".

![A laptop on the left sends one arrow into a single gateway box in the middle. Three arrows fan out from that box to three server towers on the right.](https://computese.com/images/blog/reverse-proxy/one-door-three-servers.4096e955a4-1536.webp)

*Clients talk to one address, and the proxy decides which server answers.*

One proxy can do these jobs:

- **TLS termination.** The proxy holds the certificate and ends the HTTPS connection. Our [TLS certificate guide](https://computese.com/ssl-certificate/) 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](https://nginx.org/en/docs/http/ngx_http_upstream_module.html) 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](https://nginx.org/en/docs/http/ngx_http_gzip_module.html) "often helps to reduce the size of transmitted data by half or even more".
- **Rate limiting.** NGINX's [`limit_req`](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html) limits the request rate per key, such as a client IP address.
- **WAF and a hidden origin.** [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) 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](https://computese.com/understanding-ddos-attacks-how-they-work/) and [website security](https://computese.com/essential-tips-to-secure-your-website/).
- **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"](https://nginx.org/), and [HAProxy](https://www.haproxy.org/) 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](https://docs.aws.amazon.com/elasticloadbalancing/latest/userguide/what-is-load-balancing.html)                                                      |
| 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](https://developer.mozilla.org/en-US/docs/Glossary/CDN)                                                                                                |
| Kubernetes Ingress | [HTTP and HTTPS routes into cluster services](https://kubernetes.io/docs/concepts/services-networking/ingress/), run by an [ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) |

- An [API gateway](https://learn.microsoft.com/en-us/azure/architecture/microservices/design/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](https://microservices.io/patterns/apigateway.html) 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](https://computese.com/the-future-of-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](https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/): its [repository was archived](https://github.com/kubernetes/ingress-nginx) 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](https://nginx.org/2026.html) (March 2026), which made upstream keepalive and HTTP/1.1 the defaults and stopped sending the `Connection` header to the upstream.

```nginx
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](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) also accepts an `https` address when that hop must be encrypted too. Our guide to [man-in-the-middle attacks](https://computese.com/understanding-man-in-the-middle-mitm-attacks/) explains the risk on an untrusted network.

![A sealed envelope with a closed padlock travels from a laptop to a proxy box, where the padlock is shown open. A plain envelope then continues to a server tower.](https://computese.com/images/blog/reverse-proxy/tls-ends-at-proxy.761e2cbb27-1536.webp)

*TLS ends at the proxy, so the hop to the app is plain unless you encrypt it too.*

- **`keepalive`.** `keepalive 32` caches up to 32 idle connections to the app per worker process. For HTTP, the [upstream module](https://nginx.org/en/docs/http/ngx_http_upstream_module.html) says `proxy_http_version` should be 1.1 (or 2) and the `Connection` header field cleared.
- **`Host`.** By default NGINX replaces the client's `Host` with `$proxy_host`, the name from `proxy_pass` (`app` here). `$host` is the host name from the request line or the `Host` header, falling back to the matching server name.
- **Forwarding headers.** `$proxy_add_x_forwarded_for` is the client's `X-Forwarded-For` with `$remote_addr` appended, or just `$remote_addr` if the client sent none, so the app must read it from the right, as the client IP section below explains. The [core module](https://nginx.org/en/docs/http/ngx_http_core_module.html) defines `$scheme` as `http` or `https`, so the app can tell how the client connected. `X-Real-IP` is what the realip module reads by default.
- **Timeouts.** `proxy_read_timeout` and `proxy_send_timeout` default to 60 seconds and apply only between two successive reads or writes, not to the whole response or request. `proxy_connect_timeout` also 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". `0` turns the check off.
- **Redirect.** [`return 301`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html) sends HTTP clients to HTTPS. Our [website migration SEO guide](https://computese.com/website-migration-seo/) 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](https://nginx.org/en/docs/http/websocket.html) 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:

```nginx
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](https://nginx.org/en/docs/switches.html) 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](https://nginx.org/en/docs/beginners_guide.html), "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](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) 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".

![A request path drawn as a strip of three grey blocks enters a proxy box. One small arm lifts the first block away and another sets an orange block in its place before the strip moves on.](https://computese.com/images/blog/reverse-proxy/prefix-swap.c73ea6d3ac-1536.webp)

*With a URI on proxy_pass, the matched part of the path is swapped and the rest is kept.*

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:

```caddyfile
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](https://caddyserver.com/docs/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`](https://caddyserver.com/docs/caddyfile/directives/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](https://doc.traefik.io/traefik/expose/docker/basic/):

```yaml
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](https://doc.traefik.io/traefik/) reads the labels through its [Docker provider](https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/) 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](https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/docker/) 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](https://doc.traefik.io/traefik/reference/install-configuration/tls/certificate-resolvers/acme/) 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](https://github.com/nginx/nginx-acme) module | Automatic for public domain names | An ACME resolver you configure        |

HAProxy fills the same role, and [Envoy](https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_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`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For) is the de facto standard, and [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239.html) 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".

![An envelope passes through two proxy boxes on its way from a laptop to a server. Each box clips a tag onto it, and the tag added by the second box is orange.](https://computese.com/images/blog/reverse-proxy/second-proxy-tag.898e91a931-1536.webp)

*Each proxy adds the address it saw; trust only the entries added by proxies you control.*

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](https://nginx.org/en/docs/http/ngx_http_realip_module.html): `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](https://caddyserver.com/docs/caddyfile/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](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/)                                       | `insecure` is false by default; the docs recommend it only for tests                                                                                                                                                             |
| Express | `app.set('trust proxy', ...)` in the [Express guide](https://expressjs.com/en/guide/behind-proxies/)                                                                 | 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](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html) appends to `X-Forwarded-For` by default, and [Cloudflare](https://developers.cloudflare.com/fundamentals/reference/http-headers/) 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](https://nginx.org/en/docs/http/load_balancing.html), from real traffic. In the [upstream module](https://nginx.org/en/docs/http/ngx_http_upstream_module.html), `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](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) 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](https://nginx.org/en/docs/http/ngx_http_upstream_hc_module.html) with `health_check` are "available as part of our commercial subscription".

![A proxy box sends an envelope toward an upper server whose door is shut. A curved dashed orange arrow then carries a copy of the request to a lower server whose door is open.](https://computese.com/images/blog/reverse-proxy/retry-next-server.5c627a706c-1536.webp)

*A failed request can move to the next server, if the failure and the method allow it.*

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:

```nginx
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](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), 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`](https://doc.traefik.io/traefik/reference/routing-configuration/http/load-balancing/service/) 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](https://www.rfc-editor.org/rfc/rfc9111.html) 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`.

![Two documents travel back from a server toward a proxy that has a shelf of stored copies. The plain one is copied onto the shelf; the one with an orange padlock tag is not stored and reaches the laptop on a dashed line.](https://computese.com/images/blog/reverse-proxy/cache-skips-private.c78df1e22c-1536.webp)

*Responses marked private or no-store must not be kept in a shared cache; plain ones may be.*

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.

```nginx
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](https://web.dev/articles/optimize-ttfb) says that is not appropriate for personalized content. Our [Core Web Vitals guide](https://computese.com/core-web-vitals/) covers what to measure.

## Why do 502, 504, 413, redirect loops and WebSocket failures happen?

[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) 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](https://www.f5.com/company/blog/nginx/dns-service-discovery-nginx-plus)), 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](https://docs.djangoproject.com/en/5.2/ref/settings/) 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](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content) 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](https://learn.microsoft.com/en-us/azure/container-apps/ingress-overview) 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](https://docs.cloud.google.com/run/docs/triggering/https-request) 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](https://nginx.org/2026.html) 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](https://computese.com/infrastructure-as-code/), and put the proxy on your website maintenance patch list.

## A checklist for putting an app behind a reverse proxy

1. Decide that you need a proxy at all, then pick NGINX, Caddy or Traefik by how you deploy.
2. Keep the app on a private address and firewall it so only the proxy or your CDN can reach it.
3. End TLS at the proxy, redirect port 80 to 443 and send `X-Forwarded-Proto`.
4. Pass `Host` and set trusted proxies in the proxy, then in the app.
5. Set timeouts and the body-size limit for your slowest normal request, and give WebSocket paths their own location.
6. Add health checks (passive in NGINX, active in Caddy or Traefik), cap retries and retry only requests that are safe to repeat.
7. Cache only public paths and watch `X-Cache-Status`.
8. 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.
9. Run `nginx -t` before each reload, and track the proxy's version.

If you would rather hand this over, our [hosting and maintenance](https://computese.com/services/hosting-maintenance/) work includes CDN, WAF, TLS and security header configuration, and we work with NGINX, Caddy, HAProxy and Traefik.

## Key terms
- **Reverse proxy**: A server that receives client requests on behalf of one or more backend servers and forwards them. RFC 9110 calls the role a gateway. Clients see the proxy's address, and the servers behind it can stay hidden.
- **Forward proxy**: A proxy the client chooses, usually through local configuration, to forward its requests to servers. It can hide the identities of clients, where a reverse proxy can hide the identities of servers.
- **Upstream**: The backend server, or group of servers, that a proxy forwards requests to. In NGINX it is an upstream block of server entries, which proxy_pass can refer to by name.
- **TLS termination**: Ending the encrypted TLS connection at the proxy, which holds the certificate. The hop from the proxy to the app is plain HTTP unless you configure HTTPS for it as well.
- **Load balancer**: A component that distributes incoming traffic across multiple targets and routes it only to healthy ones, as Elastic Load Balancing does. NGINX, HAProxy and Caddy can balance load across a group of upstream servers.
- **API gateway**: A single entry point for API clients that routes requests to services and handles tasks such as authentication, SSL termination, mutual TLS and rate limiting. Microsoft's guidance says it acts as a reverse proxy.
- **X-Forwarded-For**: A de facto standard request header that lists client and proxy addresses, each proxy appending the address it saw. It is not part of any current specification, and its entries can be forged unless a trusted proxy added them.
- **Passive health check**: Judging a server by real traffic instead of probe requests. In NGINX, max_fails failed attempts within fail_timeout make the server unavailable for that period.

## Common questions

### What is a reverse proxy?

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. It can terminate TLS, route by host name and path, balance load across servers, cache and compress responses, and apply limits or a firewall. NGINX, Caddy, Traefik and HAProxy are examples, and Envoy can act as an edge proxy.

### What is the difference between a forward proxy and a reverse proxy?

A forward proxy acts for clients: the client chooses it, and it can hide who the clients are. A reverse proxy acts for servers: clients are not aware of it, and it can hide who the servers are. RFC 9110 calls a reverse proxy a gateway.

### Is a reverse proxy the same as a load balancer?

No, but the roles overlap. A load balancer spreads traffic across several targets and sends it only to healthy ones, while a reverse proxy forwards requests and may also terminate TLS, cache and compress. NGINX and HAProxy do both, and an NGINX upstream group with two servers turns a reverse proxy into a load balancer.

### What is the difference between an API gateway and a load balancer?

Microsoft describes an API gateway as a centralized entry point that acts as a reverse proxy, routes requests to services and handles tasks such as authentication, SSL termination, mutual TLS and rate limiting. A load balancer distributes traffic across targets and checks their health. Azure API Management does not balance load itself, so Microsoft advises using it with a load balancer or a reverse proxy.

### Should I choose NGINX, Caddy or Traefik?

Choose by how you run things. Caddy turns on automatic HTTPS for a named domain from a short Caddyfile. Traefik reads routes from Docker labels, so containers can come and go without restarts. NGINX is configured in files and exposes detailed directives for caching, timeouts and retries, but its active health checks are a commercial feature.

### Why does my app show the proxy's IP address instead of the visitor's?

Behind a proxy, the connection to the app comes from the proxy, so that is the address the app sees. The proxy passes the client address in a header such as X-Forwarded-For, which you must configure the app or web server to trust, and only from proxies you control. Read the list from the right, because any client can send its own value.

### Do I need a reverse proxy for a single app?

Not always. Managed platforms terminate TLS for you: Cloud Run redirects all HTTP requests to HTTPS, and Azure Container Apps does so by default. On your own server, a proxy can terminate TLS, route requests and apply limits in one place, at the cost of one more hop and one more component to patch.

## Sources
1. [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), IETF
2. [Proxy servers and tunneling](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Proxy_servers_and_tunneling), MDN Web Docs
3. [nginx news: 2026](https://nginx.org/2026.html), nginx.org
4. [Invoke with an HTTPS request](https://docs.cloud.google.com/run/docs/triggering/https-request), Google Cloud
5. [Module ngx_http_upstream_module](https://nginx.org/en/docs/http/ngx_http_upstream_module.html), nginx.org
6. [Module ngx_http_gzip_module](https://nginx.org/en/docs/http/ngx_http_gzip_module.html), nginx.org
7. [Module ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), nginx.org
8. [What is AWS WAF?](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html), Amazon Web Services
9. [nginx](https://nginx.org/), nginx.org
10. [HAProxy: the reliable, high performance TCP/HTTP load balancer](https://www.haproxy.org/), HAProxy
11. [Use API gateways in microservices](https://learn.microsoft.com/en-us/azure/architecture/microservices/design/gateway), Microsoft Learn
12. [Pattern: API Gateway / Backends for Frontends](https://microservices.io/patterns/apigateway.html), microservices.io
13. [What is Elastic Load Balancing?](https://docs.aws.amazon.com/elasticloadbalancing/latest/userguide/what-is-load-balancing.html), Amazon Web Services
14. [CDN (glossary)](https://developer.mozilla.org/en-US/docs/Glossary/CDN), MDN Web Docs
15. [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/), Kubernetes
16. [Ingress NGINX Retirement: What You Need to Know](https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/), Kubernetes
17. [kubernetes/ingress-nginx (archived repository)](https://github.com/kubernetes/ingress-nginx), GitHub
18. [Ingress controllers](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/), Kubernetes
19. [Module ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html), nginx.org
20. [Module ngx_http_core_module](https://nginx.org/en/docs/http/ngx_http_core_module.html), nginx.org
21. [Module ngx_http_rewrite_module](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html), nginx.org
22. [WebSocket proxying](https://nginx.org/en/docs/http/websocket.html), nginx.org
23. [Command-line parameters](https://nginx.org/en/docs/switches.html), nginx.org
24. [Beginner's guide](https://nginx.org/en/docs/beginners_guide.html), nginx.org
25. [Automatic HTTPS](https://caddyserver.com/docs/automatic-https), Caddy
26. [reverse_proxy (Caddyfile directive)](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy), Caddy
27. [Global options (Caddyfile)](https://caddyserver.com/docs/caddyfile/options), Caddy
28. [Traefik and Docker: basic example](https://doc.traefik.io/traefik/expose/docker/basic/), Traefik Labs
29. [Traefik Proxy documentation](https://doc.traefik.io/traefik/), Traefik Labs
30. [Docker provider: install configuration](https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/), Traefik Labs
31. [Docker provider: routing configuration](https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/docker/), Traefik Labs
32. [ACME certificate resolvers](https://doc.traefik.io/traefik/reference/install-configuration/tls/certificate-resolvers/acme/), Traefik Labs
33. [nginx/nginx-acme](https://github.com/nginx/nginx-acme), GitHub
34. [What is Envoy](https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_envoy), Envoy
35. [X-Forwarded-For header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For), MDN Web Docs
36. [RFC 7239: Forwarded HTTP Extension](https://www.rfc-editor.org/rfc/rfc7239.html), IETF
37. [Module ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html), nginx.org
38. [Entrypoints: install configuration](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/), Traefik Labs
39. [Express behind proxies](https://expressjs.com/en/guide/behind-proxies/), Express
40. [HTTP headers and Application Load Balancers](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html), Amazon Web Services
41. [Cloudflare HTTP headers](https://developers.cloudflare.com/fundamentals/reference/http-headers/), Cloudflare
42. [Module ngx_http_upstream_hc_module](https://nginx.org/en/docs/http/ngx_http_upstream_hc_module.html), nginx.org
43. [Using nginx as HTTP load balancer](https://nginx.org/en/docs/http/load_balancing.html), nginx.org
44. [HTTP services: load balancer and health checks](https://doc.traefik.io/traefik/reference/routing-configuration/http/load-balancing/service/), Traefik Labs
45. [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html), IETF
46. [Optimize Time to First Byte](https://web.dev/articles/optimize-ttfb), web.dev
47. [Using DNS for service discovery with NGINX and NGINX Plus](https://www.f5.com/company/blog/nginx/dns-service-discovery-nginx-plus), F5
48. [Settings (Django 5.2)](https://docs.djangoproject.com/en/5.2/ref/settings/), Django
49. [Mixed content](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content), MDN Web Docs
50. [Ingress in Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/ingress-overview), Microsoft Learn
