A reverse proxy is a server that accepts client requests on behalf of an application: Nginx listens on ports 80 and 443, terminates HTTPS, serves static files itself and passes everything else to Node.js via proxy_pass, adding the Host and X-Forwarded-* headers. Below is a working config for Nginx 1.30 with an HTTPS redirect, static caching, gzip, a body size limit, timeouts and security headers, tested with nginx -t and curl in Docker.
After this you will be able to
- Configure
serverblocks withproxy_passthat passHost,X-Real-IPandX-Forwarded-*to a Node.js app - Serve static files from Nginx with cache headers and gzip, and limit request body size and timeouts
- Enable HTTPS with an HTTP redirect and security headers without losing them to
add_headerinheritance - Validate the config with
nginx -t, reload it without downtime and trace 413 and 504 errors in the logs
Know beforehand
Verified with
Nginx 1.30.5 (stable)Node.js 24 LTSDocker Compose v5
Checked against docs: October 11, 2026
Contents
What a reverse proxy does
A Node.js app can answer HTTP on its own, but exposing its port directly to the internet is inconvenient and risky. Instead, a reverse proxy sits in front of it: a server that accepts client connections on the app’s behalf and forwards the requests. Clients only see Nginx; the app’s address and port stay inside the server or the Docker network.
- 01Browser
- 02Nginx :443
- 03Node.js :3000
- TLS: Nginx holds the certificate and decrypts HTTPS, while the app speaks plain HTTP on the internal network
- Static files: served from a folder without touching Node.js, with cache headers and compression
- Protection at the edge: request size limits, timeouts, security headers, the HTTP-to-HTTPS redirect
- Routing: several apps and domains on one server, switching between versions during a deploy
The test stand: Nginx and Node.js on one Docker network
It’s easiest to debug the config locally without touching a server. You need Docker with Compose and curl. The stand is two containers on one Compose network: the official nginx:stable-alpine image (Nginx 1.30.5 from the stable branch at the time of testing) and node:24-alpine running a tiny app with no dependencies. Inside the network, containers find each other by service name: Nginx reaches the app as app:3000, and only the proxy’s ports are published.
name: kb-nginx
services:
app:
image: node:24-alpine
command: ["node", "/app/server.mjs"]
volumes:
- ./app:/app:ro
environment:
APP_VERSION: v1
proxy:
image: nginx:stable-alpine
depends_on:
- app
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./static:/var/www/app:roimport { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 3000);
const VERSION = process.env.APP_VERSION ?? "v1";
function send(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
}
const server = createServer((req, res) => {
const url = new URL(req.url, "http://internal");
if (url.pathname === "/healthz") return send(res, 200, { ok: true, version: VERSION });
if (url.pathname === "/api/whoami") {
// what the app sees behind the proxy: the original Host, client IP and scheme
return send(res, 200, {
version: VERSION,
host: req.headers.host,
realIp: req.headers["x-real-ip"],
forwardedFor: req.headers["x-forwarded-for"],
forwardedProto: req.headers["x-forwarded-proto"],
peer: req.socket.remoteAddress,
});
}
if (url.pathname === "/api/slow") {
const ms = Number(url.searchParams.get("ms") ?? 1000);
return setTimeout(() => send(res, 200, { waitedMs: ms }), ms);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
let bytes = 0;
req.on("data", (chunk) => (bytes += chunk.length));
req.on("end", () => send(res, 200, { receivedBytes: bytes }));
return;
}
send(res, 404, { error: "not found" });
});
server.listen(PORT, () => console.log(`app ${VERSION} on :${PORT}`));
// docker stop sends SIGTERM: stop accepting new connections,
// let in-flight requests finish, then exit
process.on("SIGTERM", () => {
console.log(`app ${VERSION}: SIGTERM, closing`);
server.close(() => process.exit(0));
});/api/whoami shows the headers that reached Node.js, /api/slow answers with a delay, /api/upload counts body bytesHTTPS on the stand needs a certificate. Let’s Encrypt doesn’t issue certificates for local names, so here we create a self-signed one: browsers won’t trust it, while curl accepts it with -k. On a server, a certificate from certbot takes its place.
mkdir -p nginx/certs static/assets
openssl req -x509 -nodes -newkey rsa:2048 -days 30 \
-subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com" \
-keyout nginx/certs/privkey.pem -out nginx/certs/fullchain.pemexample.com and www.example.com — for local testing onlyPut any JS file larger than 1 KB into static/assets/, for example a built bundle app.3f9c2b.js: we’ll use it to test caching and compression. The Nginx config and the headers snippet come in the next sections.
The config: server, location and proxy_pass
An Nginx config is made of nested blocks. The http block — already present in the image’s /etc/nginx/nginx.conf, where it includes every conf.d/*.conf file — holds virtual servers (server), and those hold location blocks, the rules for paths. Nginx picks a server by port and by the name in the Host header (server_name), then the matching location by request path.
upstream app_backend {
server app:3000;
}
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml;
# HTTP: only the Let's Encrypt challenge and a redirect to HTTPS
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
client_max_body_size 5m;
include /etc/nginx/snippets/security-headers.conf;
# hashed static files: served by Nginx itself, cached for a year
location /assets/ {
root /var/www/app;
try_files $uri =404;
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# everything else goes to the Node.js app
location / {
proxy_pass http://app_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 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}upstream app_backend
- What it does
- A group of app servers
- Why
- A second instance or a new version is added here during a deploy
proxy_pass http://app_backend
- What it does
- Passes the request to the app
- Why
- With no URI at the end, the path reaches the app unchanged
proxy_set_header Host $host
- What it does
- The original site name
- Why
- By default Nginx sends
$proxy_host— the name fromproxy_pass, i.e.app_backend
X-Real-IP, X-Forwarded-For
- What it does
- The client IP
- Why
- Otherwise the app sees Nginx’s own address
X-Forwarded-Proto $scheme
- What it does
- The request scheme, here
https - Why
- The app needs it for absolute links, redirects and
Securecookies
proxy_connect_timeout, proxy_read_timeout
- What it does
- Waiting for a connection and a response
- Why
- 60 seconds each by default; reading is timed between two operations, not for the whole response
Since version 1.29.7 Nginx talks to the app over HTTP/1.1 by default (previously 1.0), so on the 1.30 branch proxy_http_version 1.1 is no longer required. WebSocket still needs its own Upgrade and Connection header setup, described in the Nginx documentation.
Static files, cache headers and gzip
Nginx serves files faster and more cheaply than Node.js. In location /assets/, root points to the folder and try_files $uri =404 returns the file or a real 404 without passing the request to the app. Files with a hash in the name (app.3f9c2b.js) can be cached for a year with immutable: a new build changes the file name, not the content at the old URL. HTML and API responses must not be cached this way.
Don’t put always on Cache-Control. The first version of the stand had it, and Nginx marked even a 404 for a missing file as cacheable for a year. Without always the header is added only to successful responses and redirects, while security headers need always precisely so that they also appear on errors.
Gzip is enabled in the http context, which is where conf.d files end up. text/html is always compressed; other types are listed in gzip_types. gzip_min_length 1024 doesn’t waste CPU on tiny responses, and gzip_vary on adds Vary: Accept-Encoding so an intermediate cache doesn’t serve the compressed version to a client without gzip support. The test file shrank from 9801 to 1108 bytes on the wire — the ratio depends on the content.
HTTPS: a Let’s Encrypt certificate, certbot and the redirect
The server block on port 443 enables TLS with the ssl parameter of listen plus the certificate and key paths. You can skip protocols and ciphers: according to the Nginx docs, the defaults are TLSv1.2 TLSv1.3 and HIGH:!aNULL:!MD5. HTTP/2 is enabled with the http2 on; directive (since 1.25.1); the http2 parameter of listen is deprecated.
DNS and port 80
The domain points to the server and port 80 is open. Let’s Encrypt verifies domain control with an HTTP request to
/.well-known/acme-challenge/, which is why the config excludes this path from the redirect.Installing certbot
The certbot instructions for Nginx recommend the snap package; if certbot is already installed from apt or dnf, remove it first. Take the commands for your OS from certbot.eff.org.
Issuing
certbot --nginxissues a certificate and edits the config for you;certbot certonly --nginxonly issues it, and you add the paths by hand.File paths
Certbot stores certificates in
/etc/letsencrypt/live/<domain>/:fullchain.pemgoes intossl_certificate,privkey.pemintossl_certificate_key.Renewal
Certbot packages install a cron job or a systemd timer; test it with
certbot renew --dry-run. Since Certbot 4.0 a certificate is renewed when less than a third of its lifetime remains.
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot certonly --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runThe redirect is a separate server on port 80 with return 301 https://$host$request_uri;, which keeps the path and query. The test showed Location: https://example.com/api/whoami, and the Let’s Encrypt challenge path returned a 404 from the folder instead of a redirect — exactly right until certbot puts a token file there.
Security headers
# always: also add the headers to error responses (4xx/5xx)
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;server level and in every location that has its own add_headerStrict-Transport-Security
- Value
max-age=31536000- What it does
- For a year the browser uses only HTTPS for the domain, even if you type
http://
X-Content-Type-Options
- Value
nosniff- What it does
- The browser doesn’t guess a file type against
Content-Type
X-Frame-Options
- Value
DENY- What it does
- The page can’t be framed — clickjacking protection; the modern alternative is
frame-ancestorsin CSP
Referrer-Policy
- Value
strict-origin-when-cross-origin- What it does
- Other sites receive only the origin, without path and query
Permissions-Policy
- Value
geolocation=(), camera=(), microphone=()- What it does
- Turns off browser features the site doesn’t need
server_tokens off removes the Nginx version from the Server header and error pages — the test shows Server: nginx with no number. Content-Security-Policy is deliberately not set here: the policy depends on the app’s scripts and styles and is usually produced by the app itself. OWASP advises not setting X-XSS-Protection at all or disabling it with 0.
Testing: nginx -t, curl and logs
Start the stand and check it step by step. The --resolve flag points example.com at the local address so that server_name matches, and -k allows the self-signed certificate. Below is the real output of a run; the docker compose up output is omitted.
$ docker compose up -d
$ docker compose exec proxy nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ R="--resolve example.com:8080:127.0.0.1 --resolve example.com:8443:127.0.0.1"
$ curl -sI $R http://example.com:8080/api/whoami | grep -iE '^(HTTP|Server|Location)'
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://example.com/api/whoami
$ curl -sk -D - $R https://example.com:8443/api/whoami
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 12 Oct 2026 01:40:15 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
{"version":"v1","host":"example.com","realIp":"192.168.32.1","forwardedFor":"192.168.32.1","forwardedProto":"https","peer":"::ffff:192.168.32.3"}
$ curl -sk $R -H "X-Forwarded-For: 203.0.113.7" https://example.com:8443/api/whoami
{"version":"v1","host":"example.com","realIp":"192.168.32.1","forwardedFor":"203.0.113.7, 192.168.32.1","forwardedProto":"https","peer":"::ffff:192.168.32.3"}Host, IP and scheme, the security headers are in place$ curl -sk -o /dev/null -D - -H "Accept-Encoding: gzip" $R https://example.com:8443/assets/app.3f9c2b.js | grep -iE '^(HTTP|Content-Type|Content-Encoding|Cache-Control|Vary|Strict|X-Frame)'
HTTP/1.1 200 OK
Content-Type: application/javascript
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000
X-Frame-Options: DENY
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: gzip
$ curl -sk -o /dev/null -w "plain %{size_download} bytes\n" $R https://example.com:8443/assets/app.3f9c2b.js
plain 9801 bytes
$ curl -sk -o /dev/null -w "gzip %{size_download} bytes\n" -H "Accept-Encoding: gzip" $R https://example.com:8443/assets/app.3f9c2b.js
gzip 1108 bytes
$ curl -sk -o /dev/null -D - $R https://example.com:8443/assets/missing.js | grep -iE '^(HTTP|Cache-Control|Strict)'
HTTP/1.1 404 Not Found
Strict-Transport-Security: max-age=31536000$ head -c 6291456 /dev/zero > big.bin
$ curl -sk -o /dev/null -w "HTTP %{http_code}\n" $R --data-binary @big.bin https://example.com:8443/api/upload
HTTP 413
$ curl -sk -o /dev/null -w "HTTP %{http_code} after %{time_total}s\n" $R "https://example.com:8443/api/slow?ms=35000"
HTTP 504 after 30.038574s
$ docker compose exec proxy curl -sk --resolve example.com:443:127.0.0.1 -o /dev/null -w "HTTP/%{http_version} %{http_code}\n" https://example.com/api/whoami
HTTP/2 200
$ docker compose logs proxy 2>&1 | grep -E '\[error\]' | tail -2
proxy-1 | 2026/10/12 01:40:15 [error] 25#25: *11 client intended to send too large body: 6291456 bytes, client: 192.168.32.1, server: example.com, request: "POST /api/upload HTTP/1.1", host: "example.com:8443"
proxy-1 | 2026/10/12 01:40:45 [error] 28#28: *12 upstream timed out (110: Operation timed out) while reading response header from upstream, client: 192.168.32.1, server: example.com, request: "GET /api/slow?ms=35000 HTTP/1.1", upstream: "http://192.168.32.2:3000/api/slow?ms=35000", host: "example.com:8443"proxy_read_timeout gives 504 after exactly 30 seconds; the error log shows whyAlways run nginx -t before reloading. A reload is graceful: the master process re-reads the config, keeps the old one if it’s broken, and otherwise starts new worker processes while the old ones finish serving their current connections. In the official image the logs go to the container’s stdout and stderr, so you read them with docker compose logs; on a server without Docker they live in /var/log/nginx/ by default.
$ docker compose exec proxy nginx -t
2026/10/12 01:40:47 [emerg] 60#60: unexpected "}" in /etc/nginx/conf.d/app.conf:59
nginx: [emerg] unexpected "}" in /etc/nginx/conf.d/app.conf:59
nginx: configuration file /etc/nginx/nginx.conf test failed
$ docker compose exec proxy nginx -t && docker compose exec proxy nginx -s reload
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/12 01:40:48 [notice] 70#70: signal process startednginx -t names the file and the line; after the fix — a check and a graceful reload502 Bad Gateway— the app isn’t running or listens on the wrong port; inside a container it must listen on all interfaces, not only127.0.0.1504 Gateway Time-out— the response takes longer thanproxy_read_timeout; move long operations to background jobs instead of raising the timeout for everyone413 Request Entity Too Large— the body exceedsclient_max_body_size, 1 MB by default- An endless redirect — the app redirects to HTTPS itself because it doesn’t receive
X-Forwarded-Proto - Security headers missing on some responses —
add_headerinheritance kicked in, oralwaysis missing - The app sees an internal network IP instead of the client —
X-Real-IPandX-Forwarded-Foraren’t passed
How this was verified5 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.