MervCodes

Tech Reviews From A Programmer

Nginx Reverse Proxy Setup Guide for Node.js and Next.js Apps

1 min read

Nginx Reverse Proxy Setup Guide for Node.js and Next.js Apps

I'll be honest — every time I set up a new VPS, the Nginx config is the part I have to think about the most. Not because it's hard exactly, but because the details matter and getting them wrong means debugging production issues at 2 AM. After doing this dozens of times across different projects, I've settled into a pattern that works reliably. Here's everything I know about putting Nginx in front of Node.js and Next.js apps.

Why Use Nginx as a Reverse Proxy

You can run your Node.js app directly on port 80. I've done it. It works. But it's not great for production, and here's why.

Node.js is single-threaded. It handles async I/O like a champ, but it wasn't built to serve static files efficiently, manage SSL certificates, or deal with thousands of slow connections from mobile clients on bad networks. Nginx was built for exactly those things. Its event-driven architecture can handle tens of thousands of concurrent connections while barely touching your RAM.

When Nginx sits in front of your app, it absorbs slow clients, serves static assets directly from disk, terminates SSL so your app never touches certificates, and gives you a clean way to load-balance across multiple instances. Honestly, once you've set it up, you wonder why you'd ever run Node.js naked on the internet.

Prerequisites

Before we dig in, you'll need:

  • A Linux server (I'm using Ubuntu 22.04 here, but the concepts work on any distro)
  • Node.js 18+ installed
  • Your app ready to go
  • Root or sudo access
  • A domain name pointed to your server's IP (for the SSL part)

Installing Nginx

On Ubuntu/Debian:

sudo apt update
sudo apt install nginx -y

Start it up and enable it on boot:

sudo systemctl start nginx
sudo systemctl enable nginx

Hit your server's IP in a browser. If you see the Nginx welcome page, you're good.

Running Your Node.js App

Your app should listen on a local port — 3000 is the convention, but anything above 1024 works. Here's the important bit:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello from Node.js');
});

app.listen(3000, '127.0.0.1', () => {
  console.log('Server running on http://127.0.0.1:3000');
});

Bind to 127.0.0.1, not 0.0.0.0. This is a mistake I see constantly. If you bind to 0.0.0.0, your app is accessible directly on port 3000 — bypassing Nginx entirely. By binding to localhost, you force all external traffic through Nginx where it belongs.

For Next.js:

next build
next start -p 3000 -H 127.0.0.1

Basic Nginx Reverse Proxy Configuration

Create a new site config:

sudo nano /etc/nginx/sites-available/myapp

Here's what I use as my starting point:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.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;
    }
}

Enable it and reload:

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

Always run nginx -t before reloading. I learned this the hard way. One syntax error and Nginx refuses to start, taking down every site on your server. The -t flag catches that before any damage is done.

Understanding the Proxy Headers

Each header has a specific job, and I've been bitten by leaving them out:

  • proxy_http_version 1.1 — needed for WebSocket connections and keepalive. Without it you're stuck on HTTP/1.0 to the backend.
  • Upgrade and Connection — these pass WebSocket connections through. Critical for Next.js hot module replacement in dev, and for any real-time features in prod.
  • Host — preserves the original hostname. Without this, your app thinks every request is coming from localhost.
  • X-Real-IP — passes the actual client IP. Otherwise your app logs just show 127.0.0.1 for every request, which makes debugging useless.
  • X-Forwarded-For — builds a chain of all proxies the request passed through.
  • X-Forwarded-Proto — tells your app whether the original request was HTTP or HTTPS. You need this for secure cookies and HTTPS redirect logic.

Next.js-Specific Configuration

Next.js apps have extra needs. They serve static assets from /_next/static/ and you can get a real performance win by having Nginx serve those directly instead of proxying them through Node.js.

server {
    listen 80;
    server_name yourdomain.com;

    # Serve Next.js static assets directly
    location /_next/static/ {
        alias /var/www/myapp/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }

    # Serve public directory assets
    location /static/ {
        alias /var/www/myapp/public/static/;
        expires 30d;
        access_log off;
    }

    # Proxy everything else to Next.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;
    }
}

Next.js hashes its static filenames, so setting a 365-day cache with immutable is totally safe — the filename changes whenever the content changes. This takes a real load off your Node.js process.

You should also configure Next.js to trust the proxy. In newer versions, add "trustHost": true in your next.config.js.

Adding SSL with Let's Encrypt

Every production app needs HTTPS. Certbot makes this embarrassingly simple:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot rewrites your Nginx config to add the SSL bits, sets up HTTP-to-HTTPS redirect, and installs a cron job for auto-renewal. Verify the renewal works:

sudo certbot renew --dry-run

That's it. Free SSL, auto-renewing, zero maintenance. We live in good times.

Production Hardening

The basic config works, but for production you'll want some extra settings. I add these to either the http block in /etc/nginx/nginx.conf or inside the server block:

