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/withaliasmaps the URL path to the disk directory, soGET /static/style.cssreads/var/www/myapp/static/style.csswith zero Node involvement.expires 30dplus 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 from127.0.0.1. Setapp.set('trust proxy', 1)in Express to read them correctly. - The
UpgradeandConnectionheaders 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
- Express binds to localhost only; Nginx owns port 80
- A server block in
sites-available, activated by a symlink insites-enabled - Static assets served straight from disk with long-lived cache headers
- Forwarding headers preserve client IP and protocol for Express
nginx -tthensystemctl 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.