Running a Node.js app directly on port 80 or 443 is possible but not how I’d do it in production. Nginx in front of Node.js gives you SSL termination, static file serving, caching, compression, rate limiting, and request buffering — things Node.js can do but doesn’t do as efficiently as a dedicated web server.
Here’s how I set this up and what I’ve learned from running it.
The Basic Setup
A Node.js app running on port 3000, Nginx proxying to it:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Proxy to Node.js
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;
}
}
Key headers explained:
proxy_http_version 1.1 — required for WebSocket support (upgrade from HTTP/1.1).Upgrade and Connection 'upgrade' — needed for WebSocket proxying.X-Real-IP — passes the actual client IP to Node.js (without this, Node.js sees 127.0.0.1 for all requests).X-Forwarded-For — similar, but adds to the chain (preserves the original IP even through multiple proxies).X-Forwarded-Proto — tells Node.js whether the original request was HTTP or HTTPS. Important for generating correct redirect URLs.
In Node.js: Trusting the Proxy
Your Node.js app needs to know it’s behind a proxy. With Express:
const express = require('express');
const app = express();
// Trust first proxy
app.set('trust proxy', 1);
app.get('/ip', (req, res) => {
res.send(req.ip); // Now returns the real client IP
});
Without trust proxy, req.ip returns 127.0.0.1 (Nginx’s address). With it, Express reads the X-Forwarded-For header.
trust proxy = 1 means trust one proxy hop. Use the actual number of proxy layers in your setup.
Upstream Configuration
For multiple Node.js instances (load balancing):
upstream node_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name example.com;
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
# ... other headers
}
}
keepalive 64 — maintain persistent connections to Node.js instances. This significantly reduces connection overhead. Without keepalives, Nginx creates a new TCP connection for every proxied request.
When using keepalives, set proxy_set_header Connection "" (empty) to remove the Connection: upgrade header for non-WebSocket requests — it conflicts with keepalive semantics.
The default load balancing is round-robin. Other methods:
upstream node_app {
least_conn; # Route to server with fewest active connections
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
least_conn is often better than round-robin for applications with variable request duration.
Serving Static Files Directly
Node.js is inefficient at serving static files compared to Nginx. Serve them directly:
server {
listen 443 ssl http2;
server_name example.com;
# Serve static files directly (no proxy)
location /static/ {
root /var/www/myapp;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /favicon.ico {
root /var/www/myapp/public;
log_not_found off;
access_log off;
}
# Everything else goes to Node.js
location / {
proxy_pass http://127.0.0.1:3000;
# ... proxy headers
}
}
expires 30d sets a 30-day browser cache for static assets. Combined with content hashing in your asset filenames (which bundlers like Webpack/Vite do automatically), you can cache aggressively.
Gzip Compression
Enable compression in Nginx rather than Node.js:
http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
gzip_min_length 256;
}
gzip_proxied any — compress responses from proxied backends.gzip_comp_level 6 — balance between compression ratio and CPU usage. 1–6 is a good range; 9 is maximum compression but much more CPU.gzip_min_length 256 — don’t compress tiny responses (the overhead isn’t worth it below ~256 bytes).
Request Buffering
By default, Nginx buffers the entire request before proxying to Node.js. This protects Node.js from slow clients (clients uploading slowly can tie up Node.js threads):
location / {
proxy_pass http://127.0.0.1:3000;
proxy_buffering on;
proxy_buffers 16 32k;
proxy_buffer_size 64k;
}
For large file uploads, you might want to increase buffer sizes or disable buffering:
location /upload {
proxy_pass http://127.0.0.1:3000;
proxy_request_buffering off;
client_max_body_size 100M;
}
proxy_request_buffering off streams the request body directly to Node.js without buffering. Necessary for large uploads but means Node.js has to handle slow clients directly.
Proxy Timeouts
location / {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
}
proxy_read_timeout — how long to wait for Node.js to start sending a response.proxy_connect_timeout — how long to wait for the connection to Node.js.
For endpoints that do long-running work (report generation, video processing), increase proxy_read_timeout for those specific locations.
Health Checks
upstream node_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
location /health {
proxy_pass http://node_app/health;
proxy_read_timeout 5s;
}
}
Combine with a load balancer health check or monitoring. If Node.js starts returning errors, Nginx’s upstream failure tracking will temporarily route around it.
For automatic removal of failed upstreams, you need Nginx Plus (commercial) or can use the community upstream_check_module. The open-source version just tracks connection failures and uses passive health checking.
Keeping Node.js Running
Process managers keep Node.js running and restart it on crashes:
# PM2 (most popular)
npm install -g pm2
pm2 start app.js --name myapp
pm2 startup
pm2 save
Or a systemd service:
[Unit]
Description=Node.js App
After=network.target
[Service]
User=deploy
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node app.js
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
PM2 also handles multi-core clustering — running multiple Node.js worker processes:
pm2 start app.js -i max # Start one instance per CPU core
Combined with the upstream configuration in Nginx pointing to multiple ports, this fully utilizes available CPU cores.
For SSL configuration on the Nginx side, the setting up SSL in Nginx guide covers the certificate setup in detail.