How to Configure Nginx as a Reverse Proxy for a Node.js App

If your Node.js app is still answering requests directly on port 3000, you are missing TLS termination, static file caching, request buffering, rate limiting and a clean way to run several apps on one machine. An Nginx reverse proxy for Node.js solves all of that in about twenty lines of configuration.

This tutorial is not a copy of the usual three-line proxy_pass snippet. You get a complete production server block, an explanation of every directive, correct WebSocket upgrade handling, SSL termination, and the three errors that break 90% of setups (502 Bad Gateway, wrong Host headers, port conflicts) with the exact commands to diagnose them.

Why put Nginx in front of Node.js at all?

Node.js is single-threaded per process and excellent at application logic, but it is not the best tool for the jobs a mature web server has done for twenty years. Nginx acts as the traffic cop in front of your process:

  • SSL/TLS termination: certificates, HTTP/2 and HSTS live in Nginx, not in your JavaScript code.
  • Static assets: images, CSS and bundles are served from disk by Nginx, without waking up the event loop.
  • Port 80/443 binding: your Node process runs unprivileged on a high port on localhost.
  • Slow client protection: Nginx buffers slow requests and responses so a bad mobile connection does not tie up a Node handler.
  • Multiple apps, one IP: virtual hosts route api.example.com and app.example.com to different ports.
  • Zero-downtime deploys and load balancing: an upstream block with several Node instances or a blue/green swap.

Performance note: the extra hop costs a fraction of a millisecond on loopback. In exchange you get keepalive pooling, gzip/brotli, caching and connection buffering, which is almost always a net win on real traffic.

nginx server

What you need before you start

  • A Linux server (Ubuntu 24.04/26.04 LTS, Debian 12/13, Rocky/Alma 9 or similar) with root or sudo access.
  • Node.js 22 LTS or 24 LTS installed and an app that listens on a local port.
  • Nginx 1.25 or newer (needed for the modern http2 on; directive).
  • A domain name with an A/AAAA record pointing to the server, if you want HTTPS.
  • Ports 80 and 443 open in your firewall or cloud security group.

Step 1: make your Node.js app listen on localhost only

The single most important line in your app is the bind address. Listening on 0.0.0.0 exposes port 3000 to the whole internet and lets people bypass Nginx entirely.

// server.js
const express = require('express');
const app = express();

// Trust the proxy so req.ip and req.protocol come from X-Forwarded-* headers
app.set('trust proxy', 'loopback');

app.get('/', (req, res) => {
  res.json({ ip: req.ip, proto: req.protocol, host: req.hostname });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, '127.0.0.1', () => {
  console.log(`Listening on http://127.0.0.1:${PORT}`);
});

Two things matter here:

  1. app.listen(PORT, '127.0.0.1') binds to loopback, so only Nginx can reach it.
  2. app.set('trust proxy', 'loopback') tells Express to read X-Forwarded-For and X-Forwarded-Proto. Without it, every visitor looks like 127.0.0.1 and req.secure is always false, which breaks secure cookies and redirect logic.

Quick check:

curl -i http://127.0.0.1:3000/

If that does not return 200 from the server itself, Nginx will never work. Fix the app first.

Step 1b: keep the process alive with systemd

A reverse proxy is useless if the backend dies on logout. Create /etc/systemd/system/nodeapp.service:

[Unit]
Description=Node.js app behind Nginx
After=network.target

[Service]
Type=simple
User=nodeapp
WorkingDirectory=/var/www/app
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemctl status nodeapp

PM2 works too, but systemd needs no extra dependency and integrates with journalctl -u nodeapp -f for logs.

Step 2: install Nginx

Debian and Ubuntu

sudo apt update
sudo apt install nginx -y
sudo systemctl enable --now nginx
nginx -v

Rocky Linux, AlmaLinux, RHEL

sudo dnf install nginx -y
sudo systemctl enable --now nginx
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload

Visit http://your-server-ip/. The default Nginx welcome page confirms the install. Config files live in:

Distribution Where to put your server block Enable it
Debian / Ubuntu /etc/nginx/sites-available/app.conf symlink into sites-enabled/
RHEL family /etc/nginx/conf.d/app.conf loaded automatically
nginx server

Step 3: the WebSocket upgrade map (do this first)

Create /etc/nginx/conf.d/upgrade.conf so the map is defined once in the http context and reusable by every site:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      "";
}

