How to Set Up Nginx for a Node.js API

How to Set Up Nginx for a Node.js API

Node.js’s http module is genuinely good at handling concurrent connections — that’s Node’s whole selling point. So it’s fair to ask: why put Nginx in front of a Node API at all, when Node can already listen on port 80 or 443 directly? I’ve deployed Node APIs both ways, and after enough production incidents, I always land back on the same answer: Nginx handles the edge-of-network concerns so your Node process can focus entirely on application logic. TLS termination, static file serving, request buffering, rate limiting, and process supervision are all things Nginx does well and Node, left alone, does not do particularly well or at all.

This guide walks through setting up a production Node.js API behind Nginx, using Express as the example framework (though this applies identically to Fastify, Koa, NestJS, or a bare http server — Nginx doesn’t care what’s generating the responses).

Requirements

  • Ubuntu 22.04/24.04 server
  • Node.js installed (I recommend via nvm for easy version management, or the NodeSource repo for a system-wide install)
  • A Node.js API application, listening on a local port (I’ll use 3000)
  • PM2 or systemd for process management (I’ll cover both, but recommend PM2 for Node specifically since it’s purpose-built for this)

Step 1: Install Node.js

Using NodeSource for a clean system install of a current LTS release:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install nodejs -y
node -v
npm -v

Step 2: Deploy Your Application

sudo mkdir -p /var/www/myapi
sudo chown $USER:$USER /var/www/myapi
cd /var/www/myapi
# copy or git clone your app here
npm install --production

Confirm your app listens on localhost only, not 0.0.0.0, and on a non-privileged port:

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

app.get("/health", (req, res) => res.json({ status: "ok" }));

const PORT = process.env.PORT || 3000;
app.listen(PORT, "127.0.0.1", () => {
  console.log(`API listening on 127.0.0.1:${PORT}`);
});

Binding to 127.0.0.1 explicitly (rather than the default, which listens on all interfaces) means the Node process is only reachable from the same machine — Nginx is the only path in from outside.

Step 3: Install and Configure PM2

sudo npm install -g pm2

Start your app under PM2:

cd /var/www/myapi
pm2 start server.js --name myapi -i max

The -i max flag runs Node in cluster mode, spawning one worker process per CPU core, with PM2 load balancing between them automatically — a huge win for CPU-bound work, since a single Node process is single-threaded and can’t use more than one core on its own.

Save the process list and set PM2 to start on boot:

pm2 save
pm2 startup systemd

That last command prints a sudo command you need to run once to actually register the systemd service — copy and run exactly what it outputs.

Confirm everything’s running:

pm2 status
pm2 logs myapi

Step 4: Install and Configure Nginx

sudo apt install nginx -y

Create the site config:

sudo nano /etc/nginx/sites-available/myapi
upstream myapi_backend {
    server 127.0.0.1:3000;
    keepalive 64;
}

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

    client_max_body_size 10M;

    location / {
        proxy_pass http://myapi_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;

        proxy_connect_timeout 5s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
    }

    location /health {
        access_log off;
        proxy_pass http://myapi_backend;
    }
}

A few specifics worth calling out:

  • upstream with keepalive 64; — this maintains a pool of persistent connections between Nginx and your Node backend, avoiding the overhead of establishing a new TCP connection for every single proxied request. For a high-throughput API, this matters.
  • proxy_set_header Connection ""; — required alongside keepalive in the upstream block; it clears the Connection header so Nginx’s own keepalive logic to the upstream isn’t interfered with by whatever the client sent.
  • proxy_http_version 1.1; — HTTP/1.1 is required for keepalive connections to work correctly between Nginx and the upstream (1.0 doesn’t support persistent connections in the same way).
  • Separate timeouts — proxy_connect_timeout (how long to wait to establish the connection to Node), proxy_send_timeout, and proxy_read_timeout (how long to wait on slow responses) are all tunable independently; APIs with occasional slow endpoints (report generation, large exports) may need a longer proxy_read_timeout specifically for those routes.
  • /health with access_log off; — health check endpoints get hit constantly by load balancers and monitoring tools; excluding them from the access log keeps logs readable and saves a small amount of disk I/O.

Enable the site:

sudo ln -s /etc/nginx/sites-available/myapi /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Step 5: Add HTTPS

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

Step 6: Configure CORS (If Your API Is Consumed by Browsers)

If your Node API is called directly from browser-based JavaScript on a different origin, you need CORS headers. You can handle this at the Nginx layer, the app layer, or both — I generally prefer handling it in the app (via a library like Express’s cors middleware) since it has better visibility into which routes actually need it, but here’s the Nginx approach for completeness:

location / {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Length' 0;
        return 204;
    }

    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    proxy_pass http://myapi_backend;
    # ... rest of proxy config
}

Handling the OPTIONS preflight directly in Nginx (returning 204 without hitting Node at all) shaves a small but real amount of latency off every CORS preflight request, since Node never even sees it.

Testing Your Setup

curl -I https://api.example.com/health

Test with actual API calls, including a POST with a JSON body, to confirm the full path works:

curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Test User"}'

Test the keepalive connection is actually working by watching Node’s connection count under load — a tool like autocannon is useful here:

npx autocannon -c 50 -d 10 https://api.example.com/health

Watch pm2 monit during this test to see CPU and memory across your cluster workers in real time.

Troubleshooting Common Issues

502 Bad Gateway — Confirm PM2 shows the app as online, not errored or stopped:

pm2 status

Check PM2 logs for a crash loop:

pm2 logs myapi --lines 100

High latency despite low CPU usage — Often an event-loop blocking issue in the Node app itself (synchronous file reads, heavy JSON parsing, unindexed database queries) rather than an Nginx problem. Nginx is rarely the bottleneck in this scenario; profile the Node process directly.

Connection reset errors under load — Check keepalive is actually configured in the upstream block and that proxy_set_header Connection ""; is present; without both, Nginx opens and closes a new connection per request, which can exhaust ephemeral ports under high concurrency.

Requests to /health cluttering logs despite access_log off — Double check the directive is inside the correct location block and that there isn’t a more general location / block matching first due to Nginx’s location-matching precedence rules (exact and prefix matches are evaluated in a specific order that can surprise people).

PM2 cluster mode workers randomly restarting — Check memory limits; PM2 restarts a worker if it exceeds a configured max memory (--max-memory-restart), which is a feature, not a bug, but worth knowing about if you see it happening.

Security Considerations

  • Bind Node to 127.0.0.1, never 0.0.0.0, so it’s unreachable except through Nginx.
  • Never trust client-supplied X-Forwarded-For blindly in your Node app if there are multiple proxies in the chain — configure Express’s trust proxy setting appropriately so req.ip reflects the real client, not a spoofable header:
app.set("trust proxy", 1); // trust the first proxy hop (Nginx)
  • Rate limit at the Nginx layer as a first line of defense, in addition to any app-level rate limiting:
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

location / {
    limit_req zone=api_limit burst=20 nodelay;
    proxy_pass http://myapi_backend;
}
  • Validate JWTs and API keys in the app, not Nginx, unless you’re using Nginx Plus or a dedicated auth module — plain open-source Nginx doesn’t have great native JWT validation, so this responsibility usually belongs in your Node middleware.
  • Set security headers:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  • Keep Node.js and npm dependencies patched. Nginx protects the edge, but a vulnerable dependency inside your Node app is still fully exploitable through legitimate-looking proxied requests.

Performance Tips

  • Use PM2 cluster mode (-i max) to actually use all available CPU cores — a single Node process leaves the rest of a multi-core server idle.
  • Enable gzip/brotli compression in Nginx for JSON responses:
gzip on;
gzip_types application/json application/javascript text/css;
gzip_min_length 512;
  • Tune keepalive pool size in the upstream block based on expected concurrency — too small and you lose the connection-reuse benefit under load; too large wastes idle file descriptors.
  • Offload static assets entirely, if your API also serves any static files (docs, uploads, etc.) — let Nginx serve those directly from disk rather than routing them through Node:
location /uploads/ {
    alias /var/www/myapi/uploads/;
    expires 7d;
}
  • Consider proxy_cache for read-heavy, infrequently-changing GET endpoints (see the caching proxy guide in this series) — it can dramatically cut load on endpoints that don’t need to recompute their response on every single request.
  • Monitor event loop lag inside your Node app (libraries like @nodejs/clinic or simple custom metrics) — Nginx-level metrics alone won’t reveal Node-specific bottlenecks like a blocked event loop.

Real-World Use Cases

  • A public API powering a mobile app’s backend, where Nginx handled rate limiting per API key (extracted via a header and matched against a map block) before requests reached a PM2-clustered Express app.
  • An internal microservice where Nginx served purely as a TLS-terminating, access-logging front door on an otherwise private network, with the actual routing logic and auth fully owned by the Node app.
  • A webhook receiver for a third-party payment processor, where Nginx’s client_max_body_size and strict rate limiting protected the Node handler from malformed or excessive payloads before they ever reached application code.

Best Practices Recap

  • Bind Node to 127.0.0.1 and let Nginx be the only public-facing entry point.
  • Use PM2 (or systemd with multiple instances) to run Node in cluster mode across all CPU cores.
  • Enable keepalive connections between Nginx and the upstream for high-throughput APIs.
  • Separate and tune connect/send/read timeouts deliberately, especially for slow endpoints.
  • Rate limit at the Nginx layer as a first line of defense.
  • Trust proxy headers correctly in Express (trust proxy) so client IPs remain accurate.
  • Offload static files and cacheable GET responses away from Node entirely where possible.

With Node handling the application logic and Nginx handling everything at the network edge, you end up with a stack where each piece is doing what it’s actually good at — and that division of labor is exactly what makes this pairing hold up well under real production traffic.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Apache as a Reverse Proxy

How to Set Up Nginx with Apache as a Reverse Proxy

Next Post
How to Configure Nginx as a Caching Proxy

How to Configure Nginx as a Caching Proxy

Related Posts