Skip to content

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 (from tailscale 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
  • A VPS you can ssh root@<ip> into
  • Local machine has gh authenticated (gh auth status shows logged in)
  • Tailscale running on your local machine, so you keep access after lockdown
  • Repo access on GitHub

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.

Terminal window
# Create admin with zsh as login shell
useradd -m -s /usr/bin/zsh admin
usermod -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/admin
chmod 440 /etc/sudoers.d/admin
# Reuse the same local pubkey for SSH access to admin
mkdir -p /home/admin/.ssh
cp /root/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
chmod 700 /home/admin/.ssh
chmod 600 /home/admin/.ssh/authorized_keys
chown -R admin:admin /home/admin/.ssh

Generate admin’s key on the VPS so admin can clone the repo.

On the VPS (still as root):

Terminal window
sudo -u admin ssh-keygen -t ed25519 -N "" -f /home/admin/.ssh/id_ed25519 -C "admin@$(hostname)" -q
sudo -u admin bash -c "ssh-keyscan -t ed25519 github.com >> /home/admin/.ssh/known_hosts"
cat /home/admin/.ssh/id_ed25519.pub

Register admin’s pubkey from your local machine:

Terminal window
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.

The official Docker apt repo gives you both docker and docker compose.

Terminal window
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "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.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker run --rm hello-world

If your VPS isn’t on Noble, replace noble with your codename (lsb_release -cs).

Terminal window
apt-get install -y \
zsh tmux git \
neovim micro bat \
zsh-autosuggestions zsh-syntax-highlighting \
ufw ca-certificates curl

build-essential and gcc are deliberately omitted. Add them only if you need to compile native modules.

Terminal window
# Starship → /usr/local/bin so every user sees it
curl -sS https://starship.rs/install.sh | sh -s -- -y --bin-dir /usr/local/bin
# GitHub CLI
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /etc/apt/keyrings/githubcli-archive-keyring.gpg
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
echo "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.list
apt-get update
apt-get install -y gh
# Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up # interactive; opens an auth URL, join your tailnet
tailscale 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 Sublime
curl -fsSL https://raw.githubusercontent.com/aurora/rmate/master/rmate \
-o /usr/local/bin/rsub
chmod +x /usr/local/bin/rsub

With admin’s GitHub key registered (step 2), clone straight into admin’s home as admin:

Terminal window
sudo -u admin git clone <repo-url> /home/admin/<repo>
Terminal window
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=50000
SAVEHIST=50000
setopt 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-search
bindkey '^[OA' up-line-or-search
bindkey '^[[B' down-line-or-search
bindkey '^[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-word
bindkey -M viins '^[f' forward-word
bindkey -M viins '^[b' backward-word
ZSHRC
chown admin:admin /home/admin/.zshrc

cc launches Claude Code with permission prompts skipped and sets the terminal title, ccc continues the last session, and ccr resumes a picked one.

Write your Starship config into admin’s home so the VPS prompt matches your local one:

Terminal window
sudo -u admin mkdir -p /home/admin/.config
cat > /home/admin/.config/starship.toml << 'STARSHIP'
add_newline = false
# A minimal left prompt
format = """$directory$character"""
palette = "catppuccin_mocha"
# move the rest of the prompt to the right with explicit git_branch inclusion
right_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 = false
style = 'bold blue'
symbol = " "
[golang]
format = '[ ](bold cyan)'
[kubernetes]
symbol = '☸ '
disabled = true
detect_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"
STARSHIP
chown admin:admin /home/admin/.config/starship.toml

Verify both prompt modes render cleanly on the VPS:

Terminal window
ssh admin@<vps-tailscale-ip>
STARSHIP_SHELL=zsh starship prompt --status 0 # insert mode
STARSHIP_SHELL=zsh starship prompt --keymap vicmd --status 0 # normal mode

Neither should print a WARN from the character module.

Write your tmux config into admin’s home, same as the zshrc and Starship config above, with no local scp needed:

Terminal window
cat > /home/admin/.tmux.conf << 'TMUX'
unbind C-b
set -g prefix §
bind § send-prefix
bind C-b send-prefix
set -g mouse on
# Enter copy mode and scroll with PgUp/PgDn
bind -n Pageup copy-mode -u
bind -n Pagedown send-keys Pagedown
bind -T copy-mode Pageup send -X page-up
bind -T copy-mode Pagedown send -X page-down
bind -T copy-mode-vi Pageup send -X page-up
bind -T copy-mode-vi Pagedown send -X page-down
# Escape to exit copy mode fast
bind -T copy-mode Escape send -X cancel
bind -T copy-mode-vi Escape send -X cancel
TMUX
chown admin:admin /home/admin/.tmux.conf

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.

Terminal window
sudo -u admin bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
sudo -u admin /home/admin/.local/bin/claude --version # 2.x.x

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 -l

ssh vps now gives you an interactive shell that auto-cds into the repo. If you need to work elsewhere, cd .. out.

Test:

Terminal window
ssh vps # lands you in ~/<repo>

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:

  1. Install the rsub package in Sublime (Package Control, then Install Package, then rsub). It listens on port 52698.
  2. Reverse-forward that port to the VPS so rsub <file> can phone home. Putting the RemoteForward line under Host * applies it to every host:
Host *
RemoteForward 52698 127.0.0.1:52698

Then, 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.

Terminal window
ssh vps
claude auth login # interactive; opens an auth URL, paste the code back

If you want gh on the VPS authed too, run gh auth login.

Terminal window
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw --force enable
sudo ufw status verbose

Expected status:

Default: deny (incoming), allow (outgoing), deny (routed)
To Action From
-- ------ ----
Anywhere on tailscale0 ALLOW IN Anywhere
Terminal window
# from your local machine
ssh vps 'echo OK; date' # works
ssh -o ConnectTimeout=5 root@<public-ip> 'echo nope' 2>&1 # times out

If 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:

  • tailscaled handles SSH on the tailnet interface instead of sshd. sshd keeps 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:

Terminal window
sudo tailscale set --ssh

That 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 -l

Test: 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:

Terminal window
> /home/admin/.ssh/authorized_keys

Leaving 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).

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:

Terminal window
curl -sSL https://raw.githubusercontent.com/gastownhall/beads/main/scripts/install.sh | bash
bd --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 remote
bd stats # sanity check: issue counts should match your other machines
git config beads.role maintainer # match role used on other dev machines for this repo

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, not sh, 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.