Hetzner Server Provision
Provision a production-ready Hetzner Cloud server end-to-end: choose type/location, create the firewall, boot the server, and install Docker + Nginx + Certbot. The mechanics live in scripts/; this file covers when to run each one and the judgment calls.
Announce at start: "I'm using the hetzner-server-provision skill to set up your Hetzner server."
Prerequisites
This skill uses Hetzner's official hcloud CLI (cleaner and more robust than raw API curl — it waits for the server natively and gives readable errors).
- hcloud CLI —
command -v hcloud || brew install hcloud - API token — export it so every script picks it up:
(orexport HCLOUD_TOKEN="<your-hetzner-api-token>"hcloud context create <name>once, interactively). Ask the user for the token if it isn't set.
Step 0: Gather Requirements
Before provisioning, collect:
- SSH public key — check
~/.ssh/*.pub, ask which to use if multiple found - Server purpose — dev or production? (affects size recommendation)
- Location preference — or let the skill fall back across EU locations
- Server name — e.g.
my-app-dev
Step 1: Pick Type & Location
scripts/list-server-types.sh # all types, cheapest first
scripts/list-server-types.sh --location hel1 # filter to one location
scripts/list-server-types.sh --arch arm # arm only (or: --arch x86)
Recommendation guide (specs only — prices drift, so read live figures from list-server-types.sh):
| Use case | Recommended | Specs |
|---|---|---|
| Dev server (budget) | cx23 |
2vCPU / 4GB / 40GB, AMD64 |
| Dev server (ARM) | cax11 |
2vCPU / 4GB / 40GB, ARM64 |
| Production small | cx33 |
4vCPU / 8GB / 80GB, AMD64 |
Architecture matters for deploys: an
armserver (cax*) can only runarm64images; anx86server (cx*) runsamd64. Match the image you'll deploy later.
Not all types exist in all locations.
cx*are EU-only (fsn1, nbg1, hel1). Singapore (sin) only has priciercpx*types.list-server-types.shshows exactly what's bookable where.
Step 2: Provision the Server
One script handles SSH key upload, firewall, server creation, and waiting — all idempotent, so it's safe to re-run.
scripts/provision.sh \
--name my-app-dev \
--type cx23 \
--ssh-key-file ~/.ssh/id_ed25519.pub
It tries hel1 → nbg1 → fsn1 and automatically moves on if a location has no capacity. Pin one with --location hel1 if needed.
Firewall posture (secure by default): port 22 is opened only to your detected public IP, while 80/443/ICMP are public. Override with --ssh-source <CIDR[,CIDR]> (e.g. --ssh-source 0.0.0.0/0,::/0 to open SSH to the world — not recommended). Database/storage ports stay closed and are later bound to the Tailscale subnet by the tailscale-server-setup skill.
On success it prints a JSON line with the IP and ID, e.g.:
{"name":"my-app-dev","id":12345,"ip":"203.0.113.10","location":"hel1","type":"cx23","ssh_key":"id_ed25519","ssh_source":"203.0.113.5/32"}
Wait ~15s after this before the next step so sshd is fully up.
Step 3: Install Docker + Nginx + Certbot (and harden)
Copy the bootstrap script to the server and run it as root:
IP=203.0.113.10 # from Step 2 JSON
KEY=~/.ssh/id_ed25519
scp -o StrictHostKeyChecking=accept-new -i "$KEY" scripts/bootstrap-remote.sh root@"$IP":/tmp/
ssh -o StrictHostKeyChecking=accept-new -i "$KEY" root@"$IP" 'bash /tmp/bootstrap-remote.sh'
Uses
accept-new(trust-on-first-use), notStrictHostKeyChecking=no— it pins the host key on first connect and refuses a silently-changed key afterward, which closes the MITM hole.
It installs Docker CE from the official repo (so docker compose works), plus Nginx and Certbot, then hardens the box: disables SSH password auth (key-only) and enables automatic security updates. The script runs sshd -t to validate the SSH config before reloading, so a typo can't lock you out, and prints the effective SSH settings at the end.
Step 4: Report
✅ Server provisioned successfully
| | |
|--|--|
| Server ID | <id> |
| Public IP | <ip> |
| Location | <location> |
| Type | <type> (xvCPU, xGB RAM) |
| Cost | €x.xx/mo |
| SSH | ssh -i <key-path> root@<ip> |
Next steps:
- Deploy your application (docker-compose-deploy skill)
- Set up domain + SSL (nginx-ssl-setup skill)
- Secure with Tailscale (tailscale-server-setup skill)
Security Posture
Defaults are chosen to fail closed:
- Secrets stay out of the repo and argv. The Hetzner token is read only from
HCLOUD_TOKEN(or anhcloud context) — never a flag, never echoed. Never commit it.provision.shtakes a public key path and refuses anything that isn't a.pub/valid OpenSSH public key, so a private key can't be uploaded by mistake. - SSH is locked to your IP at the firewall by default (
--ssh-sourceto change), and the box is then made key-only (password auth off, root by key only). - Host keys are pinned on first connect (
accept-new), not blindly trusted. - Automatic security updates are enabled on the server.
- Input is validated (
--nameis restricted to[a-z0-9-]) and all scripts useset -euo pipefail.
Two things this skill intentionally does not do, by design: it leaves 80/443 public (web traffic needs them) and keeps a public SSH rule alive (you need SSH before Tailscale exists). Run the tailscale-server-setup skill next to drop public SSH entirely and reach the box over the tailnet.
Common Pitfalls
- No capacity in a location —
provision.shalready falls back across EU locations; if all fail, try a different type or wait. - SSH timeout right after provision — wait ~15s more for
sshdto start. - Locked out after your IP changed — SSH is pinned to the IP detected at provision time. If your IP rotates before Tailscale is up, re-run
provision.sh(idempotent) or temporarily widen with--ssh-source; the firewall rule updates. docker composenot found — only happens withapt install docker.io.bootstrap-remote.shuses the official Docker repo and avoids this.- Architecture mismatch — deploying an
amd64image to acax*(ARM) server (or vice-versa) fails to run. Pick the server arch to match your image in Step 1. HCLOUD_TOKENnot set — every script exits early with a clear message; export the token (or set anhcloud context).