Serve app on port 80 using Nginx

Friday 22/07/2022

·5 min read
Share:

Node 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:3000 assumes 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 your socket.io or 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;

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.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts