HQ Bootstrap Blueprint

Hostinger KVM 4 → Secured, Multi-User Development Server

Version: 1.1 · Audiences: Ro (operator) and Claude Code (executing agent on the Mac Mini) This document lives at: https://htmlbook.io/d/ZNlsS9yp (public link — fetchable by Claude Code)


Part 0 — Read Me First: Context and Execution Protocol

This entire part is preamble. Claude Code: read all of Part 0 before executing anything. Nothing in Part 0 is a step.

0.1 What this document is

This is the complete build plan for a new Hostinger KVM 4 VPS, hostname hq, from the moment of purchase to a verified working state. It is written to be executed jointly: a Claude Code instance running locally on Ro's Mac Mini performs the automatable work over SSH, while Ro performs the steps that require a browser, a password, a physical device, or a judgment call. Every step is labeled with who does it.

When this document ends, the following must all be true and proven (Phase 8 is the proof):

  • Ro can SSH into hq from the Mac Mini and MacBook Air over Tailscale, with keys only. The box is unreachable from the public internet.
  • Claude Code launches on hq under Ro's account.
  • The project tree exists at /srv/projects with ro/ (private) and raina/ (shared parent-daughter access).
  • Raina can log in from her laptop, reach only her project tree, and launch Claude Code under the shared account.
  • The wiki exists at /home/ro/wiki, inaccessible to anyone but Ro, syncing bidirectionally to both Macs via Syncthing so Obsidian edits it locally.
  • Cyberduck on both Macs browses the server over SFTP.
  • The historical Claude Code session corpus (JSONL) from both Macs is transferred into the wiki's ingest folder.
  • The legacy app is pushed to GitHub and cloned onto hq as a read-only reference for the upcoming rebuild.

Explicitly out of scope: Paperclip and Hermes installation (separate future project — they will land on this box later as systemd services), the wiki's internal knowledge architecture (Ro's redesign), the old-app feature-extraction pass (future work session), and the Granola transcript dump (deferred; only its landing folder is created here).

0.2 The environment: who and what

Name What it is Role in this build
Ro Operator. Solo founder running five AI services businesses; experienced, prefers understanding each step over blind execution. Performs [YOU] steps, approves at every WAIT, reviews all output.
Raina Ro's 14-year-old daughter, learning AI-assisted coding. Two-week trial period. Performs [RAINA] steps (Phase 6.4), written for a beginner.
Mac Mini ("theone") Ro's primary desktop. Claude Code (executing agent) runs here. Holds the legacy app codebase and Claude Code session history. CC's home base; source machine for harvest and old-app push.
MacBook Air Ro's laptop. Second client; gets SSH config, Syncthing, Cyberduck.
Raina's laptop MacBook. Must join the tailnet during Phase 6. Thin client only — nothing lives on it.
hq The new Hostinger KVM 4 (4 vCPU, 16 GB RAM, NVMe, Ubuntu 24.04 LTS). The subject of this document. Becomes the permanent center of gravity: all projects, the wiki, and (later) the agent system.
Tailnet Ro's existing Tailscale network. The only path to hq. MagicDNS resolves hq by name.

History that matters: a previous KVM 4 also named hq existed and was cancelled/refunded. Its stale identity must be purged from Tailscale and from both Macs' known_hosts before the new box joins (Step 1.1), or SSH will throw host-key warnings and MagicDNS may misroute.

0.3 Decisions already made — do not relitigate during execution

