In a world with , chapter 6.1.

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

Claiming Territory

Cloud Machines, SSH, and Server Setup

65 min read3 diagrams2 side quests1 runnable3,741 words20 code blocks

At a glance

PACE
Session 36 of 66 · suggested week 12. 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
5.4 Cheat Skill Trials
AFTER
6.2 The Gatekeeper
ARTIFACT
A hardened Ubuntu 24.04 droplet with a deploy user, key-only SSH, UFW, Node 24, and the hall repos cloned, plus docs/server.md describing how to reach and recover it
READ
65 min · 3 diagrams · 20 code blocks

The hook#

The guild hall works. Bran sends Sera a letter, Ivo gets a 403, the pack refuses to go negative, and the AI feature you rebuilt in Unit 5 is merged on main. All of it runs on one machine, and that machine goes to sleep when you close the lid. Nobody else can open the hall, because the hall's address is localhost, and localhost means "this computer". Today you rent a second computer that never sleeps, in a building you will never visit, and you make it yours: a named account, a key instead of a password, a firewall with exactly three openings, and the same Node 24 your laptop runs.

By the end of the session you can type ssh deploy@YOUR_DROPLET_IP from your laptop and land in a shell on that machine without a password. Root cannot log in over the network. Passwords do not work over the network. node --version prints a 24 on both machines. Both hall repos are on GitHub and cloned onto the server, and node server.js fails there for the one reason you expect: there is no .env yet. That failure is the handoff to the rest of the unit.

You walk out past the last farm and plant a stake in the ground. Nothing is built yet. But the ground is yours, and you know the way back.

The idea#

A VPS, a virtual private server, is a slice of a real machine in a data center, sold by the month and reachable over the internet. DigitalOcean calls the slice a droplet. You get an Ubuntu install, a public IPv4 address, root, and nothing else. No desktop, no editor, no Node. Every change you make is a command typed over SSH, and that is why Unit 1 made you live in the terminal first: on this machine the terminal is the only room.

The contrast with the places you have deployed before is the point. GitHub Pages, where the landing page and the memory game live, serves files and runs nothing. A droplet runs a process you start, keeps it running after you disconnect, and lets a browser on the other side of the planet reach it on port 80 and 443. Railway, which you will meet at the end of the unit, does the same job with the machine hidden; this unit shows you the machine first so the hidden one is not magic.

SSH, the secure shell, is a terminal session over the network. You already own the half of it that matters: in Unit 1 you generated ~/.ssh/id_ed25519 and ~/.ssh/id_ed25519.pub and registered the public half with GitHub. The same pair works for a server. You paste the public key into the droplet when you create it, the private key never leaves your laptop, and the server lets in whoever can prove possession of the private key. A password can be guessed by a script a thousand times a minute. A 256-bit key cannot be guessed at all, so once key login works you switch password login off and the scripts hit a wall.

The order of operations is the whole lesson. Every hardening step closes a door, and some of those doors are the one you came in through. So you never close a door until you have walked through the new one in a second terminal and seen a prompt. First session stays open. Second session proves the new login. Only then does the first session run the command that disables the old way in. If you get that order right you will never need the provider's recovery console; if you get it wrong the console is the only way back, so you also write down where it is before you start.

What a sketch on a napkin should hold: a droplet with one public address. A named user, deploy, with sudo. Your ed25519 key in that user's authorized_keys. sshd configured so root and passwords are refused over the network, and verified with sshd -T, not with grep. UFW allowing OpenSSH, 80/tcp, and 443/tcp, enabled after the SSH rule exists. Node 24 from NodeSource. Git. Two clones under /home/deploy. The figure shows the three boundaries in that order: access, boundary, recovery.

Three stages of taking over a droplet in order: access through a named deploy account and SSH key, a UFW boundary opened for OpenSSH before enabling, and recovery kept through the provider console and a verified second session.
Fig 1 · Access, then boundary, then recovery: prove the new login before closing the old one.

What you are renting#