Important detail most tutorials get wrong: the common snippet maps the empty value to close. That works, but it also closes the connection to your Node backend on every normal HTTP request, which disables upstream keepalive. Mapping to an empty string clears the Connection header instead, so plain requests reuse pooled connections and WebSocket requests still get Connection: upgrade.

Step 4: the production-ready Nginx reverse proxy config

This is the file you can copy. Replace app.example.com, the port, and the static path.

upstream node_app {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    keepalive 64;
}

# HTTP: redirect everything to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS: SSL termination + reverse proxy
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    server_tokens off;

    client_max_body_size 25m;
    client_body_timeout 30s;

    access_log /var/log/nginx/app.access.log;
    error_log  /var/log/nginx/app.error.log warn;

    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;

    # Static files straight from disk, never through Node
    location /static/ {
        alias /var/www/app/public/;
        access_log off;
        expires 30d;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Long-lived WebSocket endpoint
    location /socket.io/ {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        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_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    # Everything else goes to Node.js
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;

        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_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $server_port;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;

        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;

        proxy_buffering on;
        proxy_buffers 16 16k;
        proxy_busy_buffers_size 32k;
        proxy_redirect off;
        proxy_next_upstream error timeout http_502 http_503;
    }
}

Step 5: every directive explained

The upstream block

Directive What it does
server 127.0.0.1:3000 Points at your Node process. Add more server lines to load balance across cluster workers or PM2 instances.
max_fails / fail_timeout Marks a backend unhealthy after 3 failures for 10 seconds, so a crashing worker is skipped.
keepalive 64 Keeps up to 64 idle connections per worker to Node, removing TCP handshake cost on every request. Requires proxy_http_version 1.1 and a cleared Connection header.

The proxy headers (this is where bugs hide)

  • Host $host: forwards the hostname the visitor typed. Without it, Nginx sends Host: node_app (the upstream name) and your app generates broken absolute URLs, failed redirects and wrong multi-tenant routing. Use $host, not $http_host, so a missing header falls back to server_name.
  • X-Real-IP $remote_addr: the client IP as a single value, handy for logging.
  • X-Forwarded-For $proxy_add_x_forwarded_for: appends the client IP to any existing chain, which is what CDNs and Express expect.
  • X-Forwarded-Proto $scheme: tells Node the original request was HTTPS. This is what makes secure cookies, OAuth callbacks and req.protocol behave correctly behind SSL termination.
  • X-Forwarded-Host / X-Forwarded-Port: useful for frameworks that rebuild canonical URLs.
  • Upgrade / Connection: the two headers that let an HTTP request become a WebSocket. They rely on the map from step 3.

Timeouts, buffers and body size

  • proxy_connect_timeout 5s: fail fast if Node is not accepting connections.
  • proxy_read_timeout 60s: raise it for long reports or SSE streams; the default 60s is what produces “504 Gateway Time-out” on slow endpoints.
  • proxy_buffering on: Nginx collects the response and feeds slow clients, freeing your event loop. Turn it off for Server-Sent Events, streamed downloads and WebSockets.
  • client_max_body_size 25m: Nginx defaults to 1 MB, so uploads fail with 413 Request Entity Too Large before they ever reach Multer or Busboy.
  • proxy_redirect off: keeps Nginx from rewriting Location headers your app already built correctly.

SSL termination lines

  • listen 443 ssl; plus http2 on;: HTTP/2 over TLS on Nginx 1.25+. On older builds you would write listen 443 ssl http2;.
  • ssl_protocols TLSv1.2 TLSv1.3;: drops the deprecated protocols that fail security scans.
  • ssl_session_cache: reuses TLS sessions, cutting handshake cost for returning visitors.
  • Strict-Transport-Security: forces browsers to use HTTPS. Add it only once you are sure HTTPS works on every subdomain you include.
