How to buildOctober 11, 20269 min read
Docker and deploymentStep 6 of 8

Nginx as a reverse proxy: HTTPS, static files, headers

In short

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.

How-toLevel: IntermediateNginxPractice: ~1–2 h

After this you will be able to

  • Configure server blocks with proxy_pass that pass Host, X-Real-IP and X-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_header inheritance
  • Validate the config with nginx -t, reload it without downtime and trace 413 and 504 errors in the logs

Verified with

Nginx 1.30.5 (stable)Node.js 24 LTSDocker Compose v5

Checked against docs: October 11, 2026

Contents
  1. 01What a reverse proxy does
  2. 02The test stand: Nginx and Node.js on one Docker network
  3. 03The config: server, location and proxy_pass
  4. 04Static files, cache headers and gzip
  5. 05HTTPS: a Let’s Encrypt certificate, certbot and the redirect
  6. 06Security headers
  7. 07Testing: nginx -t, curl and logs

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.

  1. 01Browser
  2. 02Nginx :443
  3. 03Node.js :3000
The client only talks to Nginx; the app is reachable from the internal network only.
  • 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.

compose.yaml
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:ro
Two services: the app with no published ports and Nginx on ports 8080 and 8443 of the developer machine
app/server.mjs
import { 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));
});
The test app: /api/whoami shows the headers that reached Node.js, /api/slow answers with a delay, /api/upload counts body bytes

HTTPS 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.

bash
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.pem
A 30-day self-signed certificate for example.com and www.example.com — for local testing only

Put 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.

nginx/conf.d/app.conf
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;
    }
}
The full stand config: HTTP redirect, HTTPS server, static files and proxying to Node.js

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 from proxy_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 Secure cookies

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.

  1. 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.

  2. 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.

  3. Issuing

    certbot --nginx issues a certificate and edits the config for you; certbot certonly --nginx only issues it, and you add the paths by hand.

  4. File paths

    Certbot stores certificates in /etc/letsencrypt/live/<domain>/: fullchain.pem goes into ssl_certificate, privkey.pem into ssl_certificate_key.

  5. 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.

bash
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-run
Commands from the certbot instructions (Nginx, snap) for a server with a public domain. We did not run them on the local stand: Let’s Encrypt validates a real domain

The 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

nginx/snippets/security-headers.conf
# 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;
The snippet is included at the server level and in every location that has its own add_header

Strict-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-ancestors in 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.

terminal
$ 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"}
The config is valid, HTTP goes to HTTPS, the app sees the original Host, IP and scheme, the security headers are in place
terminal
$ 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
Nginx serves the static file: gzip, a one-year cache and security headers; the 404 gets no cache header
terminal
$ 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"
The limits work: 6 MB against a 5 MB limit gives 413, a response slower than proxy_read_timeout gives 504 after exactly 30 seconds; the error log shows why

Always 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.

terminal
$ 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 started
A missing semicolon: nginx -t names the file and the line; after the fix — a check and a graceful reload
  • 502 Bad Gateway — the app isn’t running or listens on the wrong port; inside a container it must listen on all interfaces, not only 127.0.0.1
  • 504 Gateway Time-out — the response takes longer than proxy_read_timeout; move long operations to background jobs instead of raising the timeout for everyone
  • 413 Request Entity Too Large — the body exceeds client_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_header inheritance kicked in, or always is missing
  • The app sees an internal network IP instead of the client — X-Real-IP and X-Forwarded-For aren’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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.