In a world with , chapter 6.2.
THE PATH · SESSION 37 OF 66 · UNIT 6 · ACT II
The Gatekeeper
Nginx, Reverse Proxies, and Process Management
At a glance
- PACE
- Session 37 of 66 · suggested week 13. Continue when you are ready.
- UNIT
- Unit 6 · I Bought Land in the Cloud and Nobody Can Stop Me
- ACT
- Act II · The cheat skill
- BEFORE
- 6.1 Claiming Territory
- AFTER
- 6.3 The Teleportation Network
- ARTIFACT
- Nginx serving /var/www/hall-client on port 80 and proxying /api/ to a pm2-managed Node process on a loopback port, with a reboot-tested startup hook and docs/runbook.md
- READ
- 65 min · 3 diagrams · 32 code blocks
The hook#
Last session ended with a hardened machine and a Node that refused to start for want of a secret. Open http://YOUR_DROPLET_IP in a browser right now and the tab spins and gives up, because port 80 is allowed through the firewall and nothing on the server is listening there. Today something listens. Nginx takes the public ports, serves a page from a directory, and forwards everything under /api/ to a Node process that only the server itself can reach. Then pm2 takes custody of that process so it survives you closing the terminal, survives a crash, and survives sudo reboot.
By the end of the session a browser anywhere opens http://YOUR_DROPLET_IP and sees a page you wrote, curl http://YOUR_DROPLET_IP/api/ping returns JSON from Express, curl http://YOUR_DROPLET_IP:3000/health times out because the firewall is doing its job, and pm2 list after a reboot shows the process online with nobody having started it. You will also have made a 502 on purpose, read the exact line Nginx writes about it, and put it back.
The land has a wall now and a gate in the wall. Behind the gate, a clerk who never sleeps hands each visitor to the right room.
The idea#
Nginx is a web server and a reverse proxy. The first job is the one GitHub Pages did for you: given a directory and a path, send the file. The second job is the one this unit needs: accept a connection from the public internet, open a second connection to a process on the same machine, forward the request, and relay the answer. The browser never learns that a Node process exists. It talked to Nginx on port 80 and got a response.
Why not let Express listen on 80 itself? Three reasons that matter at your scale. Ports below 1024 belong to root, and you do not want the hall running as root. Nginx serves static files faster than Express will and does TLS termination next session so Express never touches a certificate. And one Nginx can front several apps on several ports under one address, which is how a second project lands on this same droplet without a second droplet. The pattern is edge, upstream, process, and the figure draws it in that order.