nginx server

Step 6: enable the site and test the config

# Debian/Ubuntu only
sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/app.conf
sudo rm -f /etc/nginx/sites-enabled/default

# Always validate before reloading
sudo nginx -t
sudo systemctl reload nginx

nginx -t catches typos, duplicate server_name entries and missing certificate files. reload applies changes without dropping live connections, unlike restart.

Step 7: get the certificate (SSL termination in practice)

If you do not have a certificate yet, start with the HTTP-only server block, then let Certbot install the TLS parts:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com -d www.app.example.com
sudo systemctl list-timers | grep certbot

Certbot edits your server block, adds the redirect and installs a renewal timer. Verify renewal with:

sudo certbot renew --dry-run

Then confirm the headers reach Node:

curl -s https://app.example.com/ | jq
# expect the real client IP and "proto": "https"

The three errors that break most Node.js reverse proxies

Symptom Real cause Fix
502 Bad Gateway Nginx cannot reach the backend: app crashed, wrong port, bound to a different interface, or SELinux blocking the socket Check the app, the port and the policy (details below)
Broken redirects, wrong domain, 127.0.0.1 in logs Missing or wrong Host and X-Forwarded-* headers, or no trust proxy in the app Set the headers as shown and trust the proxy
nginx: [emerg] bind() to 0.0.0.0:80 failed Port conflict: Apache, another Nginx instance, or Node itself is already on 80/443 Find the process and free the port

1. Fixing 502 Bad Gateway

The Nginx error log always names the cause. Read it first:

sudo tail -n 50 /var/log/nginx/app.error.log
  • “connect() failed (111: Connection refused)”: nothing is listening. Run sudo systemctl status nodeapp and sudo ss -tulpn | grep 3000. If the app is down, check journalctl -u nodeapp -n 100.
  • Port mismatch: your app logs 8080 but proxy_pass says 3000. Align them and reload Nginx.
  • Wrong bind address: the app listens on ::1 only while Nginx dials 127.0.0.1 (or the reverse). Use proxy_pass http://127.0.0.1:3000; with an explicit IP instead of localhost to remove IPv6 ambiguity.
  • SELinux (RHEL, Rocky, Alma): the log shows “(13: Permission denied)”. Fix with sudo setsebool -P httpd_can_network_connect 1.
  • “upstream sent too big header”: large cookies or JWTs. Add proxy_buffer_size 16k; and proxy_buffers 8 16k;.
  • 502 only under load: Node is saturated or crashing. Add instances to the upstream block and use proxy_next_upstream.

Quick isolation test: curl -i http://127.0.0.1:3000/ from the server. If curl works and Nginx does not, the problem is in the Nginx config or the security policy, never in your JavaScript.

2. Fixing wrong Host and forwarded headers

Symptoms: login redirects send users to http://localhost:3000, every visitor is logged as 127.0.0.1, rate limiting blocks everyone at once, secure cookies are never set, or OAuth callbacks fail. Background reading: https://hostperl.com.

  1. Make sure these three lines exist in the location block: proxy_set_header Host $host;, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, proxy_set_header X-Forwarded-Proto $scheme;.
  2. Remember that proxy_set_header is not inherited once you declare a single proxy_set_header inside a nested block. If you add headers in /socket.io/, repeat all of them there.
  3. In Express, add app.set('trust proxy', 'loopback') (or 1 for one hop). In Fastify, use { trustProxy: true }. In Next.js standalone or NestJS, enable the equivalent option.
  4. Debug what actually arrives with a temporary route that dumps req.headers, then compare with curl -H "Host: app.example.com".

Also avoid double slashes: if you write proxy_pass http://node_app/; with a trailing slash inside location /api/, Nginx strips the prefix. Both behaviours are valid, but pick the one your routes expect and stay consistent.

3. Fixing port conflicts

sudo ss -tulpn | grep -E ':(80|443|3000)\\b'
sudo nginx -t
sudo journalctl -u nginx -n 30 --no-pager

