How to Set Up Nginx for a Node.js Application

How to Set Up Nginx for a Node.js Application

Node.js applications are a bit different from PHP or Java apps in one important way: Node can serve HTTP requests directly, with no separate application server required. So the first question people usually ask me is: “if Node can already handle HTTP requests on its own, why do I need Nginx at all?” It’s a fair question, and I asked it myself the first time I deployed a Node app. The answer is that Node being capable of serving traffic directly doesn’t mean it’s the best thing to expose to the public internet. Here’s how I set it up, and why.

Why Put Nginx in Front of Node.js?

Node’s built-in HTTP server is single-threaded per process and isn’t optimized for things like TLS termination, static file caching, or handling large numbers of slow client connections. Nginx does all of that extremely efficiently and lets your Node process focus purely on running your application logic. Specifically, I use Nginx to:

  • Terminate TLS/SSL so my Node app never has to deal with certificates directly
  • Serve static assets (images, CSS, bundled JS) without touching Node at all
  • Load balance across multiple Node processes (since Node is single-threaded per process, running several instances is how you use multiple CPU cores)
  • Buffer slow client connections so a slow client doesn’t tie up a Node event loop
  • Provide a single, consistent entry point for multiple backend services running on the same box

Requirements

  • A Linux server with Node.js and npm installed
  • Your Node.js application deployed and working locally on a port (I’ll assume 3000, Express’s common default)
  • Nginx installed
  • A process manager for Node — I strongly recommend PM2 over relying on node app.js directly

Install PM2 globally:

sudo npm install -g pm2

Start your app with PM2 instead of running it manually:

cd /var/www/mynodeapp
pm2 start app.js --name mynodeapp
pm2 save
pm2 startup

The pm2 startup command prints a systemd command you need to run once to make PM2 (and your app) survive server reboots. Confirm your app is running:

curl http://127.0.0.1:3000
pm2 status

Step 1: Create the Nginx Server Block

sudo nano /etc/nginx/sites-available/mynodeapp
server {
    listen 80;
    server_name mynodeapp.example.com;

    access_log /var/log/nginx/mynodeapp.access.log;
    error_log /var/log/nginx/mynodeapp.error.log;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header 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_cache_bypass $http_upgrade;
    }

    location /static/ {
        alias /var/www/mynodeapp/public/;
        expires 30d;
        access_log off;
    }
}

The Upgrade/Connection headers here matter a lot for Node apps specifically — a huge number of Node applications use WebSockets (Socket.IO, native ws, real-time dashboards, chat apps), and without these two headers set correctly, WebSocket upgrade requests fail silently and fall back to polling, or just break outright.

Step 2: Enable the Site

sudo ln -s /etc/nginx/sites-available/mynodeapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 3: Reading Forwarded Headers in Express

If your app needs to know the real client IP or whether the original request was HTTPS (for secure cookies, for example), tell Express to trust the proxy:

app.set('trust proxy', 1);

Without this, req.ip will show Nginx’s local IP instead of the real client IP, and req.secure won’t correctly reflect HTTPS even though Nginx is terminating TLS.

Scaling with PM2 Cluster Mode and Nginx Load Balancing

Because Node is single-threaded, one process only uses one CPU core. PM2’s cluster mode spins up one process per core automatically:

pm2 start app.js --name mynodeapp -i max

-i max tells PM2 to fork as many instances as there are CPU cores, and it automatically load-balances between them on the same port — so in many cases, you don’t even need Nginx’s upstream block for this, since PM2 handles it internally.

That said, if you’re running multiple Node apps or want more control (say, spreading instances across different ports for isolation), you can still use Nginx’s upstream for balancing:

upstream node_backend {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

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

    location / {
        proxy_pass http://node_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header 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;
    }
}

Adding HTTPS

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d mynodeapp.example.com

Certbot updates your server block automatically and sets up a redirect from HTTP to HTTPS. Certificate renewal is handled by a systemd timer Certbot installs for you.

