Nginx reverse proxy for Node.js and Express, with HTTPS and WebSockets
Set up Nginx in front of a Node.js or Express app: a complete config with HTTPS and WebSockets, a systemd service, trust proxy for real client IPs, and common errors.
by OhSudo ·
Part of a learning path:Web servers: nginx and HTTPS in production
Your Node.js app listens on port 3000. To put it on the internet properly, you want Nginx in front: it handles HTTPS, serves as the only public port, and keeps slow clients away from your app.
To get this config with your own domain, open the Nginx Config Generator with these options.
The config
Replace example.com with your domain. On Ubuntu and Debian, save it as /etc/nginx/sites-available/example.com:
map $http_upgrade $connection_upgrade { default upgrade; '' close;}
server { listen 80; listen [::]:80; server_name example.com www.example.com; return 301 https://example.com$request_uri;}
server { listen 443 ssl; listen [::]:443 ssl; server_name www.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; return 301 https://example.com$request_uri;}
server { listen 443 ssl; listen [::]:443 ssl; server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10m;
location / { proxy_pass http://127.0.0.1:3000; 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;
# WebSockets proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 1h; }}1. Run the app as a service
The app has to restart after a crash or a reboot. A systemd unit does that, with nothing else to install. Save it as /etc/systemd/system/myapp.service:
[Unit]Description=My Node.js appAfter=network.target
[Service]User=www-dataWorkingDirectory=/var/www/myappEnvironment=NODE_ENV=productionEnvironment=PORT=3000ExecStart=/usr/bin/node server.jsRestart=on-failure
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now myappcurl -i http://127.0.0.1:3000/The systemd Unit Generator writes a fuller one, with security settings. If it fails to start, see Job for service failed.
2. Listen only on localhost
Otherwise, port 3000 stays open to the internet, and visitors can go around Nginx:
app.listen(process.env.PORT, '127.0.0.1');Check it. This should show 127.0.0.1:3000, not 0.0.0.0:3000 or *:3000:
sudo ss -tlnp | grep ':3000 '3. Tell Express it’s behind a proxy
Without this, req.ip is always 127.0.0.1, req.protocol says http, and secure cookies don’t get set:
const app = express();app.set('trust proxy', 1); // one proxy in front: NginxUse the number of proxies you really have. true trusts anything, so a visitor can then fake their IP with a header of their own. Rate limiters and logs depend on getting this right.
4. Get the certificate
sudo apt install certbotsudo certbot certonly --standalone -d example.com -d www.example.com \ --pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx"Your DNS has to point here, and port 80 must be open. If it fails, see Certbot failed to authenticate. Certbot renews by itself, using the same hooks.
5. Turn it on
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/sudo rm -f /etc/nginx/sites-enabled/defaultsudo nginx -tsudo systemctl reload nginxcurl -I https://example.com/On RHEL, Rocky and AlmaLinux, save the file as /etc/nginx/conf.d/example.com.conf instead. SELinux also blocks Nginx from connecting to your app until you allow it:
sudo setsebool -P httpd_can_network_connect 1Then open the web ports, and only those:
sudo ufw allow 'Nginx Full'WebSockets and Socket.IO
The Upgrade and Connection lines in the config are what make WebSockets work. Without them, connections fall back to polling, or fail with a 400. proxy_read_timeout 1h keeps a quiet connection open, since the default closes it after 60 seconds.
If you run several Node.js processes, Socket.IO also needs sticky sessions with ip_hash in an upstream block, or its handshake fails across processes:
upstream myapp { ip_hash; server 127.0.0.1:3000; server 127.0.0.1:3001;}Then use proxy_pass http://myapp;.
When it goes wrong
| You see | Means | Go to |
|---|---|---|
502 Bad Gateway |
The app isn’t running, or is on another port | Nginx 502 |
504 Gateway Time-out |
The app took too long to answer | Nginx 504 |
413 Request Entity Too Large |
An upload over client_max_body_size |
Nginx 413 |
ERR_TOO_MANY_REDIRECTS |
Your app and Nginx disagree on HTTPS | Too many redirects |
Address already in use |
Something else has port 3000 or 80 | Address already in use |
To see what’s on a port, the Port Lookup tells you what normally runs there.
Warning
Don’t run the app as root, or give it port 80 directly. Nginx is there so that your app can be a normal user on a port that isn’t public, and a bug in the app doesn’t become root on your server.
Found this useful? Share it.
Found a mistake? Let us know at contact@ohsudo.com.