When I first started deploying Node.js applications to production, I made the classic beginner mistake: I exposed my Node app directly to the internet on port 3000 and called it a day. It worked, technically, but it was a fragile setup. No SSL termination, no load balancing, no protection against slow clients hogging my event loop, and an ugly port number in every URL. The fix, which I’ve used on nearly every Node deployment since, is putting Nginx in front of Node.js as a reverse proxy.
In this guide I’ll walk you through exactly how I set up Nginx with Node.js, why each piece matters, and the mistakes I’ve made along the way so you don’t have to repeat them.
Why Put Nginx in Front of Node.js?
Node.js has a built-in HTTP server, so technically you don’t need Nginx. But relying on Node alone in production has real downsides:
- Node is single-threaded per process. Nginx can distribute load across multiple Node instances running on different ports, which is essential if you want to use all your CPU cores.
- Static file serving is inefficient in Node. Nginx serves static assets (images, CSS, JS) far faster than Node’s
fsmodule ever will. - SSL/TLS termination. It’s much simpler to manage certificates in one place (Nginx) than to wire TLS into every Node app.
- Buffering slow clients. Node’s event loop can get tied up dealing with slow-reading clients. Nginx buffers requests and responses, protecting Node from that.
- Security and hardening. Nginx lets you add rate limiting, IP blocking, and header sanitization without touching your application code.
Basically, Nginx handles the things a web server is good at, and Node focuses purely on your application logic.
Requirements
Before we start, make sure you have:
- A Linux server (I’ll use Ubuntu 22.04/24.04 commands, but this translates easily to CentOS/RHEL with
yum/dnf) - Root or sudo access
- Node.js installed (I recommend using NVM or NodeSource repositories rather than the outdated version in default apt repos)
- Nginx installed
- A domain name pointed at your server (optional but recommended for SSL later)
Installing Node.js
I usually install Node via NodeSource to get a recent LTS version:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v
Installing Nginx
sudo apt update
sudo apt install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx
Verify it’s running by visiting your server’s IP in a browser — you should see the default Nginx welcome page.
Step 1: Build a Simple Node.js Application
Let’s create a minimal Express app to proxy. If you don’t already have a project, here’s one I use for testing:
mkdir ~/myapp && cd ~/myapp
npm init -y
npm install express
Create app.js:
const express = require('express');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
res.send('Hello from Node.js behind Nginx!');
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`App running on http://127.0.0.1:${PORT}`);
});
Notice I bind to 127.0.0.1, not 0.0.0.0. This is intentional — I don’t want Node directly reachable from the outside world. Only Nginx, running on the same machine, should be able to talk to it.
Run it:
node app.js
Test locally:
curl http://127.0.0.1:3000
You should see the “Hello from Node.js” message.
Step 2: Keep Node Running with a Process Manager
Before we hook up Nginx, let’s make sure Node stays alive. I use PM2 for this:
sudo npm install -g pm2
pm2 start app.js --name myapp
pm2 startup
pm2 save
pm2 startup generates a systemd script so PM2 (and your app) restarts automatically on reboot. pm2 save persists the current process list.
Step 3: Configure Nginx as a Reverse Proxy
Now for the core of this guide. Create a new server block config:
sudo nano /etc/nginx/sites-available/myapp
Here’s the configuration I use as a baseline:
server {
listen 80;
server_name example.com www.example.com;
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;
}
}
Let me break down what each directive does, because I’ve seen a lot of copy-pasted configs where people don’t actually know why these lines exist:
proxy_pass http://127.0.0.1:3000;— forwards the request to your Node app.proxy_http_version 1.1;— required for WebSocket support and keep-alive connections.proxy_set_header Upgrade/Connection 'upgrade'— needed if your app uses WebSockets (Socket.IO, etc.).proxy_set_header Host $host;— passes the original hostname to Node, soreq.headers.hostreflects the real domain rather than127.0.0.1.X-Real-IPandX-Forwarded-For— preserve the client’s real IP address, since without these Node would see every request as coming from127.0.0.1.X-Forwarded-Proto— tells your app whether the original request was HTTP or HTTPS, which matters if your app redirects based on protocol.
Enable the site and test the config:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx -t checks your syntax before you reload — always run this before reloading, because a typo can take down your entire site.
Now visit http://example.com in your browser. You should see your Node app’s response, served through Nginx on port 80.
Step 4: Serve Static Files Directly Through Nginx
If your Node app serves static assets, it’s far more efficient to let Nginx handle them directly rather than passing every image request through your Node process:
server {
listen 80;
server_name example.com;
location /static/ {
alias /home/user/myapp/public/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
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;
}
}
This offloads static file serving entirely to Nginx, which is significantly faster at this task than Node’s express.static middleware for high-traffic sites.
Step 5: Add HTTPS with Let’s Encrypt
I never run a production site without HTTPS. Certbot makes this painless:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
Certbot will automatically modify your Nginx config to add SSL directives and set up a redirect from HTTP to HTTPS. It also configures a cron job / systemd timer for automatic renewal. You can test renewal with:
sudo certbot renew --dry-run
After running Certbot, your config will look roughly like this:
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
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;
}
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Testing Your Setup
I always run through this checklist after setting up Nginx + Node:
# Check Nginx config syntax
sudo nginx -t
# Check Nginx is listening
sudo ss -tlnp | grep nginx
# Check Node is only bound locally
sudo ss -tlnp | grep node
# Test HTTP -> HTTPS redirect
curl -I http://example.com
# Test the actual response through HTTPS
curl -I https://example.com
# Check headers reach Node correctly
curl -H "X-Test: 123" https://example.com
I also like to add a debug route in Node temporarily to confirm forwarded headers are arriving correctly:
app.get('/debug', (req, res) => {
res.json({
ip: req.headers['x-real-ip'],
proto: req.headers['x-forwarded-proto'],
host: req.headers['host']
});
});
Troubleshooting Common Issues
502 Bad Gateway — This almost always means Nginx can’t reach your Node app. Check that:
- Node is actually running (
pm2 statusorps aux | grep node) - Node is listening on the port/interface you specified in
proxy_pass - SELinux (on CentOS/RHEL) isn’t blocking the connection — check with
sudo ausearch -m avc -ts recent
504 Gateway Timeout — Your Node app is too slow to respond within Nginx’s default timeout. Increase it:
proxy_read_timeout 90s;
proxy_connect_timeout 90s;
WebSocket connections failing — Double-check you have both proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection 'upgrade'; present, and proxy_http_version 1.1; set.
Static files returning 404 — Verify your alias path ends with a trailing slash matching the location block, since Nginx handles alias path concatenation differently than root.
Config changes not taking effect — You reloaded Nginx (systemctl reload nginx) but forgot the symlink in sites-enabled, or there’s a duplicate server_name in another config file taking precedence.
Security Considerations
A few things I always do on production Node/Nginx setups:
- Never expose Node directly. Bind to
127.0.0.1, not0.0.0.0, and use a firewall (ufw) to block direct access to Node’s port from outside. - Hide the Nginx version. Add
server_tokens off;in thehttpblock ofnginx.confto avoid leaking version info in error pages and headers. - Set security headers. I typically add:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- Limit request body size to prevent abuse:
client_max_body_size 10M;
- Keep both Nginx and Node updated with security patches.
Performance Tips
- Enable gzip compression in
nginx.conf:
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
- Use
keepaliveconnections to your upstream Node processes to avoid the overhead of establishing new TCP connections for every request:
upstream node_backend {
server 127.0.0.1:3000;
keepalive 64;
}
location / {
proxy_pass http://node_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
- Run multiple Node instances behind Nginx for load balancing (via PM2 cluster mode or multiple ports), covered more in the load balancing pattern below.
Real-World Use Case: Load Balancing Multiple Node Instances
If your app gets significant traffic, I run several Node instances and let Nginx distribute load:
upstream node_backend {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 32;
}
server {
listen 443 ssl;
server_name example.com;
location / {
proxy_pass http://node_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
}
}
You can launch multiple instances easily with PM2’s cluster mode instead of managing ports manually:
pm2 start app.js -i max --name myapp
This spawns one Node process per CPU core automatically, and you can point Nginx to a single port since PM2 handles internal load balancing across the cluster.
Best Practices I Follow
- Always bind Node to
127.0.0.1— never expose it directly to the internet. - Use a process manager (PM2, systemd, or Docker) to keep Node alive and restart on crash.
- Terminate SSL at Nginx, not in your Node app — it’s simpler to manage and rotate certificates in one place.
- Serve static assets via Nginx
alias/root, not through Express middleware, for better throughput. - Set proper
proxy_set_headerdirectives so your app sees the real client IP and protocol. - Test config changes with
nginx -tbefore every reload. - Monitor both Nginx and Node logs — issues often show up in one before the other.
- Set sane timeouts and body size limits to protect against abuse.
Wrapping Up
Putting Nginx in front of Node.js isn’t just a “best practice” checkbox — it genuinely solves real operational problems: SSL management, static file performance, load balancing, and protecting your app from being directly exposed to the internet. Once you’ve set this up a couple of times, it becomes second nature, and it’s the first thing I do whenever I spin up a new Node deployment.
If you’re just getting started, I’d suggest replicating this exact setup on a test VPS: get a basic Express app running, proxy it through Nginx, add Let’s Encrypt, and then experiment with the load balancing and caching directives. There’s no substitute for actually breaking and fixing your own Nginx config a few times — that’s genuinely how you learn what each directive is doing.