Testing Your Setup

  1. sudo nginx -t — validate configuration syntax
  2. pm2 status — confirm your Node process(es) are online
  3. curl -I http://mynodeapp.example.com — confirm you get a response
  4. If using WebSockets, test the upgrade explicitly: open your app in a browser and check the Network tab for a 101 Switching Protocols response on the WebSocket connection
  5. pm2 logs mynodeapp — watch logs live while testing to catch errors immediately

Troubleshooting Common Issues

502 Bad Gateway — Your Node process crashed or isn’t listening on the port Nginx expects. Check pm2 status and pm2 logs mynodeapp for a stack trace.

WebSocket connections dropping or falling back to polling — Confirm the Upgrade and Connection headers are present exactly as shown, and that proxy_http_version 1.1; is set (WebSockets require HTTP/1.1, and Nginx defaults to 1.0 for proxied connections unless you explicitly set this).

App works locally but not through Nginx — Almost always a proxy header issue. Double check proxy_pass points to the exact port your Node app is listening on.

Node app restarts wipe out in-memory session data — If you’re storing sessions in memory and running multiple PM2 instances, users will get logged out randomly as requests bounce between processes. Move sessions to Redis (connect-redis with express-session) so all instances share the same session store.

Static assets not updating after deploy — Check the expires 30d; caching directive; browsers may be aggressively caching old versions. Consider cache-busting filenames (e.g., via a build hash) for static assets that change on deploy.

Security Considerations

  • Never expose your Node app’s port directly to the internet — bind it to 127.0.0.1 only and let Nginx be the sole public-facing service
  • Use helmet in Express to set sensible security headers at the application layer as a complement to Nginx’s own headers
  • Rate limit sensitive routes (login, password reset) with Nginx’s limit_req_zone, in addition to any application-level rate limiting
  • Keep Node.js itself updated — Node LTS versions receive security patches, and older versions eventually lose support entirely
  • Run npm audit regularly and address high-severity findings
  • Never run your Node process as root; PM2 and your app should run as an unprivileged user

Performance Tips

  • Use PM2 cluster mode to use all CPU cores instead of a single Node process
  • Let Nginx serve static files rather than routing them through Express’s static middleware
  • Enable gzip compression in Nginx for JSON/HTML/JS responses
  • Set appropriate cache headers on static assets so browsers don’t re-request unchanged files
  • Use a Redis-backed session store so sessions work correctly across multiple Node instances
  • Monitor event loop lag with PM2’s built-in monitoring or a tool like clinic.js if you suspect a blocking operation is slowing down request handling

Real-World Use Case

I’ve run a real-time notification service in Node using Socket.IO, deployed with PM2 in cluster mode behind Nginx. The WebSocket upgrade headers were the single most important part of that config — without them, the app silently fell back to long-polling and lost the low-latency behavior the whole feature depended on. Once the headers were correct, Nginx handled TLS and thousands of concurrent WebSocket connections without breaking a sweat, while PM2 kept four Node processes running across the server’s CPU cores.

Best Practices Recap

  • Run Node behind a process manager (PM2), never bare node app.js in production
  • Bind Node to 127.0.0.1, never 0.0.0.0, when Nginx is your public entry point
  • Always set the WebSocket upgrade headers if your app uses them, even if you’re not sure yet — it costs nothing to have them ready
  • Use trust proxy in Express so your app sees real client IPs and protocol
  • Let Nginx handle static files and TLS
  • Use Redis for shared session storage across multiple Node instances

Node and Nginx pair well together specifically because they don’t try to do each other’s job — Nginx handles the messy realities of the public internet, and Node just runs your application code. Get the proxy headers right, especially the WebSocket ones if you need them, and the rest of this setup is genuinely straightforward.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Ruby on Rails

How to Set Up Nginx with Ruby on Rails

Next Post
How to Set Up Nginx for a Flask Application

How to Set Up Nginx for a Flask Application

Related Posts