Skip to content

Instantly share code, notes, and snippets.

@dragonworx
Last active July 29, 2026 23:14
Show Gist options
  • Select an option

  • Save dragonworx/3b5cf9c95b65c126592122627c0c1d67 to your computer and use it in GitHub Desktop.

Select an option

Save dragonworx/3b5cf9c95b65c126592122627c0c1d67 to your computer and use it in GitHub Desktop.

VPS Setup Runbook

Ubuntu 26.04 · 4 vCPU / 8 GB · two gists, two stages, ~15 min.

Gists

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.

Setup

  1. Reinstall Ubuntu 26.04. Keep the root password your provider issues.
  2. In Moshi, create the dev connection first. Host = IP, user = dev, Authentication → Generate new Ed25519 keycopy the public key. Save. Don't connect — it can't work yet.
  3. Connect to root@<ip> with the password.
  4. Run stage 1:
    curl -fsSL "https://gist.githubusercontent.com/dragonworx/1ef59bb118bf018d876d4a0cab78e67d/raw/ubuntu-bootstrap.sh?cb=$(date +%s)" | bash
    
  5. Answer five prompts — public key from step 2 · server name · name · email · sudo password (hidden, twice) — then y. Nothing has changed until that y.
  6. Wait ~15 min. Logged to /root/provision.log.
  7. Open the dev connection 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.
  8. 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
    
  9. GitHub: cat ~/.ssh/github.pub → paste at github.com/settings/keys → ssh -T git@github.com
  10. Claude Code: run claude once to authenticate.
  11. Shred the summary: sudo shred -u /root/provision-summary.txt
  12. Reboot: sudo reboot — then check docker ps works, confirming rootless Docker survived.

Adding a device

  1. Generate a keypair on the device (SSH app → new connection → generate Ed25519).
  2. Copy the public key.
  3. On the server: sshkey add ipad → paste. --sftp for file-transfer-only, no shell.
  4. 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.

What you get

  • Swap — half of RAM (4 GB), swappiness=10
  • dev user — 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/caddy and /srv/www dev-writable without sudo
  • Docker — rootless, running as dev, plus a docker-compose shim

Privilege model

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
  • sudo always prompts in Claude Code and opencode, even in auto-approve mode
  • timestamp_timeout=0 — one password, one command, no cached window
  • Audit trail: sudo-audit -n 50 or sudo-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.

Daily use

  • 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

Common fixes

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment