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 theHost,X-Forwarded-ForandX-Forwarded-Protoheaders. - Get a free certificate with
certbot --nginx; renewal is automatic, so test it withcertbot renew --dry-run. - Tell Express to trust the proxy, or every request looks like it came from
127.0.0.1over 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
Arecord 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:
upstreamwithkeepalivereuses connections to Node.js instead of opening one per request. It needsproxy_http_version 1.1and an emptyConnectionheader, which is what themapproduces for ordinary requests.Hostpasses the domain the visitor asked for, so the app can build correct URLs.X-Forwarded-ForandX-Real-IPcarry the visitor's real IP address.X-Forwarded-Prototells the app whether the visitor used HTTP or HTTPS.UpgradeandConnectionlet 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_reqcaps 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 themax-ageyou 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.
Related reading#
- Deploying Node.js to production: the complete guide
- Zero-downtime deploys for a Node.js API with Docker, Nginx and GitHub Actions
- PM2 vs Docker for running Node.js in production
- AWS ALB vs Nginx load balancing: when you need which, for when one server isn't enough
- Ubuntu server hardening checklist for a fresh EC2, for the server Nginx runs on
- Server-Sent Events vs WebSockets: picking a real-time channel