Common cases and cures:

  • Apache holds port 80: sudo systemctl disable --now apache2 (Debian) or httpd (RHEL), or move Apache to another port.
  • Two server blocks listen on the same port with the same server_name: Nginx warns about a conflicting server name and ignores one. Remove the duplicate, usually the leftover default site.
  • Node itself grabbed 80: change it to 3000 and let Nginx own 80/443.
  • Two Node apps on the same port: give each its own port (3000, 3001, 3002) and one upstream block per app.
  • Docker published the port: docker ps then remap with -p 127.0.0.1:3000:3000 so the container is reachable only from the host.
nginx server

Bonus hardening: rate limiting and a health check

Add to the http context:

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

Then inside the server block:

location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    proxy_pass http://node_app;
    proxy_http_version 1.1;
    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_set_header Connection $connection_upgrade;
}

location = /healthz {
    access_log off;
    proxy_pass http://node_app;
    proxy_set_header Host $host;
}

Final deployment checklist

  1. Node app listens on 127.0.0.1 and restarts automatically.
  2. trust proxy is enabled in the framework.
  3. The map $http_upgrade block exists once in the http context.
  4. nginx -t passes with no warnings.
  5. HTTP redirects to HTTPS and the certificate renews automatically.
  6. client_max_body_size matches your largest upload.
  7. Static assets are served by Nginx, not by Node.
  8. Access and error logs are per site so debugging is fast.
  9. Only 22, 80 and 443 are reachable from outside.

FAQ

Do I still need Nginx if my Node app runs in Docker or behind a cloud load balancer?

A cloud load balancer can handle TLS and health checks, so Nginx becomes optional there. It is still useful inside the stack for static caching, per-path routing, rate limiting and header normalization, and it keeps your config portable if you move providers.

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

A forward proxy sits in front of clients and hides them from the internet. A reverse proxy sits in front of servers: the client talks to Nginx, and Nginx talks to your Node.js process on localhost.

Why does my WebSocket connection fail with a 400 or drop after 60 seconds?

A 400 usually means the Upgrade and Connection headers are missing or proxy_http_version is still 1.0. Drops after about a minute come from proxy_read_timeout. Set both to a long value on the WebSocket location and disable proxy_buffering there.

Should I use proxy_pass with 127.0.0.1 or a Unix socket?

A Unix socket (proxy_pass http://unix:/run/app.sock;) avoids the TCP stack and is slightly faster on a single host. TCP on loopback is easier to debug with curl and works when the app moves to another machine. Both are production-safe.

How do I run several Node.js apps on the same server?

Give each app its own port and its own upstream plus server block with a distinct server_name. For a single domain, use path-based location /app1/ and location /app2/ blocks pointing at different upstreams. logrocket.com walks through the specifics.

Does the reverse proxy slow down my API?

The added latency on loopback is typically well under a millisecond. Upstream keepalive, gzip, response buffering and static offloading usually make the whole system faster under real concurrency than exposing Node directly.

Nginx or Apache in front of Node.js?

Both work. Nginx uses an event-driven model that handles many idle and slow connections with less memory, and its proxy_pass plus WebSocket support is the de facto standard for Node deployments. Choose Apache mainly if you already rely on its modules or .htaccess workflows.

Wrapping up

A solid Nginx reverse proxy for Node.js comes down to five things: bind your app to localhost, keep it supervised, forward the right headers, handle the WebSocket upgrade properly, and terminate TLS in Nginx. Copy the server block above, adapt the domain and port, run nginx -t, reload, and you have a setup that survives real traffic.

Need this deployed, hardened and monitored on your own infrastructure? The team at GeminiWeb builds and maintains production Node.js hosting stacks, from the Nginx layer to zero-downtime deploys. dev.to walks through the specifics.

Search

Recent Blog

  • All Post
  • Email Marketing
  • Responsive Website
  • SEO
  • Social Media Marketing
  • Web Design
  • Web Development

Subscribe

You have been successfully Subscribed! Ops! Something went wrong, please try again.

Company Name

Gemini Web

Company Address

3444 Hall Valley Drive, Davy, WV 24828 USA

Company Email

[email protected]

Copyright © 2022 Gemini Web. All Rights Reserved.