Create a droplet with the Ubuntu 24.04 LTS image, the smallest Basic plan with 1 GB of memory, and the region nearest you. The 512 MB plan is cheaper and will run out of memory the first time Vite builds the client. Prices change; read the current number on the create page and write it in your notes rather than trusting a figure from a session. You need a payment method on the account, even for a trial credit, and you should set a billing alert while the console is open.

The hostname is yourname-server, with your own name. It is only a label the shell prints at the prompt, but it is the label you will read a hundred times, so make it one you recognize. When the droplet finishes booting, the console shows a public IPv4 address. That address is the server for the rest of the unit; this session writes it as YOUR_DROPLET_IP, and the example output uses 203.0.113.10, a documentation address that belongs to nobody.

The create form has an authentication section with two choices, SSH key or password. Choose SSH key, click the button to add a new key, and paste the one line from cat ~/.ssh/id_ed25519.pub. That is the whole reason to generate the key before you open the form: the droplet is born with your public key in root's authorized_keys, root never has a password, and there is nothing to copy across later. The old two-step path, password first and keys afterwards, works, but it leaves a password on the machine during exactly the minutes when the scanning bots find a new address. One path.

The order that keeps you out of the recovery console#

Here is the sequence, said once in prose before you see the commands. You log in as root with the key. You update packages. You create deploy, give it sudo, and copy root's ~/.ssh into /home/deploy with the ownership fixed, so the same key opens the new account. In a second terminal you log in as deploy and run sudo whoami; it prints root. Only now, with the root session still open, do you edit sshd so root login and password login are refused, test the file, restart the service, and reconfirm in a third attempt that deploy still gets in. Then you allow OpenSSH in UFW and enable it. Then Node, git, and the clones.

Each step has a visible postcondition: a prompt, the word root, two lines from sshd -T, a Status: active table, a version string. If the postcondition does not appear, stop and read, because the next step assumes it. A command that returned without complaint has not proven anything. The second terminal is the proof.

What sshd actually reads#

