Skip to content
Huseyin Babal
Go back

Nginx in front of two Node.js apps: proxy headers, failover, 429 and a cache HIT

Most Nginx tutorials stop at proxy_pass. The interesting part starts after that line: which headers your app gets, what happens to the request that lands on a dead backend, who answers a client that sends too much, and when your app is not called at all.

This post builds a small lab where each of those can be checked with curl. It is three containers: Nginx 1.31.6 in front, and two copies of the same Node.js app behind it. Every file is printed below, so you can recreate it in an empty folder and get the same results. All outputs are from a real run.

What the lab shows, in one table

FeatureConfigResult
Static filesroot /usr/share/nginx/html200 with ETag, then 304 Not Modified for a repeat with If-None-Match
Proxy headersfour proxy_set_header linesthe app sees the client IP, the host and http as the scheme
Load balancingupstream backend with two serversrequests alternate app2, app1, app2, app1
Failoverproxy_connect_timeout 1s, max_fails=1 fail_timeout=10sone request takes 1.007 s, the next ones take 2–6 ms
Rate limitingrate=2r/s, burst=3 nodelay8 requests at once: 4 × 200, 4 × 429
Gzipgzip on for application/json96,448 bytes → 11,572 bytes
Proxy cacheproxy_cache_valid 200 30sMISS in 1.005 s, then HIT in 0.0009 s
HTTPSlisten 443 ssl, http2 on301 from HTTP, then TLS 1.3 and HTTP/2 200

The files

The folder looks like this:

demo/
├── compose.yaml
├── nginx.conf
├── conf.d/
│   ├── site.conf
│   └── upstream.conf
├── locations/
│   ├── cache.conf
│   ├── proxy.conf
│   ├── ratelimit.conf
│   └── redirect.conf
├── app/server.js
├── html/index.html
└── certs/            # cert.pem + key.pem, generated below

The app

One small Node.js server with no dependencies. The instance name comes from an environment variable, so every response says which copy answered. /report waits one second on purpose, to have something worth caching.

const http = require('http');
const NAME = process.env.NAME;

http.createServer((req, res) => {
  const send = (body, status = 200) => {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(body));
  };
  if (req.url === '/whoami') return send({ instance: NAME });
  if (req.url === '/headers') return send({ instance: NAME, received: {
    host: req.headers.host, 'x-real-ip': req.headers['x-real-ip'],
    'x-forwarded-for': req.headers['x-forwarded-for'], 'x-forwarded-proto': req.headers['x-forwarded-proto'] } });
  if (req.url === '/search') return send({ instance: NAME, results: [] });
  if (req.url === '/big') return send(Array.from({ length: 2000 }, (_, i) => ({ id: i, name: `product ${i}`, inStock: i % 3 !== 0 })));
  if (req.url === '/report') return setTimeout(() => send({ report: 'daily sales', generatedBy: NAME, generatedAt: new Date().toISOString() }), 1000);
  send({ error: 'not found' }, 404);
}).listen(3000, () => console.log(`${NAME} listening on :3000`));app/server.js

Compose

Only the nginx service publishes ports. The two apps are reachable from Nginx by their service names, app1 and app2, and from nowhere else.

services:
  app1:
    image: node:22-alpine
    command: node /app/server.js
    environment: { NAME: app1 }
    volumes: [./app:/app:ro]
  app2:
    image: node:22-alpine
    command: node /app/server.js
    environment: { NAME: app2 }
    volumes: [./app:/app:ro]
  nginx:
    image: nginx:alpine
    ports: ["8080:80", "8443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./locations:/etc/nginx/locations:ro
      - ./html:/usr/share/nginx/html:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on: [app1, app2]compose.yaml

nginx.conf

The main file is short on purpose. It sets up the workers, a log format that records which backend answered and whether the cache was used, and includes everything else.

worker_processes auto;          # one worker per CPU core

events {
  worker_connections 1024;      # per worker
}

http {
  include mime.types;
  log_format main '$remote_addr "$request" $status $body_bytes_sent '
                  'upstream=$upstream_addr time=$request_time cache=$upstream_cache_status';
  access_log /var/log/nginx/access.log main;

  include conf.d/*.conf;
}nginx.conf

worker_processes auto starts one worker process per CPU core. Each worker runs an event loop (epoll on Linux) and handles up to worker_connections connections at once, so this config can hold 1,024 × cores connections without starting a single extra process. A connection that is waiting on a slow client or a slow backend costs a little memory, not a thread.

The server blocks

The rate-limit zone, the cache and gzip are defined once at the http level. Both servers, port 80 and port 443, include the same locations/*.conf, so every feature works the same over HTTP and HTTPS.

limit_req_zone $binary_remote_addr zone=search:10m rate=2r/s;
proxy_cache_path /var/cache/nginx keys_zone=reports:10m;

gzip on;
gzip_types application/json;

server {
  listen 80;

  location / {
    root /usr/share/nginx/html;  # static files
  }

  include locations/*.conf;
}

server {
  listen 443 ssl;
  http2 on;
  ssl_certificate     certs/cert.pem;
  ssl_certificate_key certs/key.pem;

  include locations/*.conf;
}conf.d/site.conf
upstream backend {              # round robin by default
  zone backend 64k;             # shared by all workers
  server app1:3000 max_fails=1 fail_timeout=10s;
  server app2:3000 max_fails=1 fail_timeout=10s;
}conf.d/upstream.conf

The zone line matters more than it looks. Without it, every worker keeps its own count of failures and its own position in the round robin. With it, all workers share one view of which backend is down.

The locations

location /api/ {
  proxy_pass http://backend/;
  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 1s;
  add_header X-Upstream $upstream_addr always;
}locations/proxy.conf
location /api/search {
  limit_req zone=search burst=3 nodelay;   # 2 req/s, small burst
  limit_req_status 429;
  proxy_pass http://backend/search;
}locations/ratelimit.conf
location /api/report {
  proxy_cache reports;
  proxy_cache_valid 200 30s;               # keep good responses 30 s
  add_header X-Cache-Status $upstream_cache_status always;
  proxy_pass http://backend/report;
}locations/cache.conf
location = /login {
  if ($scheme = http) {
    return 301 https://$host:8443/login;
  }
  return 200 "login page, over HTTPS\n";
}locations/redirect.conf

The trailing slash in proxy_pass http://backend/; is easy to miss. With it, Nginx replaces the matched /api/ prefix, so /api/whoami reaches the app as /whoami. Without it, the app would get /api/whoami and answer 404.

Static page and certificate

<!doctype html>
<html><head><title>Hello from Nginx</title></head>
<body><h1>Served by Nginx, straight from disk.</h1></body></html>html/index.html

A self-signed certificate is enough for a local test:

mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout certs/key.pem -out certs/cert.pem -subj "/CN=localhost"

Start it

docker compose up -d
docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
SERVICE   STATUS         PORTS
app1      Up 2 seconds   
app2      Up 2 seconds   
nginx     Up 2 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 0.0.0.0:8443->443/tcp, [::]:8443->443/tcpoutput

Before changing anything, and after every change, test the config. A broken file is reported with its line number, and the running Nginx keeps serving with the old config:

docker compose exec nginx nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfuloutput

Static files and 304

curl -si localhost:8080/
HTTP/1.1 200 OK
Server: nginx/1.31.6
Content-Type: text/html
Content-Length: 133
Last-Modified: Fri, 02 Oct 2026 11:33:43 GMT
Connection: keep-alive
ETag: "6abf9697-85"
Accept-Ranges: bytesoutput

The ETag is built from the file’s modification time and size (85 is 133 in hex). A browser that already has the page sends it back, and Nginx answers without a body:

curl -si -H 'If-None-Match: "6abf9697-85"' localhost:8080/
HTTP/1.1 304 Not Modified
Server: nginx/1.31.6
Last-Modified: Fri, 02 Oct 2026 11:33:43 GMT
Connection: keep-alive
ETag: "6abf9697-85"output

What the app receives

curl -si localhost:8080/api/headers
HTTP/1.1 200 OK
Server: nginx/1.31.6
Content-Type: application/json
X-Upstream: 172.22.0.2:3000

{"instance":"app1","received":{"host":"localhost","x-real-ip":"203.0.113.24","x-forwarded-for":"203.0.113.24","x-forwarded-proto":"http"}}output

(The client address is replaced with a documentation address here.)

What each header is for:

HeaderNginx variableWithout it, the app sees
Host$hostbackend, the upstream name, so generated URLs break
X-Real-IP$remote_addrnothing; the TCP peer is always Nginx
X-Forwarded-For$proxy_add_x_forwarded_forno client chain; with several proxies, each appends its peer
X-Forwarded-Proto$schemeno way to know the user came over HTTPS, so HTTPS redirects can loop

One warning: these headers are only trustworthy if the app can only be reached through Nginx. If a client can reach the app directly, it can send any X-Forwarded-For it likes. In this lab the apps have no published ports, which is the point.

X-Upstream is not a standard header. It is added by add_header ... always in proxy.conf, and it is useful in any setup with more than one backend: when a response looks wrong, you know which instance to look at.

Round robin

for i in 1 2 3 4 5 6; do curl -s localhost:8080/api/whoami; echo; done
{"instance":"app2"}
{"instance":"app1"}
{"instance":"app2"}
{"instance":"app1"}
{"instance":"app2"}
{"instance":"app1"}output

Round robin is the default. Other methods are one line away: least_conn; sends each request to the backend with the fewest active connections, and hash $binary_remote_addr consistent; keeps a client on the same backend. Both go inside the upstream block.

Stopping a backend

docker compose stop app2
for i in 1 2 3 4; do curl -s -w '  %{time_total}s\n' localhost:8080/api/whoami; done
{"instance":"app1"}  1.007018s
{"instance":"app1"}  0.002040s
{"instance":"app1"}  0.005924s
{"instance":"app1"}  0.002576soutput

What happened, step by step:

  1. The first request was due to go to app2. Nginx tried to connect and gave up after proxy_connect_timeout 1s.
  2. A connection error is one of the cases in the default proxy_next_upstream error timeout, so Nginx retried the same request on app1. curl saw a normal 200 after 1.007 s instead of 2 ms.
  3. With max_fails=1, that single failure marked app2 as unavailable for fail_timeout=10s. The next three requests went straight to app1 in 2–6 ms.

The default proxy_connect_timeout is 60 seconds. Leaving it there means one dead backend costs a user a minute instead of a second. Inside one data centre, a connect that has not finished in a second is not going to finish.

When app2 starts again, it rejoins the rotation once its unavailable period is over:

docker compose start app2
for i in 1 2 3 4; do curl -s localhost:8080/api/whoami; echo; done
{"instance":"app1"}
{"instance":"app1"}
{"instance":"app2"}
{"instance":"app1"}output

This is passive health checking: Nginx only learns a backend is down when a real request fails. Active health checks, where Nginx probes backends on its own, are part of the commercial NGINX Plus (health_check), not open-source Nginx.

Rate limiting

for i in $(seq 1 8); do curl -s -o /dev/null -w '%{http_code} ' localhost:8080/api/search; done; echo
200 200 200 200 429 429 429 429 output

Why exactly four: rate=2r/s allows one request every 500 ms per client IP. The first request uses that slot, and burst=3 lets three more through at once. With nodelay, those three are answered right away instead of being spaced out. Requests five to eight find the bucket full.

curl -si localhost:8080/api/search
HTTP/1.1 429 Too Many Requests
Server: nginx/1.31.6
Content-Type: text/html
Content-Length: 169
Connection: keep-aliveoutput

There is no X-Upstream header on this response, so no backend was involved. The default status for a rejected request is 503; limit_req_status 429 changes it to the code that clients and retry libraries understand. The 10m zone holds the per-IP state; the documentation puts one megabyte at about 16,000 IP addresses.

Gzip

curl -s -o /dev/null -w '%{size_download} bytes\n' localhost:8080/api/big
curl -s -o /dev/null -w '%{size_download} bytes\n' -H 'Accept-Encoding: gzip' localhost:8080/api/big
96448 bytes
11572 bytesoutput

The 2,000-product JSON shrinks 8.3 times. Nginx only compresses text/html unless told otherwise, so gzip_types application/json is the line that does the work for an API. The app sends uncompressed JSON and never has to know.

Cache: MISS, then HIT

curl -si -w '\ntime: %{time_total}s\n' localhost:8080/api/report
curl -si -w '\ntime: %{time_total}s\n' localhost:8080/api/report
X-Cache-Status: MISS

{"report":"daily sales","generatedBy":"app1","generatedAt":"2026-10-19T18:09:02.800Z"}
time: 1.004948s

X-Cache-Status: HIT

{"report":"daily sales","generatedBy":"app1","generatedAt":"2026-10-19T18:09:02.800Z"}
time: 0.000906soutput

The generatedAt value is identical, down to the millisecond: the second body is the stored copy. The other values $upstream_cache_status can take are worth knowing when you debug a cache: EXPIRED (the 30 s are over and the app was asked again), BYPASS, STALE and UPDATING.

Two settings that are not in this lab but usually belong in production: proxy_cache_lock on; makes a burst of requests for the same missing key wait for one backend call instead of all hitting the app, and proxy_cache_use_stale error timeout; serves the old copy when the backend is down.

The access log tells the same story

The custom log_format from nginx.conf records the backend and the cache status for every request:

docker compose logs --no-log-prefix nginx | grep upstream= | tail -6
203.0.113.24 "GET /api/search HTTP/1.1" 429 169 upstream=- time=0.000 cache=-
203.0.113.24 "GET /api/big HTTP/1.1" 200 96549 upstream=172.22.0.3:3000 time=0.002 cache=-
203.0.113.24 "GET /api/big HTTP/1.1" 200 11585 upstream=172.22.0.2:3000 time=0.002 cache=-
203.0.113.24 "GET /api/big HTTP/1.1" 200 11585 upstream=172.22.0.3:3000 time=0.007 cache=-
203.0.113.24 "GET /api/report HTTP/1.1" 200 97 upstream=172.22.0.2:3000 time=1.003 cache=MISS
203.0.113.24 "GET /api/report HTTP/1.1" 200 97 upstream=- time=0.000 cache=HIToutput

upstream=- appears twice: on the 429 and on the cache HIT. Those are the two requests Nginx answered on its own. In production, $upstream_addr and $upstream_response_time in the log format are the fastest way to tell a slow backend from a slow client.

HTTPS and HTTP/2

curl -si http://localhost:8080/login
HTTP/1.1 301 Moved Permanently
Server: nginx/1.31.6
Location: https://localhost:8443/loginoutput
curl -k -v https://localhost:8443/login 2>&1 | grep -oE '^\* SSL connection using TLSv1\.[0-9]|^\* ALPN: server accepted h2|^< .*|^login.*'
* SSL connection using TLSv1.3
* ALPN: server accepted h2
< HTTP/2 200 
< server: nginx/1.31.6
< content-type: text/plain
< content-length: 23
< 
login page, over HTTPSoutput

The client and Nginx agree on HTTP/2 during the TLS handshake (ALPN). Behind Nginx, the apps still speak plain HTTP/1.1 and never load a certificate. Renewing the certificate means reloading Nginx (nginx -s reload), not redeploying every app.

Clean up

docker compose down -v

Sources


Share this post:

Previous Post
Spring Boot logging, from log levels to Grafana Loki
Next Post
Microservices communication patterns on a real shop: retries, Kafka, the outbox and a saga