# Increase buffer sizes for large headers (common with JWTs)
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;

# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;

# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

Apply rate limits to specific locations:

location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://127.0.0.1:3000;
    # ... other proxy headers
}

The buffer size thing is one I always forget about until I see 502 errors with JWT-heavy apps. JWTs can be huge, and the default Nginx buffer is too small for them.

Load Balancing Multiple Instances

If you're running multiple Node.js processes with PM2 (which you should be in production), Nginx can distribute traffic across them:

upstream nodejs_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 yourdomain.com;

    location / {
        proxy_pass http://nodejs_backend;
        # ... proxy headers
    }
}

least_conn sends each new request to the backend with the fewest active connections. There's also ip_hash for sticky sessions (if you need them) and plain round-robin (the default).

Process Management with PM2

Your app needs a process manager to survive crashes and reboots. PM2 is the standard:

npm install -g pm2

# For Node.js
pm2 start app.js --name myapp -i max

# For Next.js
pm2 start npm --name myapp -- start

# Save the process list and set up startup script
pm2 save
pm2 startup

The -i max flag runs one instance per CPU core, which pairs nicely with the Nginx upstream config.

Practical Tips

Always test config changes before reloading. nginx -t then systemctl reload nginx. A bad config takes down everything.

Tail the error log while debugging. tail -f /var/log/nginx/error.log has saved me more times than I can count.

Use separate log files per site. Add access_log /var/log/nginx/myapp.access.log; and error_log /var/log/nginx/myapp.error.log; to your server block.

Increase client_max_body_size if you accept uploads. The default 1MB limit is tiny. Set client_max_body_size 50m; or whatever makes sense for your app.

Add server_tokens off; to your http block. No reason to advertise your Nginx version to the world.

Keep Node.js bound to localhost. I mentioned this earlier but it bears repeating. Never expose your app port to the internet directly.

Set up a health endpoint. Add a /health route in your app and monitor it. You want to know when things break before your users do.

Troubleshooting Common Issues

502 Bad Gateway — Nginx can't reach your app. Check that it's running on the expected port. Run curl http://127.0.0.1:3000 from the server to verify.

504 Gateway Timeout — Your app is taking too long to respond. Either increase proxy_read_timeout or figure out why your endpoint is slow.

WebSocket connections failing — Almost always missing Upgrade and Connection headers. Go back and check they're both set.

Mixed content warnings after adding SSL — Your app is generating HTTP URLs instead of HTTPS. Make sure the X-Forwarded-Proto header is set and your app is reading it to construct URLs.

FAQ

Q: Can I use Nginx with Next.js standalone output mode? A: Yep. Set output: 'standalone' in next.config.js, and the build produces a minimal server at .next/standalone/server.js. Point PM2 at that file, configure Nginx exactly as shown here. It listens on port 3000 by default.

Q: Should I use Nginx or Caddy? A: Caddy is simpler — it handles SSL automatically with zero config. But Nginx gives you more control, better docs, a bigger community, and better performance under extreme load. For most Node.js deployments, both work great. I reach for Nginx because I already know it.

Q: Do I need Nginx if I deploy to Vercel or a container platform? A: Nope. Vercel, AWS App Runner, Cloud Run — they all provide their own reverse proxy and SSL termination. Nginx is for when you're managing your own VPS or server.

Q: How do I handle multiple Node.js apps on one server? A: Create separate Nginx server blocks for each domain, each proxying to a different local port. App A on port 3000, App B on port 3001, with separate config files in /etc/nginx/sites-available/.

Q: Is HTTP/2 supported? A: After adding SSL with Certbot, just change listen 443 ssl; to listen 443 ssl http2;. The proxy connection to your backend stays HTTP/1.1, which is fine — the performance benefit of HTTP/2 is on the client-to-Nginx connection.

Q: How do I update Nginx without downtime? A: Use sudo systemctl reload nginx instead of restart. Reload starts new workers with the updated config while existing connections finish on the old workers. Zero downtime.

Q: What if my app uses Server-Sent Events (SSE)? A: Add proxy_buffering off; to the location block serving SSE endpoints. Otherwise Nginx buffers the response and your events won't stream in real time.

Wrapping Up

Nginx in front of Node.js or Next.js is the battle-tested production setup for a reason. You get the flexibility of Node with the performance and security of Nginx at the network edge. Start with the basic proxy config, add SSL with Certbot, then layer on static file serving, compression, and rate limiting as your traffic grows. This setup will handle thousands of concurrent users on modest hardware without breaking a sweat.

Sources

Related Articles

AI Code Review: The Complete Guide for Engineering Teams (2026)

A definitive, practical guide to AI code review in 2026 — how it works, where it helps and where it doesn't, how to roll it out, prompt and config patterns, security trade-offs, and the metrics that prove it's working.

AI Embeddings: Practical Applications for Developers

AWS S3 and CloudFront for Static Site Hosting