Ubuntu 26.04 · 4 vCPU / 8 GB · two gists, two stages, ~15 min.
| Gist | File | Contents |
|---|---|---|
1ef59bb1… |
ubuntu-bootstrap.sh |
Stage 1 — asks questions, patches, fetches stage 2 |
0953c926… |
ubuntu-provision.sh |
Stage 2 — everything else (sshkey embedded inside) |
Both use floating raw URLs, so editing a gist is enough — no SHAs to bump.
- Reinstall Ubuntu 26.04. Keep the root password your provider issues.
- In Moshi, create the
devconnection first. Host = IP, user =dev, Authentication → Generate new Ed25519 key → copy the public key. Save. Don't connect — it can't work yet. - Connect to
root@<ip>with the password. - Run stage 1:
curl -fsSL "https://gist.githubusercontent.com/dragonworx/1ef59bb118bf018d876d4a0cab78e67d/raw/ubuntu-bootstrap.sh?cb=$(date +%s)" | bash - Answer five prompts — public key from step 2 · server name · name · email · sudo password (hidden, twice) — then
y. Nothing has changed until thaty. - Wait ~15 min. Logged to
/root/provision.log. - Open the
devconnection in Moshi and confirm it works.⚠️ Keep the root session open until it does. Password auth is now off — this is the only step with no undo. - Close root off, as dev:
sudo sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config.d/99-provision.conf sudo sshd -t && sudo systemctl reload ssh - GitHub:
cat ~/.ssh/github.pub→ paste at github.com/settings/keys →ssh -T git@github.com - Claude Code: run
claudeonce to authenticate. - Shred the summary:
sudo shred -u /root/provision-summary.txt - Reboot:
sudo reboot— then checkdocker psworks, confirming rootless Docker survived.
- Generate a keypair on the device (SSH app → new connection → generate Ed25519).
- Copy the public key.
- On the server:
sshkey add ipad→ paste.--sftpfor file-transfer-only, no shell. - Test from that device before closing your session.
sshkey list # what's authorised
sshkey remove ipad # refuses if it's your last key
Private keys are never generated on the server and never travel.
- Swap — half of RAM (4 GB),
swappiness=10 devuser — zsh + Pure prompt, key-only SSH, unattended security updates- Firewall — ufw: SSH (rate-limited), mosh UDP 60000–61000, 80/443
- Toolchain — git, nvm + Node LTS + npm, bun, bat, eza, ripgrep, fd
- Agents — Claude Code, opencode, herdr
- Editor — Neovim (upstream build) + LazyVim
- Server — Caddy on 80/443,
/etc/caddyand/srv/wwwdev-writable without sudo - Docker — rootless, running as dev, plus a
docker-composeshim
Two paths to root, deliberately unequal:
| Password? | Agents can use it? | |
|---|---|---|
apt, systemctl, ufw, journalctl, caddy, ss, ip, sysctl |
no | yes |
Everything else (bash, tee, chmod, cp, dd, editors) |
yes, every time | no |
An agent's bash tool has no interactive TTY, so sudo bash prompts, hangs, and fails. That's the boundary — enforced by the absence of a password, not by a rule.
- Docker needs no sudo (rootless), and is absent from the whitelist
sudoalways prompts in Claude Code and opencode, even in auto-approve modetimestamp_timeout=0— one password, one command, no cached window- Audit trail:
sudo-audit -n 50orsudo-audit -f
Honest limit: apt-get install runs maintainer scripts as root by design, so anything that can install software can eventually reach root. This stops accidents and gives you a review point and an audit trail — it is not a sandbox.
Keep true: never run an agent with --dangerously-skip-permissions; never store the sudo password on the box.
- mosh for the daily connection — survives sleep, network switches, tunnels
- plain SSH as a second connection — needed for SFTP, port forwarding, agent forwarding
- tmux inside it — mosh survives the network dropping, tmux survives the session dying
- Containers publish to high ports (rootless can't bind <1024); Caddy reverse-proxies to them
| Symptom | Cause |
|---|---|
| Edited a gist, old version still runs | CDN cache — the ?cb= cache-buster handles it; a pinned SHA does not |
syntax error on a line that's a comment |
Unbalanced ${ or quote earlier in the file. Run bash -n before saving |
| Stage 2 fails, stage 1 fine | Different file — stage 2 is ~1250 lines, stage 1 is ~280 |
| Rootless Docker won't start | sudo bash fix-rootless-docker.sh --permit-userns |
Permission denied under ~/.local |
sudo chown -R dev:dev /home/dev |
Change the sudo password: passwd as dev (needs the current one).
Always bash -n a gist before saving it. Stage 1 checks stage 2 automatically; nothing checks stage 1.