What an nginx reverse proxy does and who should run one

A reverse proxy is the single entry point between clients and your application servers. Clients connect only to nginx. Nginx terminates TLS, picks a backend pool based on host and path, and keeps pooled connections open to those backends. It buffers responses so a slow client does not hold an application worker, and it passes along the original client address, scheme and host.

Teams put nginx in front of application servers so that this edge work lives in one place. Without it, every service has to manage certificates, header handling and protection against slow clients. With it, application processes accept plain HTTP from a trusted neighbour and stay focused on their own logic. Nginx fits this role because its configuration is declarative, its behaviour is documented directive by directive, and it reloads configuration without dropping connections.

The people who benefit most are backend and platform engineers running services on VMs, bare metal or containers who need a stable, well-understood edge that they control.

client
  |  HTTPS
  v
[external load balancer]   (optional; L4 or L7)
  |
  v
nginx ------------------------------------------------ §6 validate, reload, log
  |- TLS termination, redirects, HSTS ............... §4
  |- realip + forwarded headers ..................... §3
  |- location -> proxy_pass URI rewrite ............. §2
  v
upstream group: keepalive, timeouts, buffering, WS .. §5
  |-> backend A
  |-> backend B
  `-> backend C
+-----------+
|  client   |
+-----------+
      |  HTTPS
      v
+------------------------------------------+
| external load balancer (optional, L4/L7) |
+------------------------------------------+
      |  client IP in X-Forwarded-For or PROXY protocol
      v
+==========================================================+
| nginx                          §6 nginx -t, reload, logs |
|  +----------------------------------------------------+  |
|  | 1. TLS termination, HTTP->HTTPS redirect, HSTS  §4 |  |
|  | 2. realip: client address from trusted LB       §3 |  |
|  |    Host, X-Forwarded-For, X-Forwarded-Proto        |  |
|  | 3. server_name + location, proxy_pass URI       §2 |  |
|  | 4. buffering, timeouts, WebSocket upgrade       §5 |  |
|  +----------------------------------------------------+  |
+==========================================================+
      |  plain HTTP/1.1 over pooled keepalive connections  §5
      v
+-----------------------------+
| upstream group: app_backend |
+-----------------------------+
      +--------------+--------------+
      v              v              v
+-----------+  +-----------+  +-----------+
| backend A |  | backend B |  | backend C |
+-----------+  +-----------+  +-----------+

Request path through an nginx reverse proxy, from client through an optional external load balancer to an upstream group of backends, with each stage labelled by the article section that configures it.

How proxy_pass rewrites the request URI

Placing those blocks is mostly about location matching. The rule that surprises people is what proxy_pass does to the path. If the proxy_pass target contains a URI, nginx replaces the part of the request path that matched the location prefix with that URI. Even a lone trailing slash counts as a URI. If there is no URI, only scheme, host and port, nginx forwards the client's original path unchanged.

location proxy_pass Client requests Upstream receives
/api/ ` /api/users /api/users
/api/ ` /api/users /users
/api/ ` /api/users /v2/users
/api ` /api/users //users

The last row shows why the location prefix and the replacement URI should both end in a slash, or both omit it. The leftover /users is appended to /, which produces a double slash. Some frameworks route a double slash differently, and others reject it.

Hostnames are a second trap. A literal hostname in proxy_pass is resolved once, when the configuration loads. If the backend's IP address changes, nginx keeps sending traffic to the old address until the next reload. If the name doesn't resolve at startup, the configuration fails to load.

Putting the target in a variable and adding a resolver directive makes nginx re-resolve the name at request time, but that changes two other things:

  • URI handling: with a variable, any URI in the directive is sent as-is and replaces the whole request URI, not just the matched prefix.
  • DNS dependency: the resolver now sits on the request path, so a slow or failing DNS server can cause request failures.

See the ngx_http_proxy_module reference at nginx.org/en/docs for the exact semantics.

server {
    listen 80;
    server_name app.example.com;

    # Static hostname, resolved once at config load.
    # Trailing slash on both sides: /api/users -> /users
    location /api/ {
        proxy_pass
    }

    # No URI in proxy_pass: path is forwarded unchanged.
    # /static/app.js -> /static/app.js
    location /static/ {
        proxy_pass
    }

    # Re-resolve the hostname at request time.
    # The resolver is now on the request path, so keep the timeout short.
    resolver 10.0.0.2 valid=30s;
    resolver_timeout 2s;

    location /reports/ {
        set $reports_upstream

        # With a variable, a URI in proxy_pass would replace the whole
        # request URI, not just the matched prefix. Strip the prefix
        # with rewrite instead, and leave the URI out of proxy_pass.
        # /reports/daily?d=1 -> /daily?d=1
        rewrite ^/reports/(.*)$ /$1 break;
        proxy_pass $reports_upstream;
    }
}

Shows prefix replacement with a trailing-slash URI, pass-through without a URI, and per-request DNS re-resolution using a variable and resolver, with a rewrite to strip the prefix because a URI in a variable-based proxy_pass would replace the entire request URI.

Forwarding client context and the header inheritance trap

By default, nginx sends the upstream Host: $proxy_host, which is the name taken from proxy_pass. It sends nothing about the original client. Set four headers:

  • Host $host, so the application sees the hostname the client asked for.
  • X-Real-IP $remote_addr.
  • X-Forwarded-For $proxy_add_x_forwarded_for, which adds $remote_addr to the end of any value the client sent.
  • X-Forwarded-Proto $scheme, so the application still builds https links and secure cookies after nginx terminates TLS.

These headers are just text the client controls until nginx overwrites them. The application should only trust them on connections that come from the proxy's address, and the backend should not be reachable directly.

The inheritance trap

proxy_set_header and add_header are inherited from an outer level only if the current level defines none of its own. Suppose you set the four headers in server and then add one proxy_set_header Upgrade to a single location. That location now forwards none of the four. The upstream sees nginx's address and $proxy_host, and nothing logs an error. Put the shared headers in one file, such as proxy_headers.conf. include it in every proxying location, and add any headers specific to that location after the include.

Behind another load balancer

If nginx sits behind a load balancer, $remote_addr is the load balancer's address, not the client's. ngx_http_realip_module fixes this. Check nginx -V for --with-http_realip_module, because the module is not built by default.

  • set_real_ip_from lists the load balancer's addresses or CIDR ranges.
  • real_ip_header X-Forwarded-For names the header that holds the client address.
  • real_ip_recursive on reads the header from right to left, skips trusted addresses, and uses the first untrusted one.

List only proxy addresses you control. If nginx trusts forwarded headers from any source, a client can forge its IP. That lets it get around IP-based rate limits and access controls, and it writes false addresses into your audit logs.

# --- /etc/nginx/proxy_headers.conf: shared by every proxying location ---
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;

# --- inside the http block ---
upstream app_backend {
    server 10.0.2.10:8080;  # app should accept traffic only from nginx
}

server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate     /etc/nginx/tls/app.example.com.crt;
    ssl_certificate_key /etc/nginx/tls/app.example.com.key;

    # Requires ngx_http_realip_module. Only addresses listed here are
    # trusted to report the client IP; everything else is ignored.
    set_real_ip_from  10.0.1.0/24;      # load balancer subnet (placeholder)
    real_ip_header    X-Forwarded-For;
    real_ip_recursive on;               # skip trusted hops, right to left

    location / {
        include /etc/nginx/proxy_headers.conf;
        proxy_pass
    }

    location /ws/ {
        # Any proxy_set_header here stops inheritance, so include first,
        # then add the headers this location needs.
        include /etc/nginx/proxy_headers.conf;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_pass
    }
}

A shared include file keeps the four forwarded headers in every location, including one that adds its own WebSocket headers, and the realip directives accept the client address only from the known load balancer subnet.

TLS termination, redirects and HSTS

Terminating TLS at nginx keeps certificate handling in one place. The server block listens with listen 443 ssl. It points ssl_certificate at the full chain (leaf plus intermediates) and ssl_certificate_key at the private key. Leaving out the intermediates is a common mistake: browsers often work around it, while API clients fail. Set ssl_protocols TLSv1.2 TLSv1.3 so older protocol versions are never negotiated. Add ssl_session_cache shared:SSL:10m so that all worker processes share one session cache. Returning clients can then resume a session instead of repeating the full handshake. The nginx guide to configuring HTTPS servers puts one megabyte of cache at roughly 4,000 sessions.

Plain HTTP gets its own small server block on port 80 that does nothing except `return 301 Keeping it separate stops proxy logic from ever running over plaintext.

