In a world with , chapter 6.4.

THE PATH · SESSION 39 OF 66 · UNIT 6 · ACT II

Opening the World Gate

Deploy Your Full Stack App to the Internet

70 min read3 diagrams3 side quests1 runnable4,651 words30 code blocks

At a glance

PACE
Session 39 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.3 The Teleportation Network
AFTER
7.1 Sparring Grounds
ARTIFACT
The guild hall live at https://YOUR-DOMAIN: hall-api under pm2 reading .env on port 4100, a MongoDB Atlas cluster, the hall-client build served by Nginx with /api/ proxied, a deploy.sh, and docs/release.md with smoke tests and rollback steps
READ
70 min · 3 diagrams · 30 code blocks

The hook#

Three sessions of groundwork and the public page still says "under construction." The test app behind /api/ has never heard of Bran or Sera. Today that changes in one sitting. The hall needs a database, and the droplet has none, because Unit 4 ran MongoDB on your laptop. The hall needs its five environment variables, and the server has no .env. The client needs to stop asking localhost:4100, because on a phone localhost is the phone. Each of those is a short change, and they only work together, so this session does them in order and checks each one before the next.

By the end of the session https://YOUR-DOMAIN shows the hall's login, a letter from Bran reaches Sera's inbox from a phone on mobile data, pm2 list shows hall-api online on 4100, curl https://YOUR-DOMAIN/api/items without a token answers 401 from Express through Nginx over TLS, and ./deploy.sh pulls, builds, restarts, and smoke-tests the whole thing in one command. You will also have rolled the page back once, watched the letters survive it, and written down why. The last H3 of the demonstration shows the same two repos on Railway, where the owner's example hall lives, for the day you want the machine hidden.

The gate opens. Travelers you have never met walk through it and find the hall exactly as you built it, because you carried every stone across yourself.

The idea#

A release is a set of inputs, not a folder you copied. The inputs are a source revision, a lockfile, a Node version, a build command, the names of the configuration the process reads, and the data the code expects to find. Change any one and you have a different release. Today you make every input explicit: the revision is a commit on main of each repo, the lockfile is package-lock.json, Node is the 24 from 6-1, the build is npm run build in hall-client, the configuration is five names in .env, and the data lives in a cluster you create in this session. The figure calls the three stages build, deploy, verify.

Three stages of a release of the guild hall: build creating a reproducible Vite output from a known commit and lockfile, deploy applying the .env, pm2 ecosystem, and Nginx site to start hall-api on 4100, and verify exercising the public journey from a phone while keeping the previous build for rollback.
Fig 1 · Build, deploy, verify: a known revision, the five variable names applied, the journey walked from outside.

The database the droplet does not have#

Unit 4 installed MongoDB Community Server on your laptop and the hall connected to mongodb://127.0.0.1:27017/guildhall. That address on the droplet means "this droplet," where nothing listens on 27017. Two honest options. Run MongoDB on the droplet itself, bound to loopback, which keeps the data on your machine and costs you a chunk of its 1 GB of memory. Or use MongoDB Atlas, the hosted service, whose free tier is enough for the hall and asks for nothing from the droplet except an outbound connection. This session takes Atlas as the one complete path and gives the droplet install as an aside, because a managed database with its own firewall and its own users is the right first database to run in public. One limit is visible at the point of choice. The free tier takes no scheduled backups. You take your own with mongodump, and 12-3 practises exactly that.

Atlas has three nouns, and the buttons move around them. A cluster is the server; you create a free one, called M0, in the region nearest your droplet. A database user is the account the hall logs in with; you create one named hall with a generated password and read-write access. The network access list is the firewall; you add your droplet's public IP and nothing else. "Allow access from anywhere" is a checkbox that exists for development on a laptop whose address changes, and it is the exact failure the diagnosis below calls Database exposed. If you rebuild the droplet, the IP changes and the list needs the new one; write that in the release notes.

The connection string Atlas shows under Connect, Drivers looks like mongodb+srv://hall:<password>@cluster0.ab1cd.mongodb.net/?retryWrites=true&w=majority. You replace <password> with the real one and insert the database name before the ?: /guildhall?. The +srv form looks up the cluster's hosts through DNS, which is the same system you learned last session doing one more job. The string is a secret, because the password is in it. It goes in .env and nowhere else.

The same five names, read by Node, owned by one user#

The hall reads MONGODB_URI, JWT_SECRET, PAGE_ORIGIN, PORT, and the guard in server.js throws JWT_SECRET is required when the secret is missing. On your laptop those came from .env through node --env-file=.env server.js. On the server they come from the same kind of file, the same flag, and no library: there is no dotenv package anywhere in this path, and you should not add one now. The only new thing is who may read the file. chmod 600 .env makes it readable by deploy alone, so another account on the machine, or a stray web process, cannot cat it.

pm2 does not read .env either. It starts Node, and Node reads the file if you pass the flag. The ecosystem file has a field for exactly that, node_args: '--env-file=.env', with cwd set to the repo so the relative path resolves. Without it, pm2 starts the process, the guard throws, pm2 restarts it, the guard throws again, and pm2 list shows a restart count climbing into the hundreds while the status flickers. That loop is the signature of a missing variable under a process manager, and it is the first thing to check when pm2 logs shows the same error repeating.

PAGE_ORIGIN on the server is https://YOUR-DOMAIN. Behind Nginx the page and the API share that origin, so the browser never sends a cross-origin request and the cors middleware from 4-12 never has to say yes. Setting the variable anyway costs nothing and makes the API honest if you ever put the page on a different host, which the Railway section does. A PORT of 4100 keeps the number the hall has always used; Nginx will be told to proxy to it.

The one line in the client#

hall-client asks for http://localhost:4100 because the slate did, in a constant named apiBase, and that was correct with two dev servers on one laptop. In production the page is served from https://YOUR-DOMAIN and the API is at https://YOUR-DOMAIN/api/, the same origin, so the base should be empty and every fetch should start with /api/. The way to have both without editing the file per machine is Vite's build-time environment. import.meta.env.VITE_API_URL is replaced during npm run build with the value from the mode's env file; in development Vite reads .env.development, in a production build it reads .env.production, and a variable that exists in neither is undefined.

So the line becomes const apiBase = import.meta.env.VITE_API_URL || ''; and .env.development holds VITE_API_URL=http://localhost:4100. That file is committed; it is not a secret, and anything prefixed VITE_ ends up in the bundle anyway, which is why the JWT secret must never be given a VITE_ name. On the server, npm run build runs with no VITE_API_URL, the base is the empty string, and the bundle fetches /api/inbox from its own origin. The checkpoint below models that resolution. Vite's output is dist/index.html, dist/assets/index-<hash>.js, dist/assets/index-<hash>.css, and dist/vite.svg; the hashes change every build, which is why the copy step uses --delete.

Nginx: one origin, two kinds of request#

The site file from 6-3 already has the shape. root /var/www/hall-client serves the build. location /api/ proxies to 127.0.0.1:4100 now, instead of the test app's 3000. The one change in the static block is try_files $uri $uri/ /index.html;: a request for a path that is not a file gets index.html, so a direct reload of the page, or a bookmark, loads the app instead of a 404. The hall is a single screen without a router, so today that line only matters for the reload. Do not let it swallow the API: an unknown /api/ path matches the proxy block first and reaches Express, whose answer is its own 404, never index.html. Test both.

Verify across the real boundary, and know which rollback you mean#

A health check proves a route answers. A smoke test proves the journey: open the public page in a browser that has never seen it, register, log in, send a letter, read it from the recipient's account, reload, spend from the pack. Do it from a phone on mobile data so a different network, resolver, and certificate store are in the loop. Then pm2 restart hall-api and do one of those again; a restart that loses the ability to log in means the secret changed between runs.

Rollback has two meanings and the figure separates them. Rolling the code back is a redeploy of an older revision, and deploy.sh makes it cheap by keeping the previous build beside the live one. Rolling the data back is a restore from a backup, and nothing in this session takes one automatically; Atlas's free tier does not include scheduled backups, and mongodump is the manual tool. A code rollback does not un-send Bran's letter. If a release changed the shape of the documents, the old code may not read the new rows. Decide which rollback you are doing before you type, and write both procedures down while the system is calm.

The demonstration#

