Skip to main content

Hands-On: Reverse Proxy an Express App

What We Are Building

A production-style setup on an Ubuntu server: your Express app listens privately on port 3000, Nginx faces the internet on port 80, forwards API traffic to Node, and serves static files itself. This is the standard topology for a deployed MERN backend.

Step 1: Have an Express App Running on Port 3000

SSH into your server and get a minimal app up (in production you'd run this under a process manager, but plain node is fine for this walkthrough):

// /home/ubuntu/myapp/index.js
const express = require('express');
const app = express();

app.get('/api/hello', (req, res) => {
res.json({ from: 'express', ip: req.ip });
});

app.listen(3000, '127.0.0.1', () => console.log('App on 3000'));

Binding to 127.0.0.1 matters: the app accepts connections only from the server itself. The outside world must go through Nginx.

cd /home/ubuntu/myapp && node index.js &
curl http://127.0.0.1:3000/api/hello # verify locally before touching Nginx

Step 2: Install Nginx on Ubuntu

sudo apt update
sudo apt install nginx -y
sudo systemctl enable --now nginx

If UFW is active, allow HTTP traffic:

sudo ufw allow 'Nginx HTTP'
sudo ufw status

Visit http://your-server-ip in a browser — the default Nginx welcome page confirms port 80 is reachable.

Step 3: Prepare a Static Assets Directory

Nginx should serve images, CSS, and bundles itself — Node never needs to touch them.

sudo mkdir -p /var/www/myapp/static
echo "body { font-family: sans-serif; }" | sudo tee /var/www/myapp/static/style.css
sudo chown -R www-data:www-data /var/www/myapp

www-data is the user Nginx worker processes run as on Ubuntu; it needs read access to anything it serves.

Step 4: Create the Server Block

Create a dedicated config file instead of editing the default one:

sudo nano /etc/nginx/sites-available/myapp

Paste:

server {
listen 80;
server_name myapp.example.com;

# Static assets served directly from disk
location /static/ {
alias /var/www/myapp/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}

# Everything else goes to Express
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;
}
}

What each piece does:

  • location /static/ with alias maps the URL path to the disk directory, so GET /static/style.css reads /var/www/myapp/static/style.css with zero Node involvement.
  • expires 30d plus the Cache-Control header tells browsers to cache assets for a month — pair this with hashed filenames from your bundler.
  • The X-Forwarded-* headers preserve the real client IP and protocol; without them Express sees every request as coming from 127.0.0.1. Set app.set('trust proxy', 1) in Express to read them correctly.
  • The Upgrade and Connection headers keep WebSocket connections (Socket.IO, for example) working through the proxy.

If you don't have a domain yet, set server_name _; and test with the server IP.

Step 5: Enable the Site

Ubuntu's Nginx uses a two-directory convention: configs live in sites-available, and a symlink in sites-enabled activates them.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default # remove the welcome-page site

Step 6: Test the Config and Reload

Never reload blindly — always validate first:

sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

sudo systemctl reload nginx

reload re-reads the config without dropping in-flight connections, which is why it's the default choice over restart on a live server. If nginx -t fails, the running config stays untouched — nothing breaks until you fix the file and reload again.

Step 7: Verify Both Paths

Proxied traffic reaching Express:

curl http://myapp.example.com/api/hello
# {"from":"express","ip":"..."}

Static file served by Nginx directly (note the cache headers):

curl -I http://myapp.example.com/static/style.css
# HTTP/1.1 200 OK
# Expires: ...
# Cache-Control: public, immutable

Stop the Node process and hit /api/hello again — Nginx returns 502 Bad Gateway. That's the signature of a healthy proxy with a dead upstream, and the first thing to check whenever you see a 502 in production.

Step 8: Know Where the Logs Are

sudo tail -f /var/log/nginx/access.log # every request Nginx handled
sudo tail -f /var/log/nginx/error.log # config problems, upstream failures

When something misbehaves, error.log almost always names the culprit — permission denied on a static file, connection refused to the upstream, or a bad directive.

Recap

  1. Express binds to localhost only; Nginx owns port 80
  2. A server block in sites-available, activated by a symlink in sites-enabled
  3. Static assets served straight from disk with long-lived cache headers
  4. Forwarding headers preserve client IP and protocol for Express
  5. nginx -t then systemctl reload nginx — the safe change-deploy loop

The natural next step for this setup is putting HTTPS in front of it with a free certificate, which builds directly on this server block.