HSTS tells browsers to refuse plain HTTP for the duration of max-age. Send it with add_header Strict-Transport-Security "max-age=..." always. Without always, nginx omits the header on 4xx and 5xx responses. Only enable HSTS once HTTPS works on every host it covers. includeSubDomains immediately breaks any subdomain still served over HTTP. preload is compiled into browsers, and removing a domain from the preload list can take months. Start with a short max-age and raise it later.

Traffic from nginx to the backend travels in plaintext unless you set proxy_pass In that case, also turn on proxy_ssl_verify onand setproxy_ssl_trusted_certificate`, because nginx does not verify the upstream certificate by default. Re-encrypting adds internal certificates to rotate and extra CPU for handshakes. It is worth that cost on shared, multi-tenant or untrusted networks, or wherever compliance requires encryption in transit. On a private loopback or a single-host link, it usually is not.

# Port 80 only redirects. No proxy logic runs over plaintext.
server {
    listen 80;
    server_name app.example.com;
    return 301
}

server {
    listen 443 ssl;
    server_name app.example.com;

    # Full chain (leaf + intermediates). Without intermediates, API clients fail.
    ssl_certificate     /etc/nginx/tls/app.example.com.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/app.example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    # One cache shared by all workers, so returning clients resume sessions.
    ssl_session_cache   shared:SSL:10m;

    # Start with a short max-age (5 minutes). Raise it once HTTPS works on every host.
    # 'always' also sends the header on 4xx/5xx responses.
    # Add includeSubDomains and preload only after that, because they are hard to roll back.
    add_header Strict-Transport-Security "max-age=300" always;

    location / {
        # Re-encrypt to the backend, for example on a shared or untrusted network.
        proxy_pass
        # nginx does not verify upstream certificates unless you enable verification.
        proxy_ssl_verify              on;
        proxy_ssl_trusted_certificate /etc/nginx/tls/internal-ca.pem;
        proxy_ssl_name                app.internal;  # name checked against the backend cert
        proxy_ssl_server_name         on;            # send SNI to the backend
        proxy_set_header Host $host;
        # Any add_header here would discard the server-level HSTS header.
    }
}

A port-80 redirect server and an HTTPS server that terminates TLS with a full-chain certificate, sends HSTS with a short max-age using 'always', and re-encrypts traffic to a backend whose certificate is verified.

Upstream keepalive, WebSockets, timeouts and buffering

Without the keepalive directive in the upstream block, nginx opens a new TCP connection to the backend for every request. Each new connection costs a handshake, and TLS adds another one. Setting keepalive caches idle connections per worker so later requests can reuse them. It only works with proxy_http_version 1.1 and proxy_set_header Connection "", because the client's Connection header would otherwise be forwarded and could close the connection.

Reuse brings an idle-timeout problem. If the backend closes idle connections before nginx's upstream keepalive_timeout expires, nginx may send a request on a socket the backend has already closed. You then see occasional 502s that do not reproduce under load testing. Set the backend's idle timeout longer than nginx's.

For WebSockets, follow the nginx documentation guide:

  • Pass Upgrade $http_upgrade.
  • Set Connection from a map on $http_upgrade.
  • Raise proxy_read_timeout, because quiet sockets otherwise drop after 60s.
Setting Default Symptom when wrong Trade-off of changing
proxy_connect_timeout 60s Slow failover to a dead upstream Lower values fail fast but can misfire on slow networks
proxy_read_timeout 60s 504 on slow reports, dropped WebSockets Higher values tie up connections on hung backends
proxy_send_timeout 60s Failed large uploads to slow backends Same trade-off as the read timeout
client_max_body_size 1m Unexpected 413 errors Larger bodies increase memory and disk exposure
proxy_buffering on Delayed SSE or streamed output Turning it off keeps slow clients tied to the backend

The read and send timeouts apply between two successive operations, not to the whole response. Set them per location: keep them short for APIs and longer for exports or uploads.

Turn off proxy_buffering only on streaming or SSE locations. A backend can also opt out for a single response by sending X-Accel-Buffering: no.

Retries need limits too. Keep non_idempotent out of proxy_next_upstream so a timed-out POST is never replayed, and set proxy_next_upstream_tries to cap the number of retries.

Operating it safely: validation, reloads and upstream logging

Every change follows the same order: nginx -t, then nginx -s reload. The test parses the full configuration, including included files and referenced certificates, and exits non-zero on failure, so it works as a CI gate as well as a pre-reload check. A reload sends the master process a HUP signal. The master re-reads the config, starts new workers, and tells the old workers to stop accepting connections and exit once their in-flight requests finish. Clients are not cut off mid-response. A full restart drops every open connection, so avoid it for config changes. Long-lived WebSocket connections can keep old workers alive for hours after a reload. worker_shutdown_timeout puts a ceiling on that.

Logging what the upstream did

The default access log records what nginx returned to the client, not what happened behind it. Define a log_format that adds $upstream_addr, $upstream_status and $upstream_response_time, and use it on proxied locations. If proxy_next_upstream retried the request, each variable holds a comma-separated list, one entry per attempt. A line like 10.0.1.5:8080, 10.0.1.6:8080 with statuses 502, 200 shows a failover that the client never noticed.

When a 502 or 504 does reach the client, $upstream_addr names the backend responsible. The timing field tells you which kind of failure it was:

  • Connect failure: A near-zero response time with a 502 usually means the connection was refused. The error log will show connect() failed for that address.
  • Read timeout: A 504 whose response time sits at your proxy_read_timeout means the backend accepted the request but went silent. The error log says upstream timed out ... while reading response header from upstream.

A refused connection points to a crashed or missing process. A read timeout points to a slow or blocked one.

When to avoid nginx, the alternatives, and the cost in engineering time

When nginx is a poor fit

Nginx works best when backends are stable and the config changes through review. It fits poorly in three situations:

  • Backend IPs change often. Nginx typically resolves upstream hostnames once, at load time. Making it re-resolve takes extra wiring with a resolver directive, and that path is easy to get wrong. A missed change shows up as traffic going to addresses that no longer exist.
  • The platform already terminates TLS and routes traffic. Putting nginx behind a managed load balancer or ingress layer adds a hop, a second set of timeouts and another place where headers can get lost.
  • Service discovery has to be dynamic. If backends join and leave every few minutes, editing a file and reloading becomes the bottleneck.
Option General role Configuration model Operational burden
Cloud-managed load balancer Edge TLS and L4/L7 routing Provider API or infrastructure-as-code Low; you are limited to the features the provider exposes
Kubernetes ingress or gateway layer Cluster-level HTTP routing Declarative cluster resources Moderate; you run the controller and handle its upgrades
Service-mesh sidecar proxy Per-service mTLS, retries and discovery Control-plane policy High; you run a proxy in every pod and also run the control plane
TLS and routing in the application No separate proxy Application code and libraries Spread across every team, each owning its own implementation

The cost in engineering time

When nginx does fit, one reviewed config is the only place where TLS, forwarded headers and timeouts are defined. Application teams then don't each build these themselves, slightly differently. The time savings come from a few habits:

  • Shared include files stop the header-inheritance bug where adding one proxy_set_header silently drops the others.
  • Upstream fields in the access log show which backend failed and how long it took, so triage goes straight to that backend instead of starting with guesses.
  • Deliberate timeouts, body limits and keepalive settings prevent recurring 413, 502 and 504 errors. Each of those otherwise turns into a new investigation.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nginx reverse proxy, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.