Server blocks and the two directories#
Nginx configuration lives in /etc/nginx/. nginx.conf is the main file, and you leave it alone. sites-available/ holds one file per site; sites-enabled/ holds symlinks to the ones that are live. The package ships a site called default that serves the welcome page. The workflow for every change is the same four commands: edit the file in sites-available, make sure the link exists in sites-enabled, sudo nginx -t to check syntax, sudo systemctl reload nginx to apply it without dropping connections. A reload after a failed test applies nothing, which is why the test comes first.
A server block answers for a listen port and a server_name. Inside it, location blocks match the request path by prefix, and the longest matching prefix wins. root names the directory, and try_files $uri $uri/ =404 means: look for a file at that path, then a directory, then answer 404. Next session server_name becomes your domain; today it is the IP, and _ would also work as "whatever the client asked for".
What proxy_pass does to the path#
A location /api/ block with proxy_pass http://127.0.0.1:4100; forwards the request path untouched: /api/items arrives at Express as /api/items. The same block with proxy_pass http://127.0.0.1:4100/;, one trailing slash more, replaces the matched prefix with that slash: /api/items arrives as /items. The nginx reference states the rule as "if proxy_pass is specified with a URI, the part of the request URI matching the location is replaced by that URI." Both forms are useful. Only one of them matches the hall, whose routes are written with /api/ in them, app.get('/api/inbox', ...), so the form without a trailing slash is the one you want. Write the mapping beside the block when you edit it: incoming /api/inbox must reach Express as /api/inbox.
Use 127.0.0.1, not localhost, in proxy_pass. Nginx resolves localhost from /etc/hosts when it starts, and on Ubuntu that name has both an IPv6 and an IPv4 entry. A Node process bound to IPv4 only then gets intermittent 502s as Nginx tries [::1] first. The numeric address removes the ambiguity, and it matches what "loopback port" in the figure means: an address only this machine can dial.
The proxy_set_header lines matter because the upstream connection is a new connection. Without Host $host, Express sees 127.0.0.1:4100 as the host. Without X-Forwarded-For, every request appears to come from 127.0.0.1, which is Nginx. Without X-Forwarded-Proto, Express cannot tell that the original request was HTTPS. The Upgrade and Connection pair exist so a WebSocket handshake can pass through later; they cost nothing now.
Why a terminal is not a process manager#
node server.js runs as a child of your shell. Close the SSH session and the shell gets a hangup, and the child goes with it. If the process throws and exits, it stays exited. If the machine reboots, nothing knows to start it. A process manager is a daemon that owns your process instead of the shell: it restarts on exit, keeps the stdout and stderr in files you can tail, and registers itself with systemd so that the whole list comes back on boot. pm2 is the one the Node world uses, and it is an npm package installed globally.
The boot part has a precise sequence that old notes always get backwards. pm2 startup does not install anything; it prints a command, with the path to the pm2 binary and your username in it, that must be run with sudo. You paste and run that printed command. Then pm2 save writes the current process list to ~/.pm2/dump.pm2, which is what the systemd unit resurrects at boot. Save before startup saves a list nobody will read. Startup without save registers a unit that starts an empty list. Then you reboot, on purpose, because an untested boot hook is a guess.
An ecosystem file turns the command line you typed into a file in the repo. pm2 start ecosystem.config.js reads the name, the script, the working directory, the restart policy, and any environment the process should see. Next session's hall will use one more field, node_args, to pass --env-file=.env so Node reads the secrets file on the server the same way it does on your laptop. The test app today uses the env field instead, so you can see that an ecosystem PORT reaches process.env.PORT.
Two connections, two checks#
A 502 from Nginx means "I accepted the client's connection and could not get a good answer from the upstream." It does not say whether the process is dead, listening on another port, or crashing on every request. So the diagnosis is two curls, in this order. First, on the server, hit the upstream directly: curl http://127.0.0.1:3000/health. If that fails, the problem is the process, and Nginx is innocent. If that succeeds, hit the public path through Nginx: curl http://YOUR_DROPLET_IP/api/ping. If that fails, the problem is the proxy block. The checkpoint below models exactly that decision, because it is the one you will make at two in the morning.
The demonstration#
Two SSH sessions to the droplet, both as deploy; call them Terminal A and Terminal B. The laptop terminal is a third window. Everything today happens on the server except the curls marked laptop. Where you are at the start of today:
laptop ~/projects/hall-api and ~/projects/hall-client · nothing runningserver A ssh deploy@YOUR_DROPLET_IP · /home/deploy/hall-api and hall-client cloned · only sshd listeningserver B second ssh session, same user · used for curl and logsbrowser http://YOUR_DROPLET_IP · spins and gives up, nothing on port 80 yetInstall and look, in Terminal A:
# server, Terminal Asudo apt updatesudo apt install -y nginxsystemctl status nginx --no-pager● nginx.service - A high performance web server and a reverse proxy server Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled) Active: active (running) since Sat 2026-11-14 15:02:11 UTC; 3s agoactive (running) and enabled are the two words to read. From the laptop, curl -I http://203.0.113.10 prints HTTP/1.1 200 OK and a Server: nginx/1.24.0 (Ubuntu) header, and a browser shows the welcome page. Port 80 went from a spinner to a page because a process now listens on it.
The directory the hall will eventually live in, with ownership that lets you write and Nginx read, still in Terminal A:
# server, Terminal Asudo install -d -o deploy -g www-data -m 0750 /var/www/hall-clientnano /var/www/hall-client/index.html<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Guild hall</title></head><body> <h1>The hall is under construction.</h1> <p>Nginx is serving this file from /var/www/hall-client.</p></body></html>install -d makes the directory owned by deploy with group www-data, which is the user Nginx runs as. Mode 0750 lets the group read and traverse, and keeps everyone else out. You write the file as yourself, no sudo. The site file:
# server, Terminal Asudo nano /etc/nginx/sites-available/hallserver { listen 80; listen [::]:80; server_name 203.0.113.10; root /var/www/hall-client; index index.html; location / { try_files $uri $uri/ =404; }}Enable it and retire the default:
# server, Terminal Asudo rm /etc/nginx/sites-enabled/defaultsudo ln -s /etc/nginx/sites-available/hall /etc/nginx/sites-enabled/hallsudo nginx -tsudo systemctl reload nginxnginx: the configuration file /etc/nginx/nginx.conf syntax is oknginx: configuration file /etc/nginx/nginx.conf test is successfulReload the browser tab: your heading. Nginx is serving a directory you own. That is the static half of the final shape, and in 6-4 the Vite build lands in that same directory.
Now a process to stand behind it. Not the hall yet, because the hall needs a database; a forty-line Express app whose only purpose is to be an upstream, in Terminal A:
# server, Terminal Amkdir -p ~/test-app && cd ~/test-appnpm init -ynpm install expressnano server.jsconst express = require('express'); const app = express();const port = process.env.PORT || 3000; app.get('/', (req, res) => { res.json({ message: 'Hello from Node behind Nginx', host: req.get('host') });}); app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: Math.round(process.uptime()) });}); app.get('/api/ping', (req, res) => { res.json({ pong: true, path: req.originalUrl, from: req.get('x-forwarded-for') || 'direct' });}); app.use((req, res) => { res.status(404).json({ error: 'no route for ' + req.originalUrl });}); app.listen(port, () => { console.log('listening on ' + port);});port reads process.env.PORT so an ecosystem file can set it later. The 404 handler echoes the path Express saw, which is the instrument for the trailing-slash lesson. Start it in Terminal A, and it holds that terminal:
# server, Terminal Anode server.jslistening on 3000In Terminal B, prove the upstream on its own, then prove the firewall from the laptop:
# server, Terminal Bcurl -s http://127.0.0.1:3000/health{"status":"ok","uptime":12}From the laptop, curl -m 5 http://203.0.113.10:3000/health prints curl: (28) Connection timed out after 5001 milliseconds. Port 3000 is not in the UFW list, so the outside cannot reach the process, and that is the design. Now the proxy, in Terminal B:
# server, Terminal Bsudo nano /etc/nginx/sites-available/hallserver { listen 80; listen [::]:80; server_name 203.0.113.10; root /var/www/hall-client; index index.html; # /api/ping must reach Express as /api/ping: no trailing slash on proxy_pass location /api/ { 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; } location / { try_files $uri $uri/ =404; }}# server, Terminal Bsudo nginx -t && sudo systemctl reload nginxFrom the laptop:
# laptopcurl -i http://203.0.113.10/api/pingHTTP/1.1 200 OKServer: nginx/1.24.0 (Ubuntu)Content-Type: application/json; charset=utf-8 {"pong":true,"path":"/api/ping","from":"198.51.100.23"}Read the three facts in that response. Server is Nginx, because Nginx answered the laptop. The body is from Express, because Nginx forwarded. from is your laptop's public address, because X-Forwarded-For carried it; without that header Express would have printed direct. Now curl http://203.0.113.10/ still returns the HTML page, because / matched the static block and /api/ matched the proxy block. Longest prefix wins.
Break the path on purpose. Change proxy_pass to http://127.0.0.1:3000/; with the trailing slash, test, reload, and curl /api/ping again:
HTTP/1.1 404 Not Found {"error":"no route for /ping"}Express told you what it received: /ping. The /api/ prefix was replaced by /. Put the slash back, test, reload, and the 200 returns. That is the Wrong path diagnosis, seen once with your own instrument.
Now the process manager. In Terminal A, Ctrl-C the running app; the laptop curl now returns 502 Bad Gateway, and sudo tail -n 2 /var/log/nginx/error.log shows connect() failed (111: Connection refused) while connecting to upstream. Remember that line. Then, in Terminal A:
# server, Terminal Asudo npm install -g pm2cd ~/test-apppm2 start server.js --name test-apppm2 list┌────┬────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┐│ id │ name │ mode │ ↺ │ status │ cpu │ mem │ user │├────┼────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┤│ 0 │ test-app │ fork │ 0 │ online │ 0% │ 48mb │ deploy │└────┴────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┘The laptop curl is 200 again, and Terminal A is free, because pm2 owns the process now. pm2 logs test-app --lines 5 shows the listening on 3000 line in the out log. Close Terminal A entirely and curl again: still 200. The shell is gone and the process is not.
Boot, in the correct order, in Terminal B, the session you still have:
# server, Terminal Bpm2 startup[PM2] Init System found: systemd[PM2] To setup the Startup Script, copy/paste the following command:sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deployCopy the printed line, yours, not this one, and run it. It writes a systemd unit called pm2-deploy. Then:
# server, Terminal Bpm2 savesudo rebootpm2 save prints [PM2] Saving the process list to /home/deploy/.pm2/dump.pm2. The reboot drops your sessions. Wait thirty seconds, ssh deploy@203.0.113.10 from the laptop, call that session Terminal A again, and:
# server, Terminal A, a fresh session after the rebootpm2 listuptimetest-app is online with uptime measured in seconds, and nobody typed pm2 start. Curl from the laptop returns 200. That reboot is the evidence the figure calls Process.
Finally, the process definition as a file, so the next person does not need to remember a command line:
# server, Terminal Anano ~/test-app/ecosystem.config.jsmodule.exports = { apps: [{ name: 'test-app', script: 'server.js', cwd: '/home/deploy/test-app', instances: 1, autorestart: true, watch: false, max_memory_restart: '200M', env: { NODE_ENV: 'production', PORT: 3000 } }]};# server, Terminal Acd ~/test-apppm2 delete test-apppm2 start ecosystem.config.jspm2 saveChange PORT to 3001 in that file, pm2 restart ecosystem.config.js --update-env, and the laptop curl is a 502 while curl http://127.0.0.1:3001/health on the server is fine: the ecosystem env reached the process, and Nginx is still pointed at 3000. Put it back to 3000, restart, save. You have now seen a 502 from both sides, process down and port moved.
A small model you can run here. It does not start Nginx or pm2. It models the proxy_pass path rule, the two-curl diagnosis, and the save-before-boot rule, and it proves you can state them. The reboot on your droplet is the proof that counts.
function check(condition, message) { if (!condition) throw new Error(message); console.log('PASS:', message);} function upstreamPath(location, proxyPass, requestPath) { const match = proxyPass.match(/^https?:\/\/[^/]+(\/.*)?$/); const uri = match && match[1]; if (!uri) return requestPath; return uri + requestPath.slice(location.length);}check(upstreamPath('/api/', 'http://127.0.0.1:4100', '/api/inbox') === '/api/inbox', 'no URI on proxy_pass forwards the path untouched');check(upstreamPath('/api/', 'http://127.0.0.1:4100/', '/api/inbox') === '/inbox', 'a trailing slash replaces the matched prefix'); function diagnose(result) { if (!result.direct) return 'inspect the upstream process'; if (!result.proxied) return 'inspect the proxy block'; return 'request path works';}check(diagnose({ direct: false, proxied: false }) === 'inspect the upstream process', 'Upstream first');check(diagnose({ direct: true, proxied: false }) === 'inspect the proxy block', 'Proxy boundary isolated');check(diagnose({ direct: true, proxied: true }) === 'request path works', 'both connections answer'); function boot(steps) { let saved = null; let unit = false; const running = ['test-app']; steps.forEach(function (step) { if (step === 'startup') unit = true; if (step === 'save') saved = running.slice(); }); return unit && saved ? saved : [];}check(boot(['startup', 'save']).length === 1, 'startup, then save, then the list survives a reboot');check(boot(['startup']).length === 0, 'startup without save boots an empty list');check(boot(['save']).length === 0, 'save without the startup unit boots nothing'); console.log('ok: checkpoint-6-2');Where it breaks#
Your turn#
The quest#

