Nginx reverse proxy for Node.js with free SSL from Let's Encrypt

Set up Nginx as a reverse proxy for Node.js on Ubuntu, add free HTTPS with Let's Encrypt and auto-renewal, and fix real client IPs, WebSockets and 502 errors.

Node.js can serve HTTPS on its own, but in production it usually sits behind Nginx. Nginx terminates TLS, speaks HTTP/2, compresses responses, absorbs slow clients so your app doesn't have to, and lets one server host several apps on different domains. It does all that in a few lines of config.

This is the setup from a fresh Ubuntu server to a Node.js app on HTTPS, with a free certificate that renews itself.

Part of my guide to deploying Node.js to production.

TL;DR#

  • Run the Node.js app on 127.0.0.1:3000, so only Nginx can reach it.
  • Point Nginx at it with proxy_pass, and forward the Host, X-Forwarded-For and X-Forwarded-Proto headers.
  • Get a free certificate with certbot --nginx; renewal is automatic, so test it with certbot renew --dry-run.
  • Tell Express to trust the proxy, or every request looks like it came from 127.0.0.1 over plain HTTP.
  • Most 502 errors mean the app isn't running on the port Nginx expects.

Before you start#

You need:

  • An Ubuntu server (these steps use 24.04) with SSH access.
  • A domain with an A record pointing at the server's IP. Certbot can't issue a certificate until it resolves.
  • Your Node.js app running on port 3000, bound to localhost:
app.listen(3000, '127.0.0.1');

Binding to 127.0.0.1 instead of 0.0.0.0 means the app can't be reached from the internet directly, only through Nginx. If the app runs in Docker, publish the port the same way: -p 127.0.0.1:3000:3000.

Then open the firewall for SSH and web traffic:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'   # ports 80 and 443; the profile exists once Nginx is installed
sudo ufw enable

Step 1: install Nginx#

sudo apt update
sudo apt install nginx

Visiting http://your-domain should now show Nginx's welcome page.

Step 2: the reverse proxy config#

Create /etc/nginx/sites-available/api.example.com:

# WebSockets: pass the Upgrade handshake through, and keep upstream connections open otherwise.
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      '';
}

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

server {
  listen 80;
  listen [::]:80;
  server_name api.example.com;

  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 Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
  }
}

What each part does:

  • upstream with keepalive reuses connections to Node.js instead of opening one per request. It needs proxy_http_version 1.1 and an empty Connection header, which is what the map produces for ordinary requests.
  • Host passes the domain the visitor asked for, so the app can build correct URLs.
  • X-Forwarded-For and X-Real-IP carry the visitor's real IP address.
  • X-Forwarded-Proto tells the app whether the visitor used HTTP or HTTPS.
  • Upgrade and Connection let WebSocket connections through. Without them, Socket.IO and other WebSocket clients fail to connect.

Enable the site, check the config, and reload:

sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default   # optional: the welcome page
sudo nginx -t && sudo systemctl reload nginx

http://api.example.com should now show your app. On Ubuntu, files in sites-enabled are loaded inside Nginx's http block, which is where map and upstream belong. If you host several apps, define the map once, not in every site's file.

Step 3: free HTTPS with Let's Encrypt#

Certbot gets a certificate from Let's Encrypt and edits your Nginx config to use it. Install it from snap, as its instructions recommend:

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d api.example.com

Certbot proves you control the domain, saves the certificate, and adds a listen 443 ssl block plus a redirect from HTTP to HTTPS. Visit https://api.example.com to check.

Renewal is automatic. The snap installs a timer that renews certificates before they expire. Make sure it works now, not when a certificate lapses:

sudo certbot renew --dry-run

That matters more every year: Let's Encrypt certificates last 90 days today, and the industry is cutting that to 45 days by 2028. Manual renewal stops being an option.

Turn on HTTP/2 in the server block Certbot created. How depends on your Nginx version (nginx -v):

# Nginx 1.25.1 and newer
listen 443 ssl;
http2 on;

# Older versions, including the 1.24 that Ubuntu 24.04 ships
listen 443 ssl http2;

Step 4: tell Node.js it's behind a proxy#

Behind Nginx, every request reaches Node.js from 127.0.0.1 over plain HTTP. Unless the app trusts the forwarded headers, req.ip is always 127.0.0.1, req.protocol is always http, cookies marked secure aren't sent, and rate limiters see every visitor as one client.

In Express, trust one proxy hop, Nginx:

app.set('trust proxy', 1);

In NestJS on Express, it's the same setting:

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1);

Trust only as many hops as you actually have. trust proxy: true would let any client set its own IP with a fake X-Forwarded-For header. The Express guide to running behind proxies explains the options.

Step 5: harden it for production#

A few settings worth adding once everything works:

# In the http block, /etc/nginx/nginx.conf
server_tokens off;   # don't advertise the Nginx version
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

# In your HTTPS server block
client_max_body_size 10m;   # the default is 1m; raise it for uploads
gzip on;
gzip_types application/json text/css application/javascript;
add_header Strict-Transport-Security "max-age=31536000" always;

location / {
  limit_req zone=api burst=20 nodelay;
  # ...the proxy settings from step 2
}
  • limit_req caps each IP at 10 requests a second, with bursts of 20, before your app sees the traffic.
  • HSTS (Strict-Transport-Security) tells browsers to use HTTPS only. Add it once HTTPS works everywhere, because browsers remember it for the max-age you set.

Troubleshooting#

Symptom Usual cause Check
502 Bad Gateway The app isn't running, or listens on another port curl -i http://127.0.0.1:3000 and sudo tail /var/log/nginx/error.log
504 Gateway Timeout The app took longer than proxy_read_timeout (60s by default) Slow endpoints, or raise the timeout for that location
413 Request Entity Too Large Upload above client_max_body_size (1m by default) Raise it in the server block
Every request shows IP 127.0.0.1 Express doesn't trust the proxy app.set('trust proxy', 1)
Redirect loop after enabling HTTPS The app redirects to HTTPS but sees every request as HTTP Forward X-Forwarded-Proto and trust the proxy
WebSockets fail to connect Missing Upgrade and Connection headers The map and two proxy_set_header lines in step 2
Server-Sent Events arrive in bursts Nginx buffers the response proxy_buffering off; for that location (details)

FAQ#

Do I need Nginx in front of Node.js?#

Not strictly, but it's the standard setup. Nginx handles TLS certificates, HTTP/2, compression and slow clients efficiently, adds rate limiting, and lets one server host several apps on different domains. Node.js then only has to run your application code.

Is SSL from Let's Encrypt really free?#

Yes. Let's Encrypt is a nonprofit certificate authority that issues domain-validated certificates at no cost, trusted by all major browsers. Certbot requests the certificate, configures Nginx to use it, and renews it automatically before it expires.

How do I renew a Let's Encrypt certificate on Nginx?#

You don't have to do it by hand: installing Certbot from snap adds a timer that renews certificates automatically before they expire. Run sudo certbot renew --dry-run once to confirm renewal works.

Can one Nginx server proxy several Node.js apps?#

Yes. Run each app on its own local port and give each domain its own server block with its own upstream. Certbot can issue a certificate for each domain, or one certificate covering several.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.