Automated provisioning of hardened Ubuntu VPS servers on Hetzner Cloud with Tailscale VPN, Docker, and developer tools.
brew bundle # installs uv + tailscale (or install them yourself)
open -a Tailscale # sign in to your tailnet
make install # set up the Python environment
cp .env.example .env # then fill in your Hetzner + Tailscale credentials
make doctor # verify everything is ready
uv run setup-vps.py # provisionFirst time through, read What you need below for the accounts, credentials, and
the one-time Tailscale tag setup. make doctor will tell you exactly what's missing.
- Hetzner Cloud — console.hetzner.cloud
- Tailscale — login.tailscale.com (free for personal use)
brew bundle installs both, or install manually:
- uv —
curl -LsSf https://astral.sh/uv/install.sh | sh - Tailscale CLI —
brew install tailscale, thenopen -a Tailscaleto sign in andtailscale statusto verify.
Copy the template and fill it in — .env is gitignored and loaded automatically
(no need to source it):
cp .env.example .envHCLOUD_TOKEN— Hetzner API token (Read & Write): console.hetzner.cloud → Security → API Tokens.SSH_KEY_NAME— the name your SSH key has (or will have) in Hetzner; defaultHetzner Automation Key. You don't have to pre-create the key: if none by that name exists,setup-vps.pyoffers to upload$PUB_KEY, an existing~/.ssh/Hetzner_Automation_Key.pub, or to generate a fresh keypair for you.PUB_KEY(optional) — public-key text to upload if the key must be created.
TAILSCALE_AUTH_KEY— reusable, taggedtag:vps, non-ephemeral. One-time setup:- Define the tag owner once in your ACL
(login.tailscale.com/admin/acls):
"tagOwners": { "tag:vps": ["autogroup:admin"] }
- Generate the key
(login.tailscale.com/admin/settings/keys):
Reusable on, Tags →
tag:vps, Ephemeral off. Tagged nodes get ACL scoping and skip the 180-day key-expiry re-auth.
- Define the tag owner once in your ACL
(login.tailscale.com/admin/acls):
GITHUB_TOKEN is optional (gh CLI / GHCR auto-login on the box).
Advanced (maintainer's setup): instead of a
.env, secrets can be injected at point-of-use from 1Password viaop-run(a personal wrapper) reading the committed.op.env. That file holds only 1Password references (op://vault/item/field), never the secrets themselves, so it's safe to commit;op runresolves them at runtime for that single command (see the 1Passwordop rundocs). This is personal and optional — if you don't already use that workflow, the.envpath above is all you need. Run any command with secrets injected asop-run -- <cmd>(e.g.op-run -- make doctor,op-run -- uv run setup-vps.py).
make doctor # preflight: tools, credentials, valid token, SSH key
uv run setup-vps.pydoctor reports exactly what's missing for anything not ready. The provisioner then
prompts for:
- Hostname (default:
hardened-host) - Server type (default:
cx23, with estimated monthly cost shown) - Datacenter (default:
hel1)
and prints the Tailscale VPN IP for SSH access when done.
ssh sysadmin@<tailscale-ip>At the end of provisioning the script offers to append the Host block to your
~/.ssh/config automatically, so you can just:
ssh <hostname>
(It skips silently if a matching Host entry already exists.) The block it adds:
Host <hostname>
HostName <tailscale-ip>
User sysadmin
IdentityFile ~/.ssh/Hetzner_Automation_Key
The provisioner prints a QR code encoding ssh://sysadmin@<tailscale-ip> — scan
it with any SSH client (Termius, Blink, …) to connect; the QR is app-agnostic, it's
just a standard SSH URI.
For a connection that survives network changes and sleep (ideal on mobile), use Mosh (pre-installed):
mosh --ssh="ssh -i ~/.ssh/Hetzner_Automation_Key" sysadmin@<tailscale-ip>Mosh rides the Tailscale tunnel (no public ports are opened — UFW already allows all
traffic on tailscale0).
To share a locally-running dev server without opening any inbound port, run on the box:
publish 3000 # → ephemeral public https://<random>.trycloudflare.com URLThis opens an outbound Cloudflare quick tunnel (cloudflared) to
localhost:3000. Ctrl+C to stop. The URL is random and ephemeral; a stable named
URL would need a Cloudflare account (see ROADMAP.md).
See https://ghostty.org/docs/help/terminfo. In ~/.config/ghostty/config, set
shell-integration-features = ssh-terminfo,ssh-env
Required for proper terminal app display (htop, vim, etc.).
- OS: Ubuntu 24.04 LTS
- User:
sysadmin(passwordless sudo, docker group) - Timezone: Europe/Berlin
- Swap: 2GB
- Tools: git, curl, wget, jq, vim, tmux, ripgrep, fzf, gh
- Remote/mobile: mosh (roaming SSH), Tailscale SSH
- Monitoring: htop, iotop, ncdu
- Docker: docker.io, docker-compose-v2
- Security: ufw (firewall), unattended-upgrades
- VPN: Tailscale (with SSH enabled, node tagged
tag:vps) - Dev: Claude Code,
cloudflared+ thepublish <port>helper
- History: 50k commands in memory, 100k on disk, timestamped
- fzf shortcuts:
Ctrl+R- Fuzzy command history searchCtrl+T- File finderAlt+C- Directory finder
- UFW: Only ports 443/tcp and 41641/udp (Tailscale) open
- Tailscale SSH: VPN-only access, no public SSH
- Auto-updates: Security patches via unattended-upgrades
- Docker logs: Auto-rotation (3 × 10MB max)
SSH key: if no Hetzner key matches SSH_KEY_NAME, the script offers to create &
upload one. If you already have a key under a different name, set SSH_KEY_NAME to
match it exactly (names are case-sensitive).
Hostname already taken: Choose different name (unique per Hetzner account).
Tailscale IP not found: Ensure tailscale status shows your tailnet is active.
setup-vps.py- Provisioning scriptdoctor.py- Preflight check (make doctor)cloud-config.yaml.tmpl- Cloud-init templateBrewfile- Local tools (brew bundle).env.example- Environment variable template (copy to.env).op.env- Maintainer's 1Password references (optional; ignore if not usingop-run)