Write docs/runbook.md in ~/projects/hall-api on your laptop, next to docs/server.md, commit, push. It is the page you would read at two in the morning. No secrets.
- The four-command Nginx change workflow, with the exact file paths on your server.
- The pm2 commands you actually use: start from the ecosystem file,
list,logs --lines 50,restart,delete,save, and where the dump file lives. - The path mapping sentence for
/api/, and what the 404 body looked like when the slash was wrong. - The two-curl diagnosis for a 502, with the two commands and what each outcome means.
- The reboot result: the
pm2 listtable aftersudo reboot, pasted, with the uptime column. - Where the three logs are: pm2 out and error, Nginx access, Nginx error, and the
journalctl -u nginxform. - One line on the port change experiment: what moved, what did not, and which curl told you.
Evidence: the committed docs/runbook.md, a screenshot named 6-2-after-reboot.png of pm2 list with a fresh uptime, and a screenshot named 6-2-502.png of the Nginx error log line.
Side quests#
The block you wrote is a reverse proxy in the narrowest sense, and the three log files you located are the beginning of observability, so the two library sheets for this session name what you just built.
Side quests from this session
- Proxies: Forward vs ReverseThe location /api/ block forwarding to 127.0.0.1 is a reverse proxy in its narrowest form, and the sheet explains why the client never sees the upstream.System Design
- ObservabilityThe pm2 restart counter and the three log files you located are the first instruments of observability, and the sheet names what to watch next.System Design
Before next session#
Leave everything running. The droplet now carries Nginx on port 80 and pm2 holding test-app on 3000, and both come back after a reboot. Close your SSH sessions with exit. Nothing runs on the laptop. State at close:
laptop ~/projects/hall-api and ~/projects/hall-client · docs/runbook.md pushed · nothing runningserver A ssh deploy@YOUR_DROPLET_IP · Nginx on 80 serving /var/www/hall-client · test-app on 3000 under pm2 · closed with exitserver B closed with exitbrowser http://YOUR_DROPLET_IP · your under-construction headingNext session the IP gets a name. You register a domain, point an A record at YOUR_DROPLET_IP, change server_name to that name, and let certbot fetch a Let's Encrypt certificate so the address bar shows https://. Have a payment method ready for the registrar; prices vary by ending, and the session teaches you where to read the renewal price before you buy. Read the Let's Encrypt "how it works" page from next session's docs list once, just enough to know what a challenge is. The session starts on the laptop, at a registrar's search box.
See also