nginx takes over TLS, host-based routing, and config validation before every reload. ufw stays closed except for SSH and 80/443, and only if you're exposing the app directly instead of routing it through Cloudflare Tunnel. Here's the proxy config, the headers, and the one leftover rule that undoes all of it.
If you followed the opening article in this series, your app runs under PM2 on 127.0.0.1:3000, and the only way in is Cloudflare Tunnel: nothing listens on a public port at all. At some point that stops being the whole story. You add a second domain on the same box and need real routing. You drop the tunnel and want the app reachable by IP or domain directly. Or you just want to know, with certainty, what's actually open on a server that now serves real traffic. All three questions land on the same two tools: an nginx reverse proxy in front of Node, and ufw drawing the line around it.
TL;DR. nginx takes the incoming connection and forwards the request to your app on 127.0.0.1:3000. That frees Node from managing raw TCP connections, gives you one place for TLS, and lets you route multiple apps by domain. ufw stays shut otherwise: SSH plus 80/443, and only if nginx genuinely needs to be reachable from outside (behind Cloudflare Tunnel you don't open those ports at all). The most common way people undo all of it: an old `ufw allow 3000` rule nobody removed, letting traffic hit the app directly and skip nginx entirely.
Node can bind 80 and 443 directly, through `setcap` or by just running as root, so binding isn't the real question. The question is what you want Node's event loop busy with while it does that. The event loop is the single thread that runs your application code: in a good setup it spends its time rendering pages and executing your route handlers, not bookkeeping for every open TCP connection. A keep-alive socket from someone on a flaky mobile connection, a slow upload dribbling in one chunk at a time, dozens of sockets sitting open and idle: all of that is overhead nginx was built to absorb. It handles thousands of concurrent connections cheaply and hands your app a fully buffered request only once there's actually something to process.
Beyond that, a handful of practical reasons tend to be what actually pushes people toward a reverse proxy (a server that sits in front of your app, terminates the incoming connection, and forwards the request internally):
If nginx isn't installed yet, check and install it first:
nginx -v
sudo apt update && sudo apt install -y nginx
nginx -v prints the installed version. If nginx isn't there, this just returns "command not found," and the second line puts it on.Next comes the site config. If you already went through the series opener, `/etc/nginx/sites-available/app` probably exists already, and you're editing it rather than starting from scratch. If it doesn't:
sudo nano /etc/nginx/sites-available/app
server {
listen 80;
server_name example.com www.example.com;
# uncomment if the app actually accepts file uploads bigger than 1 MB
# client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Here's what each line is actually doing:
server_name: the domain this block answers for. Without a match, nginx either falls back to a default server or returns a 444/400, depending on how the rest of the config is set up. If you also want this block to answer direct IP requests, not just the domain, add `default_server` to `listen`: `listen 80 default_server;`. Without it, a request to `http://YOUR_SERVER_IP/` can land on Ubuntu's stock default nginx site instead of your app and just show a "Welcome to nginx" placeholder. Only one block on the server can carry `default_server`.client_max_body_size 20m (commented out above): nginx caps request bodies at 1 MB by default. Uncomment this line only if your app actually accepts file uploads - skip it otherwise and don't pad the limit "just in case." Note your app itself (an API route, say) may enforce its own, smaller body limit that kicks in before nginx's ever does.proxy_pass http://127.0.0.1:3000: the actual handoff to the local process PM2 is managing.proxy_set_header Host $host: without it, your app sees "127.0.0.1" in the Host header instead of the visitor's domain, and anything that depends on the domain (multi-tenant logic, redirects, absolute links) breaks quietly.proxy_set_header X-Real-IP and X-Forwarded-For: pass the visitor's actual IP along in headers. Skip these and every request in your logs, every value in these headers, shows 127.0.0.1, because as far as Node is concerned the client is nginx itself. That said, the headers alone don't make `req.ip` correct in the app. Express, for instance, ships with `trust proxy` off by default, so `req.ip` keeps coming from the raw socket - nginx - even once X-Forwarded-For is set correctly; you have to explicitly tell the framework which proxy to trust: `app.set('trust proxy', '127.0.0.1')`. Also worth knowing: `$proxy_add_x_forwarded_for` appends `$remote_addr` to whatever X-Forwarded-For already carries rather than replacing it outright - irrelevant with a single nginx in front of the app, but it matters the moment there's another proxy upstream, since you'll get a chain of addresses instead of one.proxy_set_header X-Forwarded-Proto $scheme: tells the app whether the original request came in over http or https. Without it, an app sitting behind a TLS-terminating nginx can conclude the connection is insecure and start redirecting to http or dropping cookies without the Secure flag. Important if you're running Cloudflare Tunnel: if nginx sits BETWEEN cloudflared and the app, `$scheme` here is always "http" - cloudflared talks to nginx locally, with no TLS, regardless of what protocol the visitor actually used. This line then silently overwrites the correct header Cloudflare already sent with "http". For the Tunnel -> nginx -> app chain, swap this line for `proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;` so nginx forwards the protocol Cloudflare determined instead of its own local one. If nginx is only there for multiple domains on one IP and TLS is fully handled by Cloudflare either way, it's often simpler to skip nginx for the tunnel entirely and point cloudflared straight at the app's port - Cloudflare officially supports that route.If the app uses WebSocket (Next.js HMR in dev, Socket.io, any realtime feature in production), the block above isn't enough. Stock Ubuntu 24.04 ships nginx 1.24.x, which proxies over HTTP/1.0 by default, and WebSocket needs an explicit upgrade:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Add these three lines to the `location` block only if the app genuinely uses WebSocket - extra directives that do nothing just make the config harder to read.
Enable the site by symlinking it into `sites-enabled`, the directory nginx actually reads on startup:
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
Before anything gets applied, check the config for syntax errors:
sudo nginx -t
A clean config prints exactly two lines:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Drop the semicolon after `listen 80`, for instance, and the parser reads the next line as another parameter of that same directive, so it trips on that line instead of the missing semicolon itself:
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/sites-enabled/app:3
nginx: configuration file /etc/nginx/nginx.conf test failed
The exact wording depends on where the missing semicolon actually is - sometimes it's `unexpected "}"`, sometimes `invalid parameter`, as here. What's consistent either way is the file and line number, which is where to start looking.
That's the whole point of the step. `nginx -t` parses the config in a separate process and leaves the running server untouched either way. Once it passes, apply the change:
sudo systemctl reload nginx
`reload` and `restart` are not interchangeable here. `reload` tells the master process to re-read the config and spin up new workers while the old ones finish serving whatever connections are already open, then exit quietly - the master process itself never restarts, and visitors notice nothing. `restart` goes through a full stop/start cycle instead: Ubuntu's packaged `nginx.service` sends workers a proper shutdown signal on stop rather than killing them outright, but there's still a window between stop and start where nginx isn't listening on anything at all. Skip `nginx -t` and go straight to `restart` on a broken config, and you can end up with nginx failing to come back up after that stop: the site is down until you track the typo down by hand. So the one workflow that's actually safe is `nginx -t && systemctl reload nginx`.
The server block above listens on port 80 only, no encryption. From here it splits into two paths that don't mix:
Baseline hardening (a non-root user, SSH keys, `default deny incoming`) should already be done by way of the fresh-VPS security guide. "Deny incoming, allow outgoing" means the server can reach anything it wants outbound (package updates, `npm install`, outgoing webhooks), but nothing gets in until you explicitly open a port. From here it splits depending on exactly how the app is exposed:
Cloudflare Tunnel (series opener) | Direct exposure by IP/domain | |
|---|---|---|
Is nginx needed | Optional; handy for multiple sites on one IP, not required | Yes, it's the only entry point |
Ports 80/443 in ufw | Don't open them at all; cloudflared initiates the connection outbound | Open both, unless you're certain you only need http or only https |
TLS certificate on the server | Not needed, terminated on Cloudflare | Needed, via certbot |
What the app sees as the visitor's IP | The CF-Connecting-IP header from Cloudflare | The X-Real-IP header from your own nginx |
Behind the tunnel, this section is already done. Skip straight to the mistake below; nothing here needs opening. Serving the domain directly, the minimum rule set looks like this:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status verbose
Open only what you're actually using right now. If TLS isn't set up yet and nginx isn't listening on 443 - certbot adds `listen 443 ssl` in a later step - `sudo ufw allow 80/tcp` is enough for now; add `443/tcp` as a second command once certbot's done. A port that's open but unused isn't a hole by itself, but there's no reason to open it "just in case" ahead of time either.
The output should show exactly three inbound rules allowed: the SSH port you opened during initial hardening (22 by default, unless you changed it), plus 80 and 443 now.
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)
There shouldn't be a fourth port anywhere in that list. If there is, read on.
The mistake to check for: your app's raw port is still reachable, bypassing nginx entirely. During the first deploy, a lot of people open port 3000 explicitly just to confirm the app answers, before nginx is even in the picture: `sudo ufw allow 3000/tcp`. Then nginx shows up, headers and rate limits get added, and the old rule just sits there, forgotten. Anyone who knows or scans for the port can hit http://YOUR_IP:3000 directly, skipping nginx along with everything you configured there: rate limiting, the TLS you're about to add, Host-based routing, all of it.
Check the numbered rule list and remove the leftover:
sudo ufw status numbered
sudo ufw delete N
N is the line number of the `3000/tcp` rule you saw in the previous command's output.The second layer here matters more than the firewall rule: don't rely on ufw alone, and stop the app from listening on a public-facing interface in the first place. `next start` binds to 0.0.0.0 by default, meaning every interface on the box, including the public one. In `ecosystem.config.js`, the file PM2 uses to run the app, add an explicit host flag:
args: 'start -p 3000 -H 127.0.0.1'
-H 127.0.0.1 restricts Next to the loopback interface only. Even if a future debugging session adds `ufw allow 3000` again out of habit, the port stays invisible from outside, because the app is no longer listening on the public interface at all.Restart the process to pick up the new argument - but not with `pm2 restart web`: that refreshes the running process's environment variables, it doesn't re-read `ecosystem.config.js` itself, so the new `args` can quietly fail to take effect. Point PM2 at the file directly instead:
pm2 restart ecosystem.config.js --only web --update-env
That makes PM2 re-read the whole file and apply the new `args` to the `web` process, instead of just restarting it with the old settings.
Start by confirming the app is alive locally. This splits "the app is down" from "the app is fine but unreachable from outside" into two separate problems:
curl -I http://127.0.0.1:3000
Expect a successful response (usually 200, though the exact code depends on what your root route returns) with headers like `X-Powered-By: Next.js`.
Next, check which interface the process is actually bound to. It's faster than reasoning about it from the config:
sudo ss -tlnp | grep 3000
A line like `LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("next-server",...))` is what you want. The address to the left of the port is 127.0.0.1, not 0.0.0.0. If it reads `0.0.0.0:3000` or `*:3000` instead, the app is listening on every interface, and ufw's rules are the only thing standing between it and the internet.
The final check runs from your own machine, not the VPS. Through port 80, where nginx is listening, the request should go through:
curl -I http://example.com/
A request straight at the app's raw port, on a properly locked-down perimeter, should hang and time out. The connection never gets established, because ufw drops the packet before it ever reaches port 3000:
curl -m 5 http://YOUR_IP:3000/
Get a response from the app instead of a timeout, and it means two conditions lined up at once: the app is listening on the public interface AND the firewall is letting the traffic through - one alone isn't enough, which is the whole point of the "second line of defense" above. Check both: `sudo ss -tlnp | grep PORT` for whether the bind is 0.0.0.0, `sudo ufw status numbered` for a leftover `allow PORT` rule.
Create a file under `/etc/nginx/sites-available/` with `server_name`, `listen 80`, and `location / { proxy_pass http://127.0.0.1:3000; }` along with four headers: Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto. Symlink it into `sites-enabled`, validate it with `nginx -t`, then apply it with `systemctl reload nginx`.
`sudo ufw allow 80/tcp` and `sudo ufw allow 443/tcp`, followed by `sudo ufw status verbose` to confirm the rules actually took. Open these only if nginx genuinely needs to be reachable from outside directly; if traffic comes in through Cloudflare Tunnel, neither port needs to be open at all.
Without them, every request your app sees looks like it came from nginx itself on 127.0.0.1: logs show one IP for every visitor, and anything depending on domain or protocol (http vs https) can break silently. The Host, X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto headers pass along the real request details that nginx already knows - but the app itself still has to be configured to trust that specific proxy and parse the headers (Express's `trust proxy`, for instance), or it keeps reading from the socket instead.
`nginx -t` parses the config in a separate process without touching the running server, and it immediately points to the file and line if something's wrong. Skip it and reach straight for `restart` on a broken config, and nginx can fail to come back up entirely, leaving the site down until someone finds the typo by hand.
From a different machine, not the VPS itself, run `curl -m 5 http://YOUR_IP:APP_PORT/`. A response instead of a timeout means both the app is listening on the public interface and the firewall is letting the traffic through. On the server, `sudo ss -tlnp | grep APP_PORT` shows whether the app is bound to 127.0.0.1 only or listening on every interface via 0.0.0.0, and `sudo ufw status numbered` shows whether a leftover allow rule is letting it through.
The perimeter's closed, nginx is routing traffic, and ufw only lets through what it should. The next logical step in this series is shipping a new version of the app with zero downtime while nginx and PM2 already hold production traffic, and that's a dedicated article on its own. In the meantime, here's the full path so far: deploying a service on a server, deploying a Next.js site on a VPS, diagnosing the memory leak, a healthcheck that actually restarts, and securing a fresh VPS.