The main file is /etc/ssh/sshd_config. Its first real line on Ubuntu 24.04 is Include /etc/ssh/sshd_config.d/*.conf, and for most keywords the first value sshd sees wins. The drop-in directory is read first. DigitalOcean's image lets cloud-init write /etc/ssh/sshd_config.d/50-cloud-init.conf during the first boot, and depending on the image that file can carry PasswordAuthentication yes. If it does, the PasswordAuthentication no you carefully write into the main file is never used, and grep on the main file will cheerfully tell you passwords are off while they are on.

So the edit has two parts. Set the three lines in the main file, and look at the drop-in and remove it if it contradicts you. Then ask the daemon, not the file: sudo sshd -T prints the effective configuration after every include and default has been applied, in lowercase. The two lines you want to see are permitrootlogin no and passwordauthentication no. That command is your evidence for the rest of the path, and the checkpoint below models why.

The service on Ubuntu is ssh, not sshd. sudo sshd -t tests the configuration and prints nothing when it is valid; sudo systemctl restart ssh applies it. The restart does not drop the session you are in, because an established connection keeps the process that is already serving it. New connections use the new rules.

The firewall is a list, and the list is read before you enable it#

UFW is the Ubuntu front for the kernel's packet filter. ufw allow OpenSSH adds a rule by application name, which the OpenSSH package registered as port 22; ufw allow 80/tcp and ufw allow 443/tcp add the two web ports. Nothing is enforced until ufw enable. When you run it, the tool warns that the command may disrupt existing SSH connections and asks y or n. You answer y, and your session survives, because the OpenSSH rule was added first. Enable with an empty list and the server is still there, with every port closed, including the one you are talking on.

Port 4100, where the hall will listen, is not in the list. That is deliberate. The API will sit behind Nginx on the loopback address, and Nginx will be the only process the outside can reach. A port that is not allowed is not a bug; it is the shape of the next session.

Node, from NodeSource, not from Ubuntu's archive#

apt install nodejs with no extra repository gives you the Node that was current when Ubuntu 24.04 was frozen, an 18 line that is out of support, and the hall's dependencies were installed and tested under 24. NodeSource publishes an apt repository per major version. The setup script adds their repository and signing key, and then apt install -y nodejs installs the 24 line from there. Node 24 is the Active LTS at the time of writing; the release schedule page in the docs list tells you when that changes. Read the script before you run it if you like, it is a shell file you downloaded to disk, and then run it with sudo -E bash. The version you must see afterwards starts with v24. Write that number next to the one on your laptop. "Latest" is not a version.

The demonstration#

One droplet, start to finish, from a laptop that has already done Unit 1. The commands marked laptop run in your own terminal. The commands marked server run inside an SSH session. The prompt tells you which: your laptop prompt is the one from your dotfiles, and the server prompt says root@yourname-server or deploy@yourname-server. Where you are at the start of today:

TEXT
laptop      ~/projects/hall-api and ~/projects/hall-client · nothing runningserver A    not yet · becomes ssh root@YOUR_DROPLET_IP, the first terminal, once the droplet existsserver B    not yet · becomes ssh deploy@YOUR_DROPLET_IP, the second terminal that proves each stepbrowser     DigitalOcean console · the create form, then the droplet's Access tab

Before the console, the key. On the laptop:

SH
# laptopls -la ~/.sshcat ~/.ssh/id_ed25519.pub

The listing shows id_ed25519 and id_ed25519.pub from Unit 1. If it does not, generate the pair the way 1-4 did, ssh-keygen -t ed25519 -C "you@example.com", and press Enter for the default path. cat prints one line beginning ssh-ed25519 AAAA and ending with your comment. Copy that whole line.

In the DigitalOcean console: Create, Droplets. Region nearest you. Image Ubuntu 24.04 (LTS) x64. Size Basic, Regular, the 1 GB option. Authentication SSH Key, New SSH Key, paste the line, name it after the laptop. Hostname yourname-server. Create. About a minute later the droplet has an address. Write it in your notes as the server IP. Also find, on the droplet's page, the Access tab with the recovery console button. Do not use it. Know where it is.

First login, from the laptop:

SH
# laptopssh root@203.0.113.10
TEXT
The authenticity of host '203.0.113.10 (203.0.113.10)' can't be established.ED25519 key fingerprint is SHA256:3kq1x9m0dH7Yq2JrVbXk8tN5p3cF0e1LwZ6yA4uRs9o.Are you sure you want to continue connecting (yes/no/[fingerprint])? yesWarning: Permanently added '203.0.113.10' (ED25519) to the list of known hosts.root@yourname-server:~#

That question appears exactly once per address. Your laptop has never spoken to this machine and is asking you to vouch for its host key. On a brand-new droplet, yes is right. The next time you see that question for the same address, something changed, and the Wrong host diagnosis below is for you.

On the server, as root:

SH
# server A, the root sessionapt update && apt upgrade -yadduser deployusermod -aG sudo deployrsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

adduser asks for a password and some optional fields; set a real password, because sudo will ask for it, and press Enter through the rest. usermod -aG sudo deploy puts the account in the group that may use sudo. The rsync line copies root's .ssh directory, which holds the authorized_keys cloud-init wrote from the key you pasted, into the new home with the new owner. That is how the same laptop key opens both accounts.

Now the second terminal. Open a new terminal window on the laptop and leave the root session alone:

SH
# laptop, a second terminal window: it becomes server Bssh deploy@203.0.113.10sudo whoami
TEXT
[sudo] password for deploy:root

The prompt reads deploy@yourname-server:~$, and sudo whoami prints root. That line is the one that allows the next step. Back in the root session, lock the daemon down:

SH
# server A, the root sessioncat /etc/ssh/sshd_config.d/50-cloud-init.confnano /etc/ssh/sshd_config

If the drop-in exists and contains PasswordAuthentication yes, remember it. In the main file, find the three keywords, uncomment them, and set them:

TEXT
PermitRootLogin noPasswordAuthentication noPubkeyAuthentication yes

Save with Ctrl-O, Enter, Ctrl-X. Then remove the drop-in if it contradicted you, test, restart, and ask the daemon what it believes:

SH
# server A, the root sessionrm -f /etc/ssh/sshd_config.d/50-cloud-init.confsshd -tsystemctl restart sshsshd -T | grep -E '^(permitrootlogin|passwordauthentication)'
TEXT
permitrootlogin nopasswordauthentication no

sshd -t prints nothing when the file is valid. If it prints a line with a filename and a line number, fix that line before you restart; a daemon that fails to restart over a typo is the other way to lock yourself out. In the second terminal, type exit, then ssh deploy@203.0.113.10 again. A prompt, with no password question, is the proof that the new rules admit you. Try ssh root@203.0.113.10 from a third terminal and read Permission denied (publickey). That refusal is correct, and you are allowed to be pleased by it.

The firewall, from the deploy session:

SH
# server B, the deploy sessionsudo ufw allow OpenSSHsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw enablesudo ufw status verbose
TEXT
Command may disrupt existing ssh connections. Proceed with operation (y|n)? yFirewall is active and enabled on system startupStatus: activeLogging: on (low)Default: deny (incoming), allow (outgoing), disabled (routed)New profiles: skip To                         Action      From--                         ------      ----22/tcp (OpenSSH)           ALLOW IN    Anywhere80/tcp                     ALLOW IN    Anywhere443/tcp                    ALLOW IN    Anywhere22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)80/tcp (v6)                ALLOW IN    Anywhere (v6)443/tcp (v6)               ALLOW IN    Anywhere (v6)

Default deny incoming, three openings. Your session is still alive because the first rule was OpenSSH. Now Node and git:

SH
# server B, the deploy sessioncurl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.shsudo -E bash nodesource_setup.shsudo apt install -y nodejs gitnode --versionnpm --versiongit --version
TEXT
v24.11.011.6.1git version 2.43.0

Your patch numbers will differ; the v24 is what matters, and it must match the major version on your laptop. If node --version prints v18, the NodeSource step did not run and apt installed Ubuntu's own package; the Unsupported runtime diagnosis below is the fix.

Now the repos. This is the step older notes skipped: 4-15 ended with a commit, not a push, and the AI feature from Unit 5 was merged on a branch on your laptop. Both repos have to be on GitHub before the server can have them. On the laptop:

SH
# laptopcd ~/projects/hall-apigit statusgit check-ignore .envgh repo create hall-api --public --source=. --pushcd ~/projects/hall-clientgit statusgh repo create hall-client --public --source=. --push

git status should say the working tree is clean on main; commit anything that is not. git check-ignore .env prints .env, which proves the secrets file will not travel. gh repo create with --source=. --push is the command from 2-5, and it prints the new repository's URL. Public is fine here; the hall has no secrets in code, which is exactly what the .gitignore line is for. If you prefer private repositories, the server needs its own way to authenticate, either gh auth login on the droplet or a deploy key from the GitHub docs, and the clone lines below use the SSH form instead.

Back on the server, as deploy:

SH
# server B, the deploy sessioncd ~git clone https://github.com/YOUR_GITHUB_USER/hall-api.gitgit clone https://github.com/YOUR_GITHUB_USER/hall-client.gitcd ~/hall-apinpm installnode server.js
TEXT
/home/deploy/hall-api/server.js:7  if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required');                               ^ Error: JWT_SECRET is required

That is the right failure. The guard you wrote in 4-12 refuses to listen without a secret, and this machine has no .env. Try node --env-file=.env server.js and Node tells you the file does not exist. Do not create it today. The database it would point at does not exist on this machine either, and 6-4 builds both the file and the database in one sitting. Note the error text in your docs and move on.

A small model you can run here. It does not open a socket or edit a file. It models two rules from this session: how sshd picks a value when a drop-in and the main file disagree, and the order gate that decides when you may close the old door. It proves that you can state those rules. It does not prove your droplet is hardened; sshd -T and the second terminal prove that.

JScheckpoint-6-1.js
function check(condition, message) {  if (!condition) throw new Error(message);  console.log('PASS:', message);} function effective(files) {  const seen = {};  files.forEach(function (file) {    Object.keys(file.values).forEach(function (key) {      if (!(key in seen)) seen[key] = file.values[key];    });  });  return seen;} const dropIn = { name: '50-cloud-init.conf', values: { PasswordAuthentication: 'yes' } };const main = { name: 'sshd_config', values: { PasswordAuthentication: 'no', PermitRootLogin: 'no' } }; const withDropIn = effective([dropIn, main]);check(withDropIn.PasswordAuthentication === 'yes', 'an included drop-in that comes first wins over the main file'); const withoutDropIn = effective([main]);check(withoutDropIn.PasswordAuthentication === 'no' && withoutDropIn.PermitRootLogin === 'no', 'remove the drop-in and the main file is the effective config'); function mayDisableOldAccess(state) {  return state.newLoginVerified && state.consoleAvailable && state.firewallAllowsSSH;}check(!mayDisableOldAccess({ newLoginVerified: false, consoleAvailable: true, firewallAllowsSSH: true }), 'Preserve recovery before proof');check(mayDisableOldAccess({ newLoginVerified: true, consoleAvailable: true, firewallAllowsSSH: true }), 'All access prerequisites verified'); function enableFirewall(rules) {  if (rules.indexOf('OpenSSH') === -1) throw new Error('the session you are typing in would be cut');  return 'active';}let cut = false;try { enableFirewall(['80/tcp', '443/tcp']); } catch (error) { cut = true; }check(cut === true, 'enabling with no OpenSSH rule cuts your own session');check(enableFirewall(['OpenSSH', '80/tcp', '443/tcp']) === 'active', 'allow OpenSSH first, then enable'); console.log('ok: checkpoint-6-1');

Where it breaks#

Your turn#

The quest#

Evidence for the hardened droplet: the checkpoint's order rule that recovery is preserved before proof, the boundary assertion that every access prerequisite was verified, and the manual handoff of docs/server.md with sshd -T and ufw status pasted in.
Fig 3 · The model proves the order rule; sshd -T, ufw status, and the second terminal prove your droplet.

Write docs/server.md in ~/projects/hall-api on your laptop, commit it, and push. It is the access plan another developer, or you in three months, would need to reach and recover this machine. No secrets, no private keys, no passwords. One page.

  • Provider, region, plan, and the monthly price you read on the create page, with the date you read it.
  • The public IP, the hostname, and the user you log in as. The exact ssh command.
  • Where the recovery console is, in words, and what you would do there if the key stopped working.
  • The two lines sshd -T printed for permitrootlogin and passwordauthentication.
  • The ufw status verbose table, pasted.
  • node --version on the server and on the laptop, side by side.
  • The two clone URLs and the error node server.js printed in hall-api.
  • One sentence on the order rule: which terminal was open while which door was closed.

Evidence: the committed docs/server.md, a screenshot named 6-1-sshd-T.png of the two sshd -T lines, and a screenshot named 6-1-ufw.png of the firewall table.

Side quests#

The named account and the sudo group are authentication and authorization in their smallest form, and a drop-in that silently re-enables passwords is the textbook security misconfiguration, so the two library sheets for this session read like a second look at your own sshd -T output.

Side quests from this session

  • Security Basics: AuthN and AuthZThe deploy account with sudo and the ed25519 key in authorized_keys are authentication and authorization in their smallest working form.
    System Design
  • A02:2025 Security MisconfigurationA cloud-init drop-in that silently keeps password login on is a security misconfiguration you only catch by reading sshd -T instead of the file.
    Security

Before next session#

Leave the droplet running; it is the one machine on the path that stays up between sessions from now on. Nothing is listening on it yet except sshd, and that is correct. Close every SSH session with exit when you are done. On the laptop nothing is running. State at close:

TEXT
laptop      ~/projects/hall-api and ~/projects/hall-client · both pushed to GitHub · nothing runningserver A    ssh deploy@YOUR_DROPLET_IP · /home/deploy/hall-api and hall-client cloned · only sshd listening · closed with exitserver B    closed with exit · not needed until 6-2browser     DigitalOcean console · droplet running · nothing else open

Next session installs Nginx on this server, puts a page on port 80 that a browser anywhere can open at http://YOUR_DROPLET_IP, and then stands Nginx in front of a Node process on a loopback port, which is the shape the hall will use. Read the Nginx beginner's guide in next session's docs list once before you arrive, just the part about server and location blocks. Bring the IP and the deploy login; the session starts with ssh deploy@YOUR_DROPLET_IP.

See also