Serve app on port 80 using Nginx
Friday 22/07/2022
·5 min readNode apps don't bind to port 80 by default — and they shouldn't, because that requires running as root. The standard fix is to run Node on a high port (3000, 4000, whatever) and put Nginx in front as a reverse proxy that listens on port 80 (and 443 for HTTPS) and forwards traffic to Node. The config is 10 lines, the gotchas are mostly about HTTPS, websockets, and the trailing slash. Below: a complete production-quality reverse proxy setup, tested on Ubuntu, plus the steps to upgrade it to HTTPS.
Install Nginx
sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginx
Verify it's running:
curl http://localhost
# default nginx welcome page HTML
If you see a connection refused, check the service: sudo systemctl status nginx.
Create the site config
Site configs live in /etc/nginx/sites-available/ and become active when symlinked into /etc/nginx/sites-enabled/. The default config in sites-enabled/default serves Nginx's welcome page — disable it:
sudo rm /etc/nginx/sites-enabled/default
Then create your app's config:
sudo nano /etc/nginx/sites-available/myapp
Paste in this minimal proxy config:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
}
What each piece does:
listen 80; listen [::]:80;— listen on IPv4 and IPv6, port 80.server_name— which hostnames this server block handles. Lists multiple separated by spaces.proxy_pass— where to forward requests.127.0.0.1:3000assumes your Node app is running on the same machine, on port 3000.proxy_http_version 1.1+Upgrade+Connection— required for WebSocket support. Skip these and yoursocket.ioor Next.js HMR breaks.Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto— preserve the original request information so your Node app can log real client IPs and detect HTTPS even when the proxy is HTTP.
Enable the site
Symlink it into sites-enabled:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
Test the config syntax before reloading:
sudo nginx -t
# nginx: configuration file /etc/nginx/nginx.conf test is successful
If the test passes, reload Nginx (zero-downtime, doesn't drop connections):
sudo systemctl reload nginx
Verify:
curl -I http://example.com
# HTTP/1.1 200 OK (or whatever your app returns)
Upgrade to HTTPS with Let's Encrypt
In 2026, plain HTTP is a downgrade — Chrome marks it "Not Secure," Google penalizes it in SEO, and modern features (Service Workers, geolocation, HTTP/2) require HTTPS. Use certbot to add a free Let's Encrypt cert:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot reads your existing config, asks for an email, and modifies the file to add HTTPS. After the dialog, your config will look approximately like:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location / {
proxy_pass http://127.0.0.1:3000;
# ... same proxy headers as before
}
}
Renewal is automatic via a systemd timer (certbot.timer). Verify with sudo certbot renew --dry-run.
Common gotchas
502 Bad Gateway
Your Node app isn't running, or it's bound to a different port. Check:
curl http://127.0.0.1:3000 # is Node actually responding here?
sudo netstat -tlnp | grep node # is anything on port 3000?
504 Gateway Timeout
Node is responding but slowly. Increase proxy_read_timeout:
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
The default 60s is fine for HTML responses; raise it for long-polling, file uploads, or anything streaming.
WebSocket connection fails
Missing the WebSocket headers. Make sure you have:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
App sees 127.0.0.1 as the client IP
Your Node app is reading the socket address instead of the X-Forwarded-For header. Configure your framework to trust the proxy:
- Express:
app.set('trust proxy', 1) - Next.js: handle it in middleware reading
request.headers.get('x-forwarded-for') - Fastify:
trustProxy: true
Large file uploads fail
Default Nginx body limit is 1 MB. Raise it:
client_max_body_size 100M;
Running Node behind systemd (optional but recommended)
The reverse-proxy setup assumes Node is already running. To keep it running across reboots and crashes, use a systemd service:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Node app
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node server.js
Restart=on-failure
Environment=NODE_ENV=production
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo journalctl -u myapp -f # follow logs
FAQ
Why can't my Node app listen on port 80 directly?
Ports below 1024 require root privileges on Linux. Running Node as root is a security anti-pattern — any vulnerability in your app or its dependencies becomes root-level. The reverse-proxy pattern lets Nginx (which is designed to be safely exposed to the internet) handle the privileged port and forward to Node running as an unprivileged user.
What's the difference between proxy_pass with and without a trailing slash?
proxy_pass http://127.0.0.1:3000 (no trailing slash) forwards the full request path as-is. proxy_pass http://127.0.0.1:3000/ (trailing slash) strips the matched location prefix. For location / this makes no difference; for location /api/ it does — without the slash, your app sees /api/foo; with the slash, it sees just /foo.
Should I use Nginx or Caddy as a reverse proxy?
Both work. Caddy auto-provisions HTTPS via Let's Encrypt with zero config, which is genuinely nice for small sites. Nginx has wider deployment, more documentation, and is the de-facto choice for serious production work. Pick Caddy for simplicity, Nginx for ecosystem.
Can I run multiple apps on port 80 with one Nginx?
Yes — that's exactly what server_name is for. One server { ... } block per hostname, each with its own proxy_pass to a different upstream port. This is the canonical pattern for hosting many apps on one VM.