Laptop first, then Atlas in a browser, then the server. Two SSH sessions as before, Terminal A and Terminal B. The example values use 203.0.113.10, YOUR-DOMAIN, and YOUR_GITHUB_USER. Where you are at the start of today:

TEXT
laptop      ~/projects/hall-api and ~/projects/hall-client · nothing running · local hall-api on 4100 runs only while you test the clientserver A    ssh deploy@YOUR_DROPLET_IP · /home/deploy/hall-api and hall-client cloned, no .env · Nginx on 80 and 443 · test-app on 3000 under pm2server B    second ssh session, same user · used for curl against 127.0.0.1:4100browser     Atlas dashboard · https://YOUR-DOMAIN still shows the heading

On the laptop, in ~/projects/hall-client, the one line and its env file:

DIFF
-const apiBase = 'http://localhost:4100';+const apiBase = import.meta.env.VITE_API_URL || '';
ENV
VITE_API_URL=http://localhost:4100

Save the second as .env.development. Run npm run dev and confirm the hall still logs in against your local hall-api; the base is read from the file now, and the network panel still shows requests to localhost:4100. Then build once locally to see the shape:

SH
# laptop, in ~/projects/hall-clientnpm run buildls dist dist/assets
TEXT
dist:assets  index.html  vite.svg dist/assets:index-B7xk2P9q.css  index-Dq3m8cVZ.js

Commit and push: git add -A && git commit -m "Read the API base from VITE_API_URL" && git push. In ~/projects/hall-api, add a start script to package.json so a platform that runs npm start can find the entry; the VPS path does not need it today, the Railway path does:

JSON
{  "scripts": {    "dev": "node --env-file=.env server.js",    "start": "node --env-file-if-exists=.env server.js"  }}

--env-file-if-exists is Node's own flag for a file that may be absent; it reads .env on your laptop and on the droplet and ignores the missing file on a platform that injects variables directly. Commit and push.

Atlas, in a browser. Create an account at the Atlas page in the docs list, then: create a cluster, choose the free M0 tier and the region nearest your droplet, and give it a minute. Under Database Access, add a user hall, let Atlas generate the password, copy it somewhere temporary, role read and write to any database. Under Network Access, add 203.0.113.10/32, your droplet, with a comment. Under Connect, Drivers, copy the string. Assemble the final value with the password and the database name:

TEXT
mongodb+srv://hall:THE_GENERATED_PASSWORD@cluster0.ab1cd.mongodb.net/guildhall?retryWrites=true&w=majority

If the password contains characters such as @ or :, regenerate it until it does not, or URL-encode it; a raw @ in the password ends the credential early and the error says the host is unknown.

Terminal A, on the server:

SH
# server, Terminal Acd ~/hall-apigit pull --ff-onlynode -e "console.log(require('crypto').randomBytes(48).toString('hex'))"nano .env
ENV
MONGODB_URI=mongodb+srv://hall:THE_GENERATED_PASSWORD@cluster0.ab1cd.mongodb.net/guildhall?retryWrites=true&w=majorityJWT_SECRET=paste-the-96-hex-characters-node-just-printedPAGE_ORIGIN=https://YOUR-DOMAINPORT=4100NODE_ENV=production
SH
# server, Terminal Achmod 600 .envls -l .envnode --env-file=.env server.js
TEXT
-rw------- 1 deploy deploy 263 Nov 21 15:10 .envlistening on 4100

listening on 4100 with no MongooseServerSelectionError in the seconds after it is the first proof. A connection failure to Atlas does not stop the listen line; it arrives as a stack trace a few seconds later, and its usual cause is the network access list. Terminal B, prove the database by writing to it:

SH
# server, Terminal Bcurl -i -H 'Content-Type: application/json' \  -d '{"name":"Bran","email":"bran@example.com","password":"a-long-passphrase"}' \  http://127.0.0.1:4100/api/register
TEXT
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8 {"name":"Bran","email":"bran@example.com"}

A 201 means the document is in Atlas. Open the cluster's Collections tab if you like and see guildhall.users with one row. Ctrl-C the server in Terminal A. Now the process manager, in that same terminal:

SH
# server, Terminal Anano ~/hall-api/ecosystem.config.js
JS
module.exports = {  apps: [{    name: 'hall-api',    script: 'server.js',    cwd: '/home/deploy/hall-api',    node_args: '--env-file=.env',    instances: 1,    autorestart: true,    watch: false,    max_memory_restart: '300M'  }]};
SH
# server, Terminal Acd ~/hall-apipm2 delete test-apppm2 start ecosystem.config.jspm2 savepm2 logs hall-api --lines 3 --nostream
TEXT
[PM2] Applying action deleteProcessId on app [test-app](ids: [ 0 ])[PM2] [test-app](0) ✓┌────┬────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┐│ id │ name       │ mode    │ ↺       │ status   │ cpu    │ mem  │ user      │├────┼────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┤│ 1  │ hall-api   │ fork    │ 0       │ online   │ 0%     │ 71mb │ deploy    │└────┴────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┘[PM2] Saving the process list to /home/deploy/.pm2/dump.pm21|hall-api | listening on 4100

Read the restart column: 0. If it is climbing, the env file was not read; the Works only locally diagnosis has the fix. The test app is gone and its port with it. The startup unit from 6-2 is still installed, so the saved list is what survives the next reboot.

Commit the ecosystem file to the repo from the laptop later, or now from the server with git add ecosystem.config.js && git commit -m "pm2 process definition" && git push if you configured git's identity here. .env stays uncommitted; git status must never list it.

The client, in Terminal A:

SH
# server, Terminal Acd ~/hall-clientgit pull --ff-onlynpm installnpm run buildsudo install -d -o deploy -g www-data -m 0750 /var/www/hall-client.prevrsync -a --delete /var/www/hall-client/ /var/www/hall-client.prev/rsync -a --delete dist/ /var/www/hall-client/chmod -R u=rwX,g=rX,o= /var/www/hall-clientls /var/www/hall-client
TEXT
vite v7.1.4 building for production...✓ 34 modules transformed.dist/index.html                   0.46 kB │ gzip:  0.30 kBdist/assets/index-B7xk2P9q.css    1.21 kB │ gzip:  0.62 kBdist/assets/index-Dq3m8cVZ.js   188.73 kB │ gzip: 59.40 kB✓ built in 1.92sassets  index.html  vite.svg

No sudo on the copy, because 6-2 made deploy the owner of /var/www/hall-client. The .prev directory is created once and receives a copy of whatever was live before each release; that is the rollback target. If the build is killed on a 1 GB droplet, the alternative is to run npm run build on the laptop and rsync -a --delete dist/ deploy@203.0.113.10:/var/www/hall-client/ from there; the result is the same directory.

Nginx, still in Terminal A, with sudo nano /etc/nginx/sites-available/hall. Two edits in the first block: the proxy port and the fallback:

