DevOps

Nginx Reverse Proxy for Node Apps (With SSL Notes That Matter)

Exposing Node on port 3000 to the internet is fine for a demo and a liability in production. Here is the Nginx reverse proxy setup I reuse, plus SSL mistakes that cost me overnight outages.

What you will learn

  • Bind Node to localhost; let Nginx own 80/443
  • Always forward Host, X-Forwarded-For, and X-Forwarded-Proto
  • WebSockets need Upgrade headers and longer timeouts
  • Use fullchain.pem and reload Nginx after cert renewals
  • Serve static assets from Nginx when you can

Why Node should not face the public internet alone

Node can terminate TLS, but I still put Nginx in front for static assets, rate limiting at the edge, predictable certificate renewal, and one place to terminate HTTP/2. It also lets me run multiple Node processes on different ports behind one hostname.

On an early project I bound Express to 0.0.0.0:443 with a hand-rolled cert reload. Renewal failed silently; browsers showed a scary interstitial for six hours before anyone noticed. Nginx + certbot’s deploy hook would have been boring and correct.

Another reason: connection handling. Nginx absorbs slow clients and keeps Node focused on app work. Under a modest bot scrape, that difference showed up as stable API latency instead of event-loop stalls.

Security groups and firewalls should only expose 80/443 on the public interface. Developers who SSH tunnel to :3000 for debugging sometimes leave the port open “for a bit.” That bit becomes months. Bind localhost and use a VPN or SSH tunnel instead.

Minimal reverse proxy that actually works

Bind Node to 127.0.0.1:3000. Nginx listens on 80/443 and proxies to that upstream. Always set Host, X-Real-IP, and X-Forwarded-For/Proto so your app logs and absolute URL builders see the real client and scheme.

If you forget X-Forwarded-Proto, OAuth redirect URIs and secure cookies break in subtle ways—HTTPS in the browser, http:// in your app’s redirect logic.

I keep upstream in its own block so I can add a second Node instance later without rewriting every location. keepalive on the upstream reduces churn for busy JSON APIs.

proxy_redirect and trailing slashes on proxy_pass matter. A slash at the end of proxy_pass changes path rewriting. I keep a comment above each location explaining whether paths are preserved. Silent path bugs waste more time than SSL.

upstream node_app {
  server 127.0.0.1:3000;
  keepalive 32;
}

server {
  listen 80;
  server_name api.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name api.example.com;

  ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

  location / {
    proxy_pass http://node_app;
    proxy_http_version 1.1;
    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_set_header Connection "";
  }
}

WebSockets and long requests

Socket.IO and similar need Upgrade headers. Without them, the handshake falls back or fails. I also bump proxy_read_timeout for long-lived connections instead of accepting Nginx’s default 60s cutoffs.

File uploads and slow exports need higher proxy_read_timeout and client_max_body_size. I set body size per location so a public marketing site does not inherit a 50m upload limit meant for an admin API.

Health checks from a load balancer should hit Nginx, which then hits Node—or hit Node on a private interface. Do not open a second public port just for /health unless you know why.

location /socket.io/ {
  proxy_pass http://node_app;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
  proxy_read_timeout 86400;
}

location /api/uploads {
  client_max_body_size 25m;
  proxy_pass http://node_app;
  proxy_request_buffering off;
  proxy_read_timeout 300;
}

SSL notes I write down for every server

Use certbot with the nginx plugin or webroot. Prefer fullchain.pem, not cert.pem alone—missing intermediates cause mobile clients to fail while desktop Chrome looks fine. Enable a modern cipher suite and turn on OCSP stapling when you have time; HSTS only after you are sure HTTPS works everywhere.

Reload Nginx after renewals. Certbot usually installs a timer, but I still verify with `sudo nginx -t && sudo systemctl reload nginx` after the first issuance. I once had a valid new cert on disk while Nginx kept serving the expired one until a manual reload.

For staging, use a real subdomain certificate, not a permanent exception in every teammate’s browser. Fake SSL in staging trains people to ignore warnings in production.

TLS 1.2+ only. Disable ancient ciphers. Mozilla’s intermediate profile is a sane default for most business apps. I keep a calendar reminder to revisit cipher config yearly because defaults rot.

I keep certificate expiry in monitoring with a 14-day warning. Calendars fail; alerts do not. The overnight outage I mentioned earlier would have been a daytime ticket with a simple probe.

  • Redirect HTTP→HTTPS at Nginx, not only in Express
  • Trust proxy in Express: app.set('trust proxy', 1)
  • Serve static files from Nginx when possible to free Node
  • Limit body size explicitly (client_max_body_size) for upload APIs
  • Test certificate chain on a phone, not only desktop Chrome

Static files and gzip

Let Nginx serve `/public` or `/dist` directly. Node should handle API routes. Enable gzip (or brotli if you install the module) for text assets. This alone dropped p95 TTFB on a marketing site I inherited because Node had been streaming every CSS file through Express middleware.

Fingerprinted assets get long cache headers. HTML stays short-cached or no-store if it bootstraps a SPA. Mixing those policies is how users run old JS against a new API.

Access logs at Nginx tell you the truth about bots and slow endpoints before app metrics do. Sample or ship them to your log stack; do not discover a scrape only when the disk fills.

location /assets/ {
  alias /var/www/app/dist/assets/;
  access_log off;
  expires 7d;
  add_header Cache-Control "public";
}

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;

Deploy checklist I actually run

Confirm Node listens on localhost only. Confirm `nginx -t` passes. Hit health endpoints through the public hostname, not localhost:3000. Check certificates with SSL Labs or `openssl s_client`. Watch error.log during the first traffic spike—502s usually mean Node crashed or the upstream port is wrong.

Keep a second upstream server line ready if you run two Node instances for zero-downtime restarts. Rolling restarts without a proxy is how I used to drop websocket clients mid-deploy.

Document the server inventory in the repo: ports, unit files, cert domains. Future-you on a page at 2am should not reconstruct the topology from `ps aux`.

After every deploy, curl -I https://your.domain and confirm the certificate dates and HSTS header if enabled. Automate that check in the deploy job if you can spare thirty seconds of CI.

Rate limits and basic hardening

limit_req_zone at Nginx stops the dumbest floods from reaching Node. It is not a WAF, but it buys time. I rate-limit login and password-reset locations more tightly than public GETs.

Hide version tokens in Server headers if your threat model cares. More importantly, return custom 502/504 pages so users see “we are redeploying” instead of a raw upstream error during restarts.

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

location /api/ {
  limit_req zone=api_limit burst=20 nodelay;
  proxy_pass http://node_app;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
}

Key takeaways

  • Bind Node to localhost; let Nginx own 80/443
  • Always forward Host, X-Forwarded-For, and X-Forwarded-Proto
  • WebSockets need Upgrade headers and longer timeouts
  • Use fullchain.pem and reload Nginx after cert renewals
  • Serve static assets from Nginx when you can

About the author

Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.

More about Ram · Contact

← Flexbox vs CSS Grid: When I Use Each in Pr… Next: Redis Caching Patterns I Use: Cache-Aside,… →