How to Set Up Nginx for a Node.js API

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

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:

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

app.set("trust proxy", 1); // trust the first proxy hop (Nginx)
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;
}
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;

Performance Tips

gzip on;
gzip_types application/json application/javascript text/css;
gzip_min_length 512;
location /uploads/ {
    alias /var/www/myapi/uploads/;
    expires 7d;
}

Real-World Use Cases

Best Practices Recap

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.

Exit mobile version