NGINX
    location /api/ {        proxy_pass http://127.0.0.1:4100;        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/ /index.html;    }
SH
# server, Terminal Asudo nginx -t && sudo systemctl reload nginx

The listen 443 ssl lines and the redirect block that certbot wrote stay exactly as they are. Now, from the laptop, the three curls that separate the layers:

SH
# laptopcurl -sI https://YOUR-DOMAIN | head -n 3curl -i https://YOUR-DOMAIN/api/itemscurl -i https://YOUR-DOMAIN/api/nope
TEXT
HTTP/2 200server: nginx/1.24.0 (Ubuntu)content-type: text/html HTTP/2 401content-type: application/json; charset=utf-8{"error":"missing token"} HTTP/2 404

The first is the build, served by Nginx. The second is Express refusing an unauthenticated request, through the proxy, over TLS, with the JSON error shape from 4-12; the message text is whatever your requireUser says. The third proves the proxy block beats the fallback: an unknown API path gets Express's 404, not the page. If the third returns 200 and HTML, /api/ is not reaching the proxy block.

The journey, in a browser on the laptop first. https://YOUR-DOMAIN shows the hall's login. Register Sera through the form or with the curl from earlier against https://YOUR-DOMAIN/api/register. Seed the catalog once with any token, the way 4-15 did, from the laptop:

SH
# laptopTOKEN=$(curl -s -H 'Content-Type: application/json' \  -d '{"email":"bran@example.com","password":"a-long-passphrase"}' \  https://YOUR-DOMAIN/api/login | sed 's/.*"token":"\([^"]*\)".*/\1/')curl -s -X POST -H "Authorization: Bearer $TOKEN" https://YOUR-DOMAIN/api/items/seed

Three items come back with a 201. Log in as Bran in the browser, send Sera a letter, log out, log in as Sera, read it. Reload the tab on the inbox: the page comes back, because of try_files, and the inbox reloads, because the token is in sessionStorage. Give Bran two lamp oils, spend one, try to spend fifty and watch the 409 leave the number alone. Then the phone, Wi-Fi off: the same URL, the same login, the same letter. That is the public journey the figure asks for. pm2 restart hall-api, refresh, send one more letter. Still signed, same secret.

The script that does the next release. On the laptop, in ~/projects/hall-api:

SH
#!/usr/bin/env bashset -euo pipefail API=/home/deploy/hall-apiCLIENT=/home/deploy/hall-clientWEB=/var/www/hall-clientSITE=https://YOUR-DOMAIN echo "== api"cd "$API"git pull --ff-only origin mainnpm ci --omit=devpm2 restart hall-api --update-env echo "== client"cd "$CLIENT"git pull --ff-only origin mainnpm cinpm run buildrsync -a --delete "$WEB/" "$WEB.prev/"rsync -a --delete dist/ "$WEB/"chmod -R u=rwX,g=rX,o= "$WEB" echo "== smoke"curl -fsS -o /dev/null -w "page %{http_code}\n" "$SITE/"curl -sS -o /dev/null -w "api %{http_code}\n" "$SITE/api/items"echo "api revision $(git -C "$API" rev-parse --short HEAD)"echo "client revision $(git -C "$CLIENT" rev-parse --short HEAD)"

Save it as deploy.sh, chmod +x deploy.sh, commit, push. The file is written on the laptop and runs on the server. In Terminal A, cd ~/hall-api && git pull --ff-only && ./deploy.sh. The last four lines are page 200, api 401, and two short hashes. Those hashes are the release. Write them in docs/release.md with the date. npm ci installs exactly the lockfile, which is the reproducibility the idea section promised; --omit=dev keeps test tooling off the server once Unit 7 adds it.

The rollback drill, so it is rehearsed and not imagined. Change the hall's heading on the laptop, commit, push, run ./deploy.sh, and see the new heading at the name. Now put the previous build back, in Terminal A:

SH
# server, Terminal Arsync -a --delete /var/www/hall-client.prev/ /var/www/hall-client/

Reload: the old heading. Log in as Sera: the letters are all there, including the one you sent after the heading changed. The page went back; the data did not, because the page and the data are different things. Run ./deploy.sh again to return to the new heading. For the API, a code rollback is git checkout <previous hash> in ~/hall-api followed by npm ci --omit=dev && pm2 restart hall-api, and the same sentence applies to the documents in Atlas.

If you would rather run MongoDB on the droplet#

The commands are real and they fit on one screen; the cost is memory and the responsibility for backups. On the server, following the Ubuntu install page in the docs list for the current major version, with 8.0 shown here:

SH
# server, Terminal Asudo apt-get install -y gnupg curlcurl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmorecho "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.listsudo apt-get updatesudo apt-get install -y mongodb-orgsudo systemctl enable --now mongodgrep bindIp /etc/mongod.conf

bindIp: 127.0.0.1 is the shipped default; leave it. The daemon listens only on loopback, UFW never sees it, and MONGODB_URI=mongodb://127.0.0.1:27017/guildhall in .env is the laptop string again. Add a database user and authorization: enabled in mongod.conf before you trust it with real accounts; the Atlas path gave you both for free. Never run a container with -p 27017:27017 on this machine: Docker publishes ports ahead of UFW, and that single flag puts an unauthenticated database on the public internet. On a laptop it is harmless; on a server it is the exposed database the diagnosis below describes.

The same app on Railway#

The reference hall at guild-hall-production.up.railway.app is not on a droplet. It runs on Railway, where the machine, Nginx, pm2, and certbot are the platform's problem and yours is the code and its variables. If the VPS was the lesson and not the destination, this is the path you keep. Install the CLI with npm install -g @railway/cli, then, on the laptop:

SH
# laptoprailway logincd ~/projects/hall-apirailway initrailway add --database mongorailway uprailway variable set 'MONGODB_URI=${{MongoDB.MONGO_URL}}/guildhall'railway variable set JWT_SECRET=paste-a-fresh-96-hex-secretrailway domain

railway init creates a project and links the folder. railway add --database mongo provisions a MongoDB service that publishes a MONGO_URL variable; the ${{MongoDB.MONGO_URL}} form is a reference, so the API gets the real string without you ever pasting a password. railway up builds from the folder, runs npm start, which is why the start script exists, and injects PORT. railway domain generates a public *.up.railway.app name with TLS already in front of it. Set PAGE_ORIGIN after the client exists.

The client is a second service in the same project: cd ~/projects/hall-client, railway link to the project, railway add --service hall-client, railway variable set VITE_API_URL=https://THE-API-DOMAIN.up.railway.app, and railway up. Railway's builder runs npm run build; if the deploy log says it could not find a start command, npm install serve and set the start command to npx serve -s dist -l $PORT. Then railway domain for the client, and back in hall-api, railway variable set PAGE_ORIGIN=https://THE-CLIENT-DOMAIN.up.railway.app. On Railway the page and the API are different origins, so this time the cors middleware from 4-12 is doing real work, and a wrong PAGE_ORIGIN is a blocked preflight in the browser console. The reference hall avoids that by serving its built client from Express as static files in one service; both layouts are fine, and railway logs is where listening on appears. The variable names are the same five. The release is still a revision, a lockfile, and a set of names; only the machine moved out of view.

A small model you can run here. It does not build, connect, or deploy. It models how the API base resolves in each mode, what counts as release evidence, and the difference between rolling code back and rolling data back. It proves you can say those rules. The phone on mobile data and the deploy.sh output prove your hall.

JScheckpoint-6-4.js
function check(condition, message) {  if (!condition) throw new Error(message);  console.log('PASS:', message);} function apiBase(env) {  return env.VITE_API_URL || '';}function requestUrl(env, path) {  return apiBase(env) + path;}check(requestUrl({ VITE_API_URL: 'http://localhost:4100' }, '/api/inbox') === 'http://localhost:4100/api/inbox', 'the development build asks the laptop API');check(requestUrl({}, '/api/inbox') === '/api/inbox', 'the production build asks its own origin'); function route(path) {  if (path.indexOf('/api/') === 0) return 'proxy';  return 'static';}check(route('/api/nope') === 'proxy', 'an unknown /api/ path still reaches Express');check(route('/inbox') === 'static', 'a page path falls through to index.html'); const release = { revision: 'a1b2c3d', build: true, smoke: false, rollback: true };function ready(r) {  return Boolean(r.revision && r.build && r.smoke && r.rollback);}check(!ready(release), 'Build alone does not prove deployment');check(ready(Object.assign({}, release, { smoke: true })), 'Release evidence complete'); function rollbackCode(state) {  return { page: state.previousPage, letters: state.letters };}const after = rollbackCode({ previousPage: 'old heading', letters: 3 });check(after.page === 'old heading' && after.letters === 3, 'a code rollback changes the page and keeps the letters'); console.log('ok: checkpoint-6-4');

Where it breaks#

Your turn#

The quest#

Evidence for the live hall: the checkpoint's rule that a build alone does not prove a deployment, the boundary assertion that release evidence is complete only with a smoke test and a rollback path, and the manual handoff of deploy.sh output and docs/release.md with the two revision hashes.
Fig 3 · The model proves what counts as evidence; deploy.sh's four lines and the phone prove your hall.

Write docs/release.md in ~/projects/hall-api, commit it beside deploy.sh, push. It is the handoff for the live hall: another developer with SSH access and this page could operate it tonight. No secrets, no connection strings.

  • The public URL and the two short hashes deploy.sh printed, with the date.
  • The five variable names in .env (the four from 4.15 plus NODE_ENV), with one phrase each on where their values come from, and the ls -l line showing the mode.
  • The service name in pm2, the ecosystem path, and the pm2 list row.
  • The Nginx site path, the proxy_pass port, and the try_files line.
  • The three curl results from step 8, pasted.
  • The smoke journey as a checklist with what you saw at each step, including the phone.
  • Rollback, code: the .prev rsync for the page and the git checkout plus pm2 restart for the API. Rollback, data: the mongodump and mongorestore lines and a sentence saying the free tier has no scheduled backup.
  • Known limits: a single droplet, a single process, the Atlas IP list that must change if the droplet is rebuilt.

Evidence: the committed deploy.sh and docs/release.md, a screenshot named 6-4-phone.png of the hall's inbox on a phone at https://YOUR-DOMAIN, and a screenshot named 6-4-deploy.png of the last four lines of ./deploy.sh.

Side quests#

The three logs and the restart counter you now read are the observability the library sheet describes, the single droplet with a single process is where the availability sheet's arithmetic starts, and an "allow from anywhere" entry in the database firewall is the security misconfiguration sheet in one checkbox, so the three pages for this session are all about the machine you just shipped.

Side quests from this session

  • Observabilitypm2 logs, the Nginx access and error logs, and the restart counter are the observability sheet's signals, and the deploy script's smoke lines are its first check.
    System Design
  • Availability and the NinesOne droplet running one process is where the availability sheet's arithmetic starts, so read it knowing exactly which single thing your hall depends on.
    System Design
  • A02:2025 Security MisconfigurationAn 'allow access from anywhere' entry in the Atlas network list, or a database bound to 0.0.0.0, is the security misconfiguration sheet in a single checkbox.
    Security

Before next session#

The hall is live and it stays live. The droplet carries Nginx on 80 and 443, pm2 holding hall-api on 4100, and a certificate that renews itself; Atlas holds the data. Nothing on the server changes unless you run deploy.sh. Close your SSH sessions. On the laptop nothing is running. State at close:

TEXT
laptop      ~/projects/hall-api and ~/projects/hall-client · deploy.sh and docs/release.md pushed · nothing runningserver A    ssh deploy@YOUR_DROPLET_IP · Nginx on 80 and 443 · hall-api on 4100 under pm2 reading ~/hall-api/.env · test-app deleted · closed with exitserver B    closed with exitbrowser     https://YOUR-DOMAIN · the hall's login · Atlas cluster holding guildhall

If you are stopping the course for a while, pause the bill on purpose instead of forgetting it. The 1 GB droplet from 6-1 is $6.00 a month at the time of writing, charged by the hour whether or not anyone visits, and the create page always shows the current number. A snapshot keeps the disk for $0.06 per GB per month at the time of writing, so you can destroy the droplet and bring the whole setup back later. The Atlas free cluster costs nothing, but Atlas pauses it after thirty days with no connections and emails you a week before; a paused cluster keeps its data and resumes with one click. The domain renews once a year at the renewal price you wrote in docs/domain.md, and a lapsed name goes back on the market.

  • Pause: on the droplet's page, Power, turn it off. Snapshots, name it hall-<date>, Take Snapshot, wait for it to finish. Destroy, Destroy this Droplet. The IP goes back to the pool.
  • Resume: Images, Snapshots, More, Create Droplet, same 1 GB size. The new droplet has a new IP, so update the A record at the registrar and the Atlas network access entry, and run ssh-keygen -R OLD_IP on the laptop. Then ssh deploy@NEW_IP, pm2 list shows hall-api online, and sudo certbot renew --dry-run still says Congratulations.
  • Atlas: nothing to pay. If the cluster shows Paused, click Resume and wait a minute. Take a mongodump before a long pause anyway, the way 12-3 does.
  • Domain: check the renewal date and that auto-renew is on in the registrar panel, with a card that will still be valid on that date.

Unit 7 is about proving the hall works instead of believing it: unit tests with Jest, request tests against the real routes with Supertest, a browser test with Playwright, and a GitHub Actions workflow that runs them on every push. Those tests will target /api/register, /api/login, /api/messages, /api/inbox, /api/items, and /api/pack, the twelve routes you just shipped, against a guildhall_test database on your laptop, never against Atlas. Read the Jest getting-started page from 7-1's docs list before you arrive. The next session starts on the laptop, in ~/projects/hall-api, with the live hall untouched.

See also