These were settled in planning. Claude Code: execute them as written; if reality contradicts one, stop and raise it at a WAIT rather than improvising.

  1. SSH is firewalled to the tailnet, not bound to the Tailscale IP. Same security outcome (zero public exposure), but binding sshd to a Tailscale address fails at boot if tailscaled starts after sshd. UFW rules have no boot-order dependency. This is deliberate.
  2. Claude Code installs via the native installer (curl -fsSL https://claude.ai/install.sh | bash) — Anthropic's recommended method: no Node dependency, background auto-updates, and it sidesteps the npm global-prefix permission failures Ro has fought before. Node.js is still installed, but for project work only.
  3. One project root, two subfolders: /srv/projects/ro (mode 700, Ro only) and /srv/projects/raina (setgid + default ACL, full read-write for both via the devkids group). Symlinks make both feel local from each home directory. The folder is named raina, not shared — her name, her ownership.
  4. The wiki lives at /home/ro/wiki behind /home/ro being mode 750 with Raina outside the ro group. Nobody but Ro (and, later, agents running as Ro) can reach it.
  5. Syncthing, not Obsidian Sync, syncs the wiki — because agents on a headless server must read/write vault files at the filesystem level, and Obsidian Sync only operates inside the Obsidian app. Topology is hub-and-spoke: hq is the hub; Mini and Air each pair with hq only.
  6. Shared Claude Code auth for the trial: Raina's Linux account gets its own ~/.claude (separate history, settings, sessions) but its one-time browser login is completed with Ro's Anthropic credentials. Rate limits are shared; nothing else is. If the trial converts, she runs /logout and signs into her own account — no other change needed.
  7. Recovery is two-layer: Hostinger snapshots/backups restore the box; GitHub remotes restore the work. Standing law: every repo has a remote. The Hostinger browser console is the break-glass path if SSH access is ever misconfigured.
  8. fail2ban and similar public-facing defenses are deliberately omitted. With SSH unreachable from the public internet, they're dead weight. Keep the box minimal.

0.4 Execution protocol: cmux, the two panes, and who types what

The workspace. Ro works in cmux on the Mac Mini: one workspace for this project, split into two side-by-side panes.

  • Left pane — the agent. Runs claude (Claude Code, locally on the Mini). This is where Ro converses with CC and where CC's activity streams.
  • Right pane — the window into hq. An ordinary shell that Ro keeps SSH'd into the VPS. Two jobs: (1) Ro's live observation post — run htop, tail logs, poke around; (2) the interactive console where Ro personally executes the small number of steps that demand a human at a prompt (setting passwords, answering installer menus). The doc marks these clearly.

How CC executes. Claude Code runs every [CC] command from its own shell on the Mini, wrapped over SSH — ssh root@PUBLIC_IP '…' in Phase 1, ssh hq '…' from Phase 2 onward. Protocol for every command:

  1. Announce — print the exact command and one line on what it does.
  2. Execute — run it; capture stdout/stderr and exit code.
  3. Report — show the result. On failure: stop, diagnose, present findings to Ro. Never improvise around a failure silently, and never retry destructive commands blind.

Optional observer mode (available from Step 4.1 onward, once tmux exists on hq): for phases Ro wants to watch keystroke-by-keystroke, CC starts a remote tmux session on hq (tmux new -d -s bootstrap), Ro attaches to it in the right pane (ssh hq -t tmux attach -t bootstrap), and CC executes by sending commands into that session (ssh hq "tmux send-keys -t bootstrap 'COMMAND' Enter") and reading results back (ssh hq "tmux capture-pane -p -t bootstrap"). Everything CC does then renders live in Ro's pane. This is optional theater — the announce/execute/report protocol above is the contract either way.

Hard rules for Claude Code:

  • Execute phases strictly in order. No parallelizing across phases, no skipping ahead.
  • ⏸ WAIT markers are absolute stops: present status, then wait for Ro's explicit go-ahead.
  • Never close or break an SSH access path until the replacement path is verified working. This rule has no exceptions and matters most in Phases 1–2.
  • One command block = one action. Never concatenate blocks. (Command blocks in this doc contain only pasteable text — no commentary mixed in.)
  • Every step ends by checking its Expect line. An unmet Expect is a failure — stop and diagnose.
  • Substitute placeholders before executing: PUBLIC_IP (from Step 1.2), YOUR_GITHUB_EMAIL, TAILNET_NAME, PASTE_HER_KEY_HERE (Phase 6.4).

0.5 How to read the steps

Every step follows the same skeleton — Who · What · Why · Do · Expect — so both audiences can execute without interpretation. [YOU] = Ro, in the browser or the right pane. [CC] = Claude Code over SSH. [RAINA] = Raina on her laptop, with Dad nearby. Phases each open with a short "what this phase accomplishes" so you always know why you're doing what you're doing.


Phase 1 — Purchase, First Contact, Tailnet Join

What this phase accomplishes: a clean Ubuntu box exists, the stale hq identity is purged, root access works temporarily, Ro's user exists with key-based SSH from both Macs, and the box is on the tailnet answering to the name hq. At the end of this phase, the public-IP root login — the bootstrap crutch — is ready to be retired.

1.1 [YOU] Purge the ghost of the old hq

What/Why: The cancelled KVM 4 left two kinds of residue: a machine entry in Tailscale (which would collide with the new box claiming the same name) and cached SSH host keys on your Macs (which would trigger scary man-in-the-middle warnings when the new box presents a different key). Clear both now, before anything else.

Do:

  1. Open https://login.tailscale.com/admin/machines → find the old hq → ⋯ menu → Remove. While you're there, confirm MagicDNS is enabled (DNS tab) — the whole doc addresses the box as hq by name.
  2. On the Mini, run:
ssh-keygen -R hq
  1. Then:
ssh-keygen -R hq.TAILNET_NAME.ts.net
  1. Repeat both commands on the Air.

Expect: Each command either reports a removed entry or "not found" — both outcomes are fine. The Tailscale admin console shows no machine named hq.

1.2 [YOU] Purchase and provision the VPS

What/Why: The box itself. Plain Ubuntu 24.04 LTS — not a panel/template image, which preinstalls software we'd only have to fight.

Do:

  1. Hostinger → purchase KVM 4.
  2. OS: Ubuntu 24.04 LTS, plain.
  3. Datacenter: nearest US location (West Coast if offered — you're in the Bay Area; latency on an SSH-everything workflow is quality of life).
  4. In hPanel, set a strong root password (password manager) and record the public IP — this is PUBLIC_IP everywhere in this doc.
  5. Find hPanel's browser console (emergency/VNC terminal) and open it once to confirm it works. This is the break-glass recovery path if SSH is ever misconfigured. Verify the fire escape before there's a fire.

Expect: hPanel shows the VPS running; the browser console reaches a login prompt; you have PUBLIC_IP and the root password recorded.

1.3 [YOU] Build the cmux workspace and make first contact

What/Why: Set up the two-pane cockpit from 0.4, and log into the box as root — the only time password SSH will ever be used against this machine.

Do:

  1. Open cmux → new workspace (name it hq-bootstrap) → split into two side-by-side panes.
  2. Right pane — first login:
ssh root@PUBLIC_IP
  1. Accept the new host-key fingerprint (yes), enter the root password.
  2. Left pane — start the agent:
claude
  1. Paste CC's kickoff prompt (fill in the IP):

You are executing the HQ Bootstrap Blueprint — fetch it from https://htmlbook.io/d/ZNlsS9yp and read Part 0 in full before acting. The new VPS is at PUBLIC_IP; root password auth works for now. I am observing in a separate cmux pane and will handle all [YOU] steps when you cue me. Execute [CC] steps yourself over SSH per the protocol in section 0.4: announce each command, run it, report the result, stop at every WAIT. Begin at Step 1.4.

Expect: Right pane shows a root@…:~# prompt on the VPS. Left pane: CC confirms it has read Part 0 and is ready.

1.4 [CC] Update the system and set identity

What/Why: Fresh images ship weeks-stale packages, including security patches. Update first so everything after installs against a current base. Then set the hostname (hq — Tailscale will inherit it) and timezone (log timestamps, cron sanity).

Do (each block separately, over ssh root@PUBLIC_IP):

apt update && apt upgrade -y

⏸ WAIT — report to Ro whether a new kernel was installed (look for linux-image-* in the upgrade list). If yes: run reboot, tell Ro to re-establish the right-pane session after ~60 seconds, and confirm the box is back before continuing.

hostnamectl set-hostname hq
timedatectl set-timezone America/Los_Angeles

Expect: hostnamectl shows Static hostname: hq; timedatectl shows America/Los_Angeles; apt upgrade exits clean.

1.5 [YOU] + [CC] Create Ro's user account

What/Why: Daily work never happens as root — root is unlimited blast radius with no audit trail. The ro account with sudo is the working identity; root login gets disabled in Phase 2. adduser is interactive (it asks for a password), so this executes in Ro's right pane while CC directs.

Do:

  1. [YOU], in the right pane:
adduser ro
  1. Enter a strong password when prompted (this becomes your sudo password — SSH itself will be key-only; store it in the password manager). Press Enter through the name/room/phone fields.
  2. [CC] grants sudo:
usermod -aG sudo ro

Expect: id ro shows sudo among the groups.

1.6 [YOU] Install SSH keys from both Macs

What/Why: Key-based auth is the prerequisite for disabling passwords in Phase 2. Both Macs get independent keys — if one machine is lost, you revoke one line, not your whole access.

Do — on the Mini:

  1. Check for an existing key:
ls ~/.ssh/id_ed25519.pub
  1. If missing, create one (accept the default location; passphrase optional):
ssh-keygen -t ed25519 -C "ro@mini"
  1. Ship it to the box (prompts for the ro password from 1.5):
ssh-copy-id ro@PUBLIC_IP
  1. Verify:
ssh ro@PUBLIC_IP 'echo KEY-OK && whoami'

Do — on the Air: repeat steps 1–4 with comment ro@air.

Expect: Both Macs print KEY-OK / ro without a password prompt. Do not proceed until both do — Phase 2 slams the password door.

1.7 [CC] + [YOU] Join the tailnet

What/Why: Tailscale is the box's only doorway after Phase 2. Installing it before hardening means we never have a moment where the box is hard to reach.

Do:

  1. [CC] installs (as root):
curl -fsSL https://tailscale.com/install.sh | sh
  1. [CC] brings it up:
tailscale up

⏸ WAIT — this prints an authentication URL. [YOU]: open it in your browser, approve the machine into the tailnet, then confirm in the admin console that it appears as hq (it inherits the hostname from 1.4). If your tailnet uses key expiry, consider disabling it for hq (⋯ → Disable key expiry) — a server shouldn't silently fall off the network in 90 days.

  1. [CC] verifies:
tailscale ip -4
  1. [CC] from the Mini side:
ping -c 2 hq

Expect: A 100.x.y.z address (record it for the inventory); ping resolves the name hq and gets replies. MagicDNS is doing its job.

1.8 [YOU] SSH config on both Macs — the name hq becomes the whole address

What/Why: One config stanza per Mac and every tool that speaks SSH — terminal, Cyberduck, rsync, future VS Code Remote — addresses the box as just hq, as user ro, forever.

Do: Append to ~/.ssh/config on the Mini and the Air:

Host hq
    HostName hq
    User ro

Then test from each:

ssh hq 'echo TAILNET-OK'

Expect: TAILNET-OK from both Macs, no password, no prompts. From here on, every [CC] command targets ssh hq as user ro, using sudo where shown. Ro: re-log the right pane as ssh hq too.


Phase 2 — Security Hardening

What this phase accomplishes: the box becomes invisible to the public internet and hard even from inside. Passwords and root login die; the firewall admits only tailnet traffic; security patches apply themselves; swap prevents memory spikes from killing processes; the first snapshot is banked. Sequencing rule in force: 1.6 and 1.8 proved key access before this phase removes the alternatives. Keep the current right-pane session open through all of 2.1–2.2 — test new doors from new windows, never by closing the one you're standing in.

2.1 [CC] Harden sshd — keys only, no root

What/Why: Three lines end password guessing and root login as attack surfaces. A drop-in file under sshd_config.d/ survives package upgrades better than editing the main config. We validate syntax before restarting — a typo'd sshd config that gets restarted is how people lock themselves out.

Do:

  1. Write the drop-in:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf > /dev/null << 'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
EOF
  1. Validate syntax (silence = valid):
sudo sshd -t
  1. Restart:
sudo systemctl restart ssh

⏸ WAIT — verification is three tests, and the session you're in stays open the whole time. [YOU], from a new terminal on the Mini:

Test A — keys still work:

ssh hq 'echo STILL-IN'

Test B — passwords are dead:

ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ro@hq

Test C — root is dead:

ssh root@hq

Expect: A prints STILL-IN; B and C fail with Permission denied. All three must behave exactly so before proceeding.

2.2 [CC] Firewall — the box exists only on the tailnet

What/Why: Four UFW rules implement decision 0.3-1: deny everything inbound by default, allow everything arriving on the tailscale0 interface. Result: to the internet the box is a void; to your tailnet it's fully open (SSH, Syncthing, future services — no per-port bookkeeping as the box grows). Your current session rides tailscale0, so enabling the firewall won't cut it — but the WAIT verifies anyway, because "should survive" isn't "did survive."

Do (separate blocks, in order):

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw enable

(Answer y to the warning about disrupting connections — tailnet traffic is exempted by rule 3.)

⏸ WAIT — two verifications:

  1. [CC]: ssh hq 'echo FIREWALL-SURVIVED' from the Mini → must print.
  2. [YOU]: from a device off the tailnet (phone hotspot, Tailscale toggled off):
ssh ro@PUBLIC_IP

→ must hang until timeout (not "permission denied" — nothing, as if no machine exists at that address). That silence is the finish line: the public internet cannot see this box.

  1. [CC] records for the inventory:
sudo ufw status verbose

Expect: Deny (incoming), Allow (outgoing), Anywhere on tailscale0 ALLOW IN.

2.3 [CC] + [YOU] Automatic security updates

What/Why: A box you don't log into daily must patch itself. unattended-upgrades applies Ubuntu's security channel automatically — security fixes only, not feature upgrades, so it won't surprise you with breaking changes.

Do:

  1. [CC]:
sudo apt install -y unattended-upgrades
  1. [YOU], in the right pane (interactive menu):
sudo dpkg-reconfigure -plow unattended-upgrades

→ select Yes.

Expect: /etc/apt/apt.conf.d/20auto-upgrades contains both Update-Package-Lists "1" and Unattended-Upgrade "1".

2.4 [CC] Swap — the graceful-degradation cushion

What/Why: 16 GB RAM is comfortable for the planned load, but a runaway research job or memory-leaking agent can spike past any ceiling. Without swap, Linux's OOM killer terminates processes — possibly business-critical ones — with no warning. 8 GB of swap converts "sudden death" into "temporary slowdown," which buys time to notice and intervene. swappiness=10 tells the kernel to treat swap as emergency-only, not a RAM extension.

Do:

sudo fallocate -l 8G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf && sudo sysctl -p /etc/sysctl.d/99-swappiness.conf

Expect: free -h shows ~16 GB RAM and 8 GB swap; swapon --show lists /swapfile. (The fstab line makes it survive reboots.)

2.5 [YOU] Bank the baseline snapshot

What/Why: Layer one of the recovery story. A snapshot taken now — hardened but empty — is the clean restore point if anything later goes sideways. Weekly backups cover ongoing drift.

Do: In hPanel: enable weekly backups; take a manual snapshot named something like post-hardening-baseline.

Expect: Snapshot listed in hPanel. Recovery ladder now fully rigged: browser console → snapshot → GitHub remotes.


Phase 3 — Users, Groups, and the Project Tree

What this phase accomplishes: the permission architecture from 0.3-3 and 0.3-4 becomes real. Raina gets an account; the devkids group binds you two; /srv/projects/{ro,raina} exists with the exact access semantics designed; symlinks make it all feel local; the wiki skeleton exists behind a hard boundary; and — because permissions are where silent mistakes live — every boundary is adversarially tested before the phase closes.

3.1 [YOU] + [CC] Raina's account and the shared group

What/Why: Linux permissions attach to users — selective access requires Raina to be a distinct user, even though her projects won't live in her home. Her home directory is just her front door. The devkids group is the mechanism that grants both of you rights over her project tree.

Do:

  1. [YOU], right pane (interactive — set her initial password, Enter through the rest):
sudo adduser raina
  1. [CC]:
sudo groupadd devkids
sudo usermod -aG devkids ro && sudo usermod -aG devkids raina

⏸ WAIT — group membership only applies to new logins. [YOU]: log the right pane out and back in (exit, then ssh hq), run groups, confirm devkids appears. [CC]: verify your own next SSH command sees it too (ssh hq groups).

Expect: Both users list devkids.

3.2 [CC] Build the project tree

What/Why: One root, two territories. Three permission mechanisms do the work, and it's worth knowing which does what: mode 700 on ro/ makes it Ro-only; the setgid bit (the 2 in 2770) on raina/ makes everything created inside inherit the devkids group automatically; the default ACL guarantees group write on new files regardless of either user's umask — without it, files Ro creates could be read-only to Raina, producing mysterious "permission denied on my own project" moments mid-lesson.

Do:

sudo mkdir -p /srv/projects/ro /srv/projects/raina
sudo chown ro:ro /srv/projects/ro && sudo chmod 700 /srv/projects/ro
sudo chown raina:devkids /srv/projects/raina && sudo chmod 2770 /srv/projects/raina
sudo setfacl -d -m g::rwx /srv/projects/raina
sudo chown root:root /srv/projects && sudo chmod 755 /srv/projects

Expect: ls -la /srv/projects shows drwx------ ro ro on ro and drwxrws--- raina devkids on raina (the s is the setgid bit). getfacl /srv/projects/raina shows default:group::rwx.

What/Why: cd ~/projects should just work for both of you; nobody types /srv/... day to day. Ro also gets ~/raina — one hop into her territory for visibility and pairing.

Do:

ln -s /srv/projects/ro /home/ro/projects && ln -s /srv/projects/raina /home/ro/raina
sudo -u raina ln -s /srv/projects/raina /home/raina/projects

Expect: ls -la ~ for each user shows the links; cd ~/projects && pwd -P resolves to the right /srv path for each.

3.4 [CC] The wiki skeleton — behind a hard boundary

What/Why: The wiki is Ro's second brain and, later, the agents' workspace — Raina-proof by design. The mechanism is simple: /home/ro at mode 750 is impassable to anyone outside the ro group, and Raina isn't in it. Everything inside inherits the boundary. Only the skeleton the pipelines need is created here — the internal knowledge architecture is Ro's redesign, deliberately not prescribed.

Do:

mkdir -p /home/ro/wiki/ingest/claude-sessions /home/ro/wiki/ingest/granola /home/ro/wiki/apps/{operative,brass,skiff,tabulo,singular}
chmod 750 /home/ro && chmod 700 /home/ro/wiki

Expect: Tree exists; permissions read 750 on the home, 700 on the wiki.

3.5 [CC] Adversarial permission verification — do not skip

What/Why: Permissions fail silently; a typo in 3.2 wouldn't announce itself until Raina hits a wall mid-lesson or — worse — doesn't hit a wall she should. Test every boundary from the untrusted side now.

Do (each with its required outcome):

  1. Raina can enter and list her tree — must succeed:
sudo -u raina ls /srv/projects/raina
  1. Raina cannot see Ro's tree — must fail (Permission denied):
sudo -u raina ls /srv/projects/ro
  1. Raina cannot enter Ro's home (wiki boundary) — must fail:
sudo -u raina ls /home/ro
  1. Group inheritance works — Raina creates, Ro edits, then cleanup:
sudo -u raina touch /srv/projects/raina/boundary-test
echo ro-was-here >> /srv/projects/raina/boundary-test && rm /srv/projects/raina/boundary-test

⏸ WAIT — report all four results to Ro. Any deviation from must-succeed/must-fail = stop, fix, retest. The phase is not done until all four behave exactly as specified.


Phase 4 — Core Tooling

What this phase accomplishes: everything a working development server needs — base packages, Node with the npm-permission fix baked in, Git wired to GitHub, Claude Code for both users under the shared-auth arrangement, the tmux pairing system with one-word helper commands, and a starter CLAUDE.md that shapes how Claude Code behaves in Raina's territory.

4.1 [CC] Base packages

What/Why: The standard toolkit. acl backs the setfacl work from Phase 3; ripgrep and jq are heavily used by agent workflows; tmux powers both session persistence and the pairing system; build-essential covers native compilation for npm/pip packages.

Do:

sudo apt install -y git curl build-essential tmux htop ripgrep jq unzip zip python3-venv python3-pip acl

Expect: Clean install; tmux -V reports 3.4 (matters for 4.5 — cross-user access needs ≥3.3).

(Observer mode from 0.4 is now available for the rest of the build if Ro wants it.)

4.2 [CC] Node.js 22 LTS + the permanent npm-permissions fix

What/Why: Node system-wide via NodeSource (Ubuntu's own Node packages lag badly), for project work — Claude Code won't need it (native installer, 4.4). Then each user's npm global prefix moves to a home-owned directory: this is the durable fix for the class of npm prefix/permission failure that burned the last VPS build. Global installs land where the user has rights; sudo npm install -g becomes a thing that never needs to exist here.

Do:

  1. Node itself:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs
  1. Ro's prefix:
mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global && echo 'export PATH=$HOME/.npm-global/bin:$PATH' >> ~/.bashrc
  1. Raina's prefix:
sudo -u raina bash -c 'mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global && echo "export PATH=\$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc'

Expect: node --version → v22.x; npm config get prefix → /home/ro/.npm-global (and the raina equivalent under her account).

4.3 [CC] + [YOU] Git identity and GitHub trust

What/Why: The VPS needs its own GitHub deploy identity — a fresh key generated on hq, registered to the roarora account. Never copy private keys between machines; each machine gets its own, so each is independently revocable.

Do:

  1. [CC] — identity (Ro supplies the email):
git config --global user.name "Ro Arora" && git config --global user.email "YOUR_GITHUB_EMAIL"
  1. [CC] — keypair (no passphrase; this key's security = the box's security, which Phase 2 established):
ssh-keygen -t ed25519 -C "ro@hq-vps" -f ~/.ssh/id_ed25519 -N ""
  1. [CC] — print the public half:
cat ~/.ssh/id_ed25519.pub

⏸ WAIT — [YOU]: copy the printed line → https://github.com/settings/keys → New SSH key → title hq-vps → save. Tell CC when done.

  1. [CC] — verify:
ssh -T git@github.com

Expect: Hi roarora! You've successfully authenticated... (the "does not provide shell access" note is normal).

4.4 [CC] + [YOU] Claude Code — both users, native installer, shared auth

What/Why: Decision 0.3-2 (native installer) and 0.3-6 (shared auth for the trial). Two installs, two separate ~/.claude worlds, one Anthropic account. Headless-box detail: the login flow can't pop a browser on hq, so it prints a URL — Ro completes it in the Mini's browser and pastes the code back. Same dance for both users; the only difference is whose Anthropic credentials complete it (Ro's, both times).

Do — Ro's install:

  1. [CC]:
curl -fsSL https://claude.ai/install.sh | bash
  1. [CC] — fresh shell, then health check:
claude --version && claude doctor

⏸ WAIT — Ro's auth. [YOU], right pane: cd ~/projects && claude → at the login prompt, copy the printed URL → open in the Mini's browser → sign into your Anthropic account → paste the code back into the terminal. Confirm Claude Code reaches a working prompt, then exit.

Do — Raina's install:

  1. [CC]:
sudo -iu raina bash -c 'curl -fsSL https://claude.ai/install.sh | bash'

⏸ WAIT — Raina's auth. [YOU]: sudo -iu raina in the right pane → cd ~/projects && claude → same copy-URL flow → complete the browser login with your credentials again → paste the code → exit her shell. Her history/settings/sessions stay hers; billing and rate limits are shared. (Trial converts later: she runs /logout, signs into her own account, done.)

Expect: claude doctor clean for ro; claude reaches a working prompt for both users; ls /home/raina/.claude exists and is separate from /home/ro/.claude.

4.5 [CC] The pairing system — shared tmux, one word each

What/Why: The live-teaching mechanism: both of you attached to one terminal, both seeing every keystroke in real time. Cross-user tmux needs three things — a socket both can reach (a devkids-owned directory), socket file permissions, and tmux's server-access grant (v3.3+). All three get buried inside two helper commands so the daily reality is: she types pair, you type pair-join.

Do:

  1. Socket home:
sudo mkdir -p /srv/pair && sudo chown raina:devkids /srv/pair && sudo chmod 2770 /srv/pair
  1. Her command:
sudo tee /usr/local/bin/pair > /dev/null << 'EOF'
#!/bin/bash
# Start (or rejoin) the shared pairing session. Run as raina.
S=/srv/pair/socket
tmux -S "$S" has-session -t lesson 2>/dev/null && exec tmux -S "$S" attach -t lesson
tmux -S "$S" new -d -s lesson
tmux -S "$S" server-access -a ro
chmod 660 "$S"
exec tmux -S "$S" attach -t lesson
EOF
sudo chmod +x /usr/local/bin/pair
  1. Your command:
sudo tee /usr/local/bin/pair-join > /dev/null << 'EOF'
#!/bin/bash
# Join Raina's pairing session. Run as ro.
exec tmux -S /srv/pair/socket attach -t lesson
EOF
sudo chmod +x /usr/local/bin/pair-join
  1. Test it now, before she's watching:
sudo -u raina /usr/local/bin/pair

(from a second session as ro: pair-join; type in each side, watch it mirror; detach both with Ctrl-b d.)

Expect: Both sessions show the identical live terminal. Known quirk, not a bug: the shared screen sizes to the smaller attached terminal — your ultrawide will show a boxed region while paired.

4.6 [CC] Starter CLAUDE.md for Raina's territory

What/Why: Claude Code reads CLAUDE.md from the working tree automatically. Seeding one at the root of her territory shapes every session she runs into teaching mode — explanations in plain language, small verifiable steps, hard scope limit — without her configuring anything or even knowing the file exists yet.

Do:

tee /srv/projects/raina/CLAUDE.md > /dev/null << 'EOF'
# Raina's Projects
- Each project lives in its own folder here.
- Explain what you're doing in plain language as you work; Raina is learning.
- Prefer small, working steps over big changes. After each step, show how to run or see the result.
- Never modify anything outside /srv/projects/raina.
EOF

Expect: File exists, group devkids (setgid inheritance from 3.2 — incidentally your first real-world confirmation it works).


Phase 5 — The Wiki Pipeline: Syncthing over the Tailnet

What this phase accomplishes: the two-writer sync design from planning becomes real. The wiki's canonical home is hq; agents (later) write it there; you edit it in Obsidian on either Mac; Syncthing reconciles all three within seconds, entirely over your tailnet. Topology: hub-and-spoke — Mini and Air each pair with hq only; the hub reconciles, so the Macs never need to see each other.

5.1 [CC] Syncthing on hq — official repo, running as a service

What/Why: Ubuntu's own syncthing package lags significantly; the project's apt repo tracks current. The syncthing@ro systemd unit runs it as your user — necessary, since it must read/write files in your home — and starts it at boot.

Do:

sudo mkdir -p /etc/apt/keyrings && sudo curl -L -o /etc/apt/keyrings/syncthing-archive-keyring.gpg https://syncthing.net/release-key.gpg
echo "deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable" | sudo tee /etc/apt/sources.list.d/syncthing.list
sudo apt update && sudo apt install -y syncthing
sudo systemctl enable --now syncthing@ro

Expect: systemctl status syncthing@ro → active (running).

5.2 [YOU] Syncthing on both Macs

What/Why: The menu-bar app (installed via Homebrew cask) runs Syncthing in the background and starts at login — install once, forget forever.

Do, on Mini and Air:

brew install --cask syncthing

Launch it once from Applications (menu-bar icon appears; enable start-at-login if prompted).

Expect: Menu-bar icon on both Macs; local GUI reachable at http://localhost:8384 on each.

5.3 [YOU] Pair the devices and share the vault

What/Why: Syncthing pairing is mutual introduction by device ID. The hub's GUI listens on localhost only (correct — never expose an admin GUI), so you reach it through an SSH tunnel: the command below makes hq's GUI appear at a local port on your Mini.

Do:

  1. Open the tunnel from the Mini (leave this running while you work):
ssh -L 8385:localhost:8384 hq
  1. Browse to http://localhost:8385 — this is hq's GUI. (First visit: consider setting a GUI password when it nags; the tunnel already gates access, but belt-and-suspenders.)
  2. hq GUI → Actions → Show ID → copy.
  3. Mini GUI (http://localhost:8384) → Add Remote Device → paste ID → name hq → save. Back on hq's GUI, accept the pairing prompt that appears.
  4. Repeat step 4 from the Air.
  5. hq GUI → Add Folder: path /home/ro/wiki, label wiki → Sharing tab → check both Macs → save.
  6. On each Mac, accept the incoming wiki folder → local path ~/wiki → folder type Send & Receive.
  7. Tailnet-only hardening (recommended): on each device's settings — set hq's device address statically to tcp://hq:22000 (instead of dynamic), and in global settings disable Global Discovery and Relaying. Result: sync traffic never touches third-party infrastructure, not even for coordination.

Expect: All three devices show each other "Up to Date" / connected. Folder wiki listed on all three.

5.4 [CC] + [YOU] Ignore patterns

What/Why: Two file classes must not sync: macOS .DS_Store litter, and Obsidian's per-device workspace layout (workspace.json — which windows/panes you had open; syncing it makes your Mini and Air fight over layout). Everything else in .obsidian/ should sync — themes, plugins, settings follow you between Macs. Syncthing doesn't propagate ignore files themselves, so the same .stignore goes on all three devices.

Do:

  1. [CC] on hq:
tee /home/ro/wiki/.stignore > /dev/null << 'EOF'
.DS_Store
.obsidian/workspace.json
.obsidian/workspace-mobile.json
EOF
  1. [YOU]: create the identical .stignore at ~/wiki/.stignore on both Macs (after the folder appears there post-5.3).

Expect: Same three-line file in all three replicas.

5.5 [YOU] Obsidian + the convergence test

What/Why: Prove the full loop in both directions before trusting it with real notes.

Do:

  1. Obsidian on the Mini → Open folder as vault → ~/wiki.
  2. Create a note sync-test-mini.md. Within ~10 seconds, [CC] checks: ssh hq 'ls ~/wiki' → file present.
  3. [CC] writes from the hub: ssh hq 'echo hello-from-hq > ~/wiki/sync-test-hq.md' → appears in Obsidian on both Macs.
  4. Delete both test notes in Obsidian → [CC] confirms deletion propagated to hq.
  5. Record the agent-write convention as the wiki's first real note (conventions.md): agents write into ingest/, apps/*, and designated output folders — never into notes Ro actively edits. Writers separated by folder don't produce conflict files; this one convention is most of your conflict prevention.

Expect: Both directions propagate in seconds; deletions propagate; the convention note itself syncs everywhere — a fitting first artifact.


Phase 6 — Mac-Side Work: Harvest, Cyberduck, Raina's Laptop

What this phase accomplishes: the historical Claude Code corpus is preserved and transferred, both Macs get drag-and-drop file access to the server, and Raina's laptop becomes a working thin client. Ordering matters within this phase: 6.1 before 6.2, always — stop the evaporation before harvesting.

6.1 [YOU] FIRST — stop Claude Code history from evaporating

What/Why: Claude Code prunes old session files on a retention schedule (cleanupPeriodDays). Your session history is the raw material for the "how Ro works" analysis — every day at default retention loses the oldest day. This step must precede the harvest.

Do, on both Macs: edit ~/.claude/settings.json to include (merging with any existing keys — don't clobber the file):

{ "cleanupPeriodDays": 3650 }

Expect: Valid JSON on both machines containing the key. (cat ~/.claude/settings.json | jq . to verify parse.)

6.2 [CC] + [YOU] One-time JSONL harvest

What/Why: Copy the full session corpus from both Macs into the wiki's ingest folder — dumb pipe now, smart processing later by agents on hq. This is one-time by design: future sessions happen on hq, where agents read ~/.claude/projects/ natively with no pipeline at all.

Do:

  1. [CC] from the Mini:
rsync -av ~/.claude/projects/ hq:/home/ro/wiki/ingest/claude-sessions/mini/
  1. [YOU] from the Air (same command, different destination):
rsync -av ~/.claude/projects/ hq:/home/ro/wiki/ingest/claude-sessions/air/
  1. [CC] sanity-checks the counts (run the find on each source Mac and on each destination folder):
find ~/.claude/projects -name '*.jsonl' | wc -l

Expect: File counts match source-to-destination for both machines. Note: this corpus now also syncs to both Macs via the wiki — if it's bulky and you'd rather it live on hq only, add ingest/claude-sessions to .stignore on all replicas before the transfer.

6.3 [YOU] Cyberduck on both Macs

What/Why: Drag-and-drop file movement to/from the server, no terminal. Rides the exact SSH setup you already have — the hq config stanza and your key. (Mountain Duck, the Finder-mount upgrade, stays on the deferred list until Cyberduck's workflow proves limiting.)

Do, on Mini and Air:

  1. Download Cyberduck from cyberduck.io, install.
  2. New Bookmark → protocol SFTP → Server hq, Port 22, Username ro → SSH Private Key: ~/.ssh/id_ed25519.
  3. Connect — you land in /home/ro. Test: drag any file in, confirm it arrives (ls in the right pane), delete it.
  4. Optional second bookmark pointed at /srv/projects for one-click tree access.

Expect: Both Macs browse and transfer without password prompts.

6.4 [RAINA] Raina's laptop onboarding

Written to hand to Raina; Dad handles steps 3, 4, and the Tailscale prerequisite.

Prerequisite [YOU]: her laptop must be on the tailnet — install Tailscale on it, sign in, approve the device in the admin console.

  1. Make your key. Open Terminal, run this, and press Enter at every question:
ssh-keygen -t ed25519 -C "raina@laptop"

(This creates your personal key to the server — like cutting your own house key. The private half never leaves your laptop.)

  1. Show the public half:
cat ~/.ssh/id_ed25519.pub

Copy the whole line it prints and send it to Dad (AirDrop or Messages). This half is safe to share — it's the lock pattern, not the key.

  1. [YOU] — install her key on hq (paste her line in place of the placeholder):
sudo mkdir -p /home/raina/.ssh && echo 'PASTE_HER_KEY_HERE' | sudo tee /home/raina/.ssh/authorized_keys && sudo chown -R raina:raina /home/raina/.ssh && sudo chmod 700 /home/raina/.ssh && sudo chmod 600 /home/raina/.ssh/authorized_keys
  1. [YOU] — add to her laptop's ~/.ssh/config:
Host hq
    HostName hq
    User raina
  1. Log in. In Terminal:
ssh hq

You're now on the server — the same computer Dad works on.

  1. Find your projects and start Claude:
cd ~/projects && claude

Everything you build lives in ~/projects. Claude already knows the ground rules there.

  1. Pair with Dad: you type pair, he types pair-join — you'll both be looking at the same live terminal, either of you can type.

Expect [CC] verifies after: her login works key-only; sudo -u raina claude --version succeeds; the Phase 3.5 boundary tests still hold from her real account via a live login.

(Optional, later, not day one: VS Code with Remote-SSH pointed at hq gives her a full editor on the server. Skip until the terminal feels comfortable. Cyberduck for her laptop: same recipe as 6.3 with username raina — do it whenever file-dragging becomes useful to her.)


Phase 7 — Staging the Old-App Blueprint

What this phase accomplishes: the legacy app gets a permanent GitHub home and a read-only clone on hq, positioned for the future extraction pass. The extraction itself — turning the old code into reviewable feature specs — is deliberately a separate work session.

7.1 [CC] + [YOU] Push the legacy app to GitHub

What/Why: GitHub in the middle rather than any direct Mini→VPS copy: it enforces the every-repo-has-a-remote law, gives the legacy code a permanent backup independent of both machines, and makes the clone trivial.

Do:

  1. [CC] on the Mini, in the old app's directory — assess state:
git status
  1. If it isn't a repo yet:
git init && git add -A && git commit -m "Legacy app snapshot — reference blueprint for rebuild"

⏸ WAIT — [YOU]: create a private repo on github.com (suggested: oldapp-legacy-reference — or tell CC the real name). Confirm to CC.

  1. [CC]:
git remote add origin git@github.com:roarora/oldapp-legacy-reference.git && git push -u origin main

Expect: Repo visible on GitHub, private, full history pushed.

7.2 [CC] Clone to hq as read-only reference

What/Why: The clone lands beside the future rebuild directory, never inside it — zero chance of legacy code bleeding into the new repo or an agent editing the wrong tree. The README makes the convention machine-readable: every future Claude Code session that opens this tree gets told, by the tree itself, what it may and may not do.

Do:

git clone git@github.com:roarora/oldapp-legacy-reference.git /srv/projects/ro/oldapp-reference
tee /srv/projects/ro/oldapp-reference/README-REFERENCE.md > /dev/null << 'EOF'
# READ-ONLY REFERENCE
Legacy codebase preserved as the blueprint for the rebuild.
Claude Code: read freely; NEVER edit, commit, or run destructive commands in this tree.
The rebuild lives in a sibling directory and shares no code with this tree.
Feature-extraction specs are produced FROM this tree before the rebuild begins (separate work session).
EOF

Expect: Clone present; README in place; git -C /srv/projects/ro/oldapp-reference log --oneline | head -3 shows history.


Phase 8 — Verification: Prove It All

What this phase accomplishes: "done" becomes a checklist, not a feeling. [CC] runs every automatable check and reports a pass/fail table; [YOU] performs the three physical-world checks (items 4, 7, 8). Every line must pass. Any failure → back to its phase, fix, re-run the section.

Access & security

  1. ssh hq 'echo ok' succeeds from Mini and Air, key-only.
  2. Password SSH refused (2.1 Test B).
  3. Root SSH refused (2.1 Test C).
  4. [YOU] From off-tailnet: ssh ro@PUBLIC_IP times out — the box is publicly invisible.
  5. sudo ufw status verbose → deny incoming / allow on tailscale0.
  6. free -h → 8G swap; swapon --show lists it.
  7. [YOU] hPanel: weekly backups on; post-hardening-baseline snapshot exists.
  8. [YOU] hPanel browser console opens to a login prompt.

Users & permissions

  1. Raina logs in from her laptop, key-only.
  2. From her real login: full read/write in ~/projects; Permission denied on /srv/projects/ro and /home/ro.
  3. Cross-editing works both directions in her tree (group inheritance live test).
  4. Wiki unreachable from her account.

Tooling

  1. claude doctor clean under ro.
  2. claude reaches a working prompt under raina; /home/raina/.claude distinct from Ro's.
  3. ssh -T git@github.com → authenticates as roarora.
  4. pair / pair-join produce a mirrored live terminal.

Wiki & sync

  1. Note created in Obsidian (Mini) appears on hq and Air within seconds.
  2. File written on hq appears in Obsidian on both Macs.
  3. Deletion propagates all three ways; .stignore identical on all replicas.
  4. Global Discovery and Relaying disabled; hq address static (if 5.3.8 applied).

Pipelines & staging

  1. JSONL corpus in wiki/ingest/claude-sessions/{mini,air}, counts matching sources.
  2. cleanupPeriodDays: 3650 on both Macs.
  3. Cyberduck transfers on Mini and Air.
  4. oldapp-reference cloned with README-REFERENCE and intact history.
  5. wiki/ingest/granola/ exists, empty, awaiting the deferred one-time dump.

Closeout

  1. [CC] writes /home/ro/wiki/hq-inventory.md: Ubuntu version, kernel, tailscale IP and version, node/tmux/syncthing/claude versions, the directory map, UFW rules, swap config, backup schedule, users and groups, and the date. This is the box's birth certificate — future sessions (and the Paperclip/Hermes build) read it instead of re-deriving the machine. Syncthing carries it to your Macs the moment it's written.

Appendix

Recovery ladder: (1) SSH misconfiguration → hPanel browser console, fix from inside. (2) Box-level disaster → restore post-hardening-baseline or latest weekly backup. (3) Nothing unique dies with the box: repos live on GitHub, the wiki lives in three replicas. Standing law: every repo has a remote — CC should refuse to consider any project "set up" until it does.

Deferred, tracked: Granola one-time dump (free-tier local-cache extraction → wiki/ingest/granola/, run once, verify, done) · Granola MCP standing tap (when sales-call volume justifies a paid plan; slots into the same funnel) · Mountain Duck (if Cyberduck chafes) · Raina's own Claude account (post-trial verdict: her /logout, her login, nothing else changes) · VS Code Remote-SSH for Raina (when the terminal is comfortable) · Paperclip + Hermes (separate blueprint; lands as systemd services under ro; starts by reading hq-inventory.md).