VPS setup (Hetzner)
If you are working local-first, start at Local (macOS) setup. This page is for the optional always-on box you keep running when you want agents working while your laptop is shut.
Bootstrap a fresh Ubuntu 24.04 VPS for development. Tested on a Hetzner CX22 (4GB), and it should work on any clean Ubuntu Noble box with root SSH access.
Throughout this guide, replace these placeholders:
<repo>: the directory name your repo lives in (e.g.my-project)<repo-url>: its SSH clone URL (e.g.git@github.com:you/my-project.git)<vps-tailscale-ip>: the VPS’s Tailscale IP (fromtailscale ip -4)
End state:
- Non-root user (
admin) with sudo, docker, and zsh - Repo cloned to
~/<repo> - Docker CE plus the Compose plugin
- zsh as default shell with Starship prompt, history search, autosuggestions, syntax highlighting
- Claude Code, GitHub CLI, Tailscale
- Beads (
bd) issue tracker wired to the repo’s shared Dolt database - Public SSH blocked by ufw, so the box is only reachable over Tailscale
Prerequisites
Section titled “Prerequisites”- A VPS you can
ssh root@<ip>into - Local machine has
ghauthenticated (gh auth statusshows logged in) - Tailscale running on your local machine, so you keep access after lockdown
- Repo access on GitHub
1. Create the non-root user
Section titled “1. Create the non-root user”Claude Code refuses to run with --dangerously-skip-permissions as root, so we develop as a regular user. Create admin first so that everything you install later lands in a sane layout, but don’t switch to an admin shell yet. You stay logged in as root for the whole install: the apt steps (Docker, packages, Starship, gh, Tailscale) genuinely need root. For the few commands that must produce admin-owned files in admin’s home you stay root and use sudo -u admin … (or chown afterward). The only real switch to an interactive admin session happens at the interactive-logins step and the ufw step.
# Create admin with zsh as login shelluseradd -m -s /usr/bin/zsh adminusermod -aG sudo,docker admin
# Passwordless sudo (this is a single-user dev box; tighten if shared)echo "admin ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/adminchmod 440 /etc/sudoers.d/admin
# Reuse the same local pubkey for SSH access to adminmkdir -p /home/admin/.sshcp /root/.ssh/authorized_keys /home/admin/.ssh/authorized_keyschmod 700 /home/admin/.sshchmod 600 /home/admin/.ssh/authorized_keyschown -R admin:admin /home/admin/.ssh2. admin’s GitHub SSH key
Section titled “2. admin’s GitHub SSH key”Generate admin’s key on the VPS so admin can clone the repo.
On the VPS (still as root):
sudo -u admin ssh-keygen -t ed25519 -N "" -f /home/admin/.ssh/id_ed25519 -C "admin@$(hostname)" -qsudo -u admin bash -c "ssh-keyscan -t ed25519 github.com >> /home/admin/.ssh/known_hosts"cat /home/admin/.ssh/id_ed25519.pubRegister admin’s pubkey from your local machine:
echo "ssh-ed25519 AAAA... admin@host" | gh ssh-key add - --title "vps-admin-<ip>"Verify on the VPS with sudo -u admin ssh -T git@github.com, which should greet you by username.
3. Install Docker
Section titled “3. Install Docker”The official Docker apt repo gives you both docker and docker compose.
export DEBIAN_FRONTEND=noninteractiveapt-get updateapt-get install -y ca-certificates curlinstall -m 0755 -d /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascchmod a+r /etc/apt/keyrings/docker.ascecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" \ > /etc/apt/sources.list.d/docker.listapt-get updateapt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginsystemctl enable --now dockerdocker run --rm hello-worldIf your VPS isn’t on Noble, replace noble with your codename (lsb_release -cs).
4. System packages
Section titled “4. System packages”apt-get install -y \ zsh tmux git \ neovim micro bat \ zsh-autosuggestions zsh-syntax-highlighting \ ufw ca-certificates curlbuild-essential and gcc are deliberately omitted. Add them only if you need to compile native modules.
5. Starship, gh, Tailscale (system-wide)
Section titled “5. Starship, gh, Tailscale (system-wide)”# Starship → /usr/local/bin so every user sees itcurl -sS https://starship.rs/install.sh | sh -s -- -y --bin-dir /usr/local/bin
# GitHub CLIinstall -m 0755 -d /etc/apt/keyringscurl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ -o /etc/apt/keyrings/githubcli-archive-keyring.gpgchmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpgecho "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.listapt-get updateapt-get install -y gh
# Tailscalecurl -fsSL https://tailscale.com/install.sh | shtailscale up # interactive; opens an auth URL, join your tailnettailscale ip -4 # note the IP, you'll need it for the ssh config and lockdown
# rmate (the `rsub` server-side helper) → edit VPS files in your local Sublimecurl -fsSL https://raw.githubusercontent.com/aurora/rmate/master/rmate \ -o /usr/local/bin/rsubchmod +x /usr/local/bin/rsub6. Clone the repo
Section titled “6. Clone the repo”With admin’s GitHub key registered (step 2), clone straight into admin’s home as admin:
sudo -u admin git clone <repo-url> /home/admin/<repo>7. zshrc for admin
Section titled “7. zshrc for admin”cat > /home/admin/.zshrc << 'ZSHRC'# --- Rust (no-op until rustup is installed) ---[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
# --- Bun (no-op until bun is installed) ---export BUN_INSTALL="$HOME/.bun"export PATH="$BUN_INSTALL/bin:$PATH"
# --- PATH ---export PATH="$HOME/.local/bin:$PATH"
# --- Editor ---export EDITOR='vim'
# --- History ---HISTFILE="$HOME/.zsh_history"HISTSIZE=50000SAVEHIST=50000setopt EXTENDED_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE SHARE_HISTORY APPEND_HISTORY INC_APPEND_HISTORY
# --- Completions + history search ---autoload -Uz compinit && compinit# up/down arrow: walk history on empty line, prefix-match when typing# (bind both ^[[ and ^[O variants so app-cursor terminals work too)bindkey '^[[A' up-line-or-searchbindkey '^[OA' up-line-or-searchbindkey '^[[B' down-line-or-searchbindkey '^[OB' down-line-or-search
# --- Claude Code aliases ---cc() { print -Pn "\e]0;claude: %~\a"; claude --dangerously-skip-permissions "$@" }ccc() { cc -c "$@" }ccr() { cc -r "$@" }alias cl="clear"
# --- zsh plugins ---[ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ] && \ source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh[ -f /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ] && \ source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
# --- Starship ---command -v starship &>/dev/null && eval "$(starship init zsh)"bindkey -M viins '^[^?' backward-kill-wordbindkey -M viins '^[f' forward-wordbindkey -M viins '^[b' backward-wordZSHRCchown admin:admin /home/admin/.zshrccc launches Claude Code with permission prompts skipped and sets the terminal title, ccc continues the last session, and ccr resumes a picked one.
8. Starship config for admin
Section titled “8. Starship config for admin”Write your Starship config into admin’s home so the VPS prompt matches your local one:
sudo -u admin mkdir -p /home/admin/.configcat > /home/admin/.config/starship.toml << 'STARSHIP'add_newline = false# A minimal left promptformat = """$directory$character"""palette = "catppuccin_mocha"# move the rest of the prompt to the right with explicit git_branch inclusionright_format = """$git_branch$all"""command_timeout = 1000
[character]vicmd_symbol = '[N >>>](bold yellow)'success_symbol = '[>](bold green)'
[git_branch]format = '[$symbol$branch(:$remote_branch)]($style) 'style = "bold purple"
[aws]format = '[$symbol(profile: "$profile" )(\(region: $region\) )]($style)'disabled = falsestyle = 'bold blue'symbol = " "
[golang]format = '[ ](bold cyan)'
[kubernetes]symbol = '☸ 'disabled = truedetect_files = ['Dockerfile']format = '[$symbol$context( \($namespace\))]($style) '
[gcloud]format = '[$symbol]($style) 'symbol = '☁️'
[docker_context]disabled = true
[bun]disabled = true
[palettes.catppuccin_mocha]rosewater = "#f5e0dc"flamingo = "#f2cdcd"pink = "#f5c2e7"mauve = "#cba6f7"red = "#f38ba8"maroon = "#eba0ac"peach = "#fab387"yellow = "#f9e2af"green = "#a6e3a1"teal = "#94e2d5"sky = "#89dceb"sapphire = "#74c7ec"blue = "#89b4fa"lavender = "#b4befe"text = "#cdd6f4"subtext1 = "#bac2de"subtext0 = "#a6adc8"overlay2 = "#9399b2"overlay1 = "#7f849c"overlay0 = "#6c7086"surface2 = "#585b70"surface1 = "#45475a"surface0 = "#313244"base = "#1e1e2e"mantle = "#181825"crust = "#11111b"STARSHIPchown admin:admin /home/admin/.config/starship.tomlVerify both prompt modes render cleanly on the VPS:
ssh admin@<vps-tailscale-ip>STARSHIP_SHELL=zsh starship prompt --status 0 # insert modeSTARSHIP_SHELL=zsh starship prompt --keymap vicmd --status 0 # normal modeNeither should print a WARN from the character module.
9. tmux config
Section titled “9. tmux config”Write your tmux config into admin’s home, same as the zshrc and Starship config above, with no local scp needed:
cat > /home/admin/.tmux.conf << 'TMUX'unbind C-bset -g prefix §bind § send-prefixbind C-b send-prefixset -g mouse on
# Enter copy mode and scroll with PgUp/PgDnbind -n Pageup copy-mode -ubind -n Pagedown send-keys Pagedownbind -T copy-mode Pageup send -X page-upbind -T copy-mode Pagedown send -X page-downbind -T copy-mode-vi Pageup send -X page-upbind -T copy-mode-vi Pagedown send -X page-down
# Escape to exit copy mode fastbind -T copy-mode Escape send -X cancelbind -T copy-mode-vi Escape send -X cancelTMUXchown admin:admin /home/admin/.tmux.conf10. Install Claude Code as admin
Section titled “10. Install Claude Code as admin”The official installer uses bash syntax, so pipe it to bash, not sh. On Ubuntu, sh is dash and the script will fail with a syntax error.
sudo -u admin bash -c 'curl -fsSL https://claude.ai/install.sh | bash'sudo -u admin /home/admin/.local/bin/claude --version # 2.x.x11. Local ssh config alias
Section titled “11. Local ssh config alias”On your local machine, add to ~/.ssh/config (pick any short Host alias you like, vps is used here):
Host vps HostName <vps-tailscale-ip> User admin ServerAliveInterval 60 RequestTTY yes RemoteCommand cd ~/<repo> 2>/dev/null; exec zsh -lssh vps now gives you an interactive shell that auto-cds into the repo. If you need to work elsewhere, cd .. out.
Test:
ssh vps # lands you in ~/<repo>rsub remote editing (Sublime)
Section titled “rsub remote editing (Sublime)”To edit VPS files in your local Sublime over the same SSH session (the rsub helper installed in step 5), two pieces are needed locally:
- Install the rsub package in Sublime (Package Control, then Install Package, then
rsub). It listens on port52698. - Reverse-forward that port to the VPS so
rsub <file>can phone home. Putting theRemoteForwardline underHost *applies it to every host:
Host * RemoteForward 52698 127.0.0.1:52698Then, while SSHed in, rsub ~/<repo>/some-file.ts opens the file in Sublime locally, and saving writes it back over the tunnel. It is a good nano replacement for one-off edits.
12. Interactive logins (as admin)
Section titled “12. Interactive logins (as admin)”ssh vpsclaude auth login # interactive; opens an auth URL, paste the code backIf you want gh on the VPS authed too, run gh auth login.
13. Lock down public SSH (ufw)
Section titled “13. Lock down public SSH (ufw)”sudo ufw default deny incomingsudo ufw default allow outgoingsudo ufw allow in on tailscale0sudo ufw --force enablesudo ufw status verboseExpected status:
Default: deny (incoming), allow (outgoing), deny (routed)
To Action From-- ------ ----Anywhere on tailscale0 ALLOW IN Anywhere14. Verify
Section titled “14. Verify”# from your local machinessh vps 'echo OK; date' # worksssh -o ConnectTimeout=5 root@<public-ip> 'echo nope' 2>&1 # times outIf both behaviours match, you’re locked down.
15. Optional: Tailscale SSH (drop SSH keys entirely)
Section titled “15. Optional: Tailscale SSH (drop SSH keys entirely)”The setup above uses your local SSH key (over Tailscale) to reach admin@vps. If you’d rather not manage SSH keys at all, which helps when SSHing from devices that can’t use a desktop SSH agent (any iOS or Android client), switch the local-to-VPS auth from OpenSSH key auth to Tailscale SSH. Auth becomes your tailnet identity: there are no keys to rotate or sync, and adding mobile access just means joining the phone to the tailnet.
What changes:
tailscaledhandles SSH on the tailnet interface instead ofsshd.sshdkeeps running but stays unreachable (the public side is ufw-blocked, and the tunnel side is intercepted by tailscaled).- Auth is controlled by your tailnet policy file, not
authorized_keys. - No SSH keys are needed on any client to reach this VPS.
Enable Tailscale SSH on the VPS, from inside ssh vps:
sudo tailscale set --sshThat is equivalent to having included --ssh on the original tailscale up in step 5. Toggling it on later is non-disruptive.
Grant SSH access via the tailnet policy. By default Tailscale SSH allows no connections until your policy file says so. In the Tailscale admin console (or via the GitOps action if you manage the policy as code), add an ssh block:
"ssh": [ { "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["admin", "root"], },],This lets your own tailnet identity (any device signed in to your tailnet) SSH as admin or root on your own nodes. Adjust src and dst if you share the tailnet.
Update the local ssh config. Switch HostName from the raw Tailscale IP to MagicDNS, and drop key-specific options:
Host vps HostName <vps-magicdns-name> # e.g. vps.tailnet-name.ts.net, or just `vps` User admin ServerAliveInterval 60 RequestTTY yes RemoteCommand cd ~/<repo> 2>/dev/null; exec zsh -lTest: ssh vps should land you in the repo with no key exchanged. Confirm with ssh -v vps 2>&1 | grep -i tailscale, which shows Tailscale’s banner.
(Optional) Drop admin’s authorized_keys. Once Tailscale SSH is verified working, the OpenSSH key path is unused. You can empty it for cleanliness:
> /home/admin/.ssh/authorized_keysLeaving it in place is harmless (just dormant). The GitHub key from step 2 stays, because it is a different key for a different purpose (admin to GitHub).
16. Beads issue tracker
Section titled “16. Beads issue tracker”Beads (bd) tracks issues in a shared Dolt database synced via refs/dolt/data on the repo’s git remote. The .beads/config.yaml, metadata.json, and issues.jsonl files are git-tracked, so cloning the repo (step 6) already brought the config. This step wires up the actual database.
As admin, inside the repo:
curl -sSL https://raw.githubusercontent.com/gastownhall/beads/main/scripts/install.sh | bashbd --version # confirm it matches every other machine on this project (see gotcha below)
cd ~/<repo>bd bootstrap --yes # clones the shared Dolt database from the git remotebd stats # sanity check: issue counts should match your other machinesgit config beads.role maintainer # match role used on other dev machines for this repoRecovery
Section titled “Recovery”If you lose Tailscale access (key revoked, daemon broken, tailnet down) you’ll be locked out at the SSH layer. Use the provider’s web console to log in and either:
- restart Tailscale:
sudo systemctl restart tailscaled && sudo tailscale up - temporarily reopen public SSH:
sudo ufw allow ssh(re-deny after recovery)
- Don’t expose dev ports publicly. Bind to the Tailscale IP for any web UI.
- Docker and ufw: Docker bypasses ufw via its own iptables chains. Bind container ports to the Tailscale IP (
100.x.x.x) when you don’t want them publicly reachable. - Claude installer quirk: it must be piped to
bash, notsh, on Ubuntu (dash chokes on the script). - Codex, Rust, uv, Bun, and OAuth-over-SSH tunnels are intentionally not in this guide. Add them per user as needed.