tangled-infra#
self-hosted knot + spindle for tangled.org, on a single hetzner box.
- knot hostname:
knot.zzstoatzz.io(git hosting) - spindle hostname:
spindle.zzstoatzz.io(CI runner) - platform: hetzner cloud, cpx31 (4 vCPU / 8GB / 160GB), ubuntu-24.04
- provisioning: terraform + cloud-init
- reverse proxy: caddy (auto letsencrypt)
- tangled core pinned to
v1.13.0-alpha
knot and spindle are colocated on one box because (a) it's cheap and (b) consolidating personal tangled infra in one place makes ops simpler. spindle needs a real docker daemon to spawn step containers, which is why this lives on a VPS instead of fly.io.
layout#
tangled-infra/
├── infra/ # terraform — hcloud_server, firewall, ssh_key
├── deploy/ # cloud-init + systemd + Caddyfile (templated by terraform)
├── scripts/ # ops helpers (e.g. listing repos on a knot)
├── justfile # all the verbs
├── NOTES.md # ops notes + history
└── .env (gitignored) # HCLOUD_TOKEN + TF_VAR_owner_did
first-time setup#
- get a hetzner cloud API token: console.hetzner.cloud → your project → security → API tokens → generate (read+write).
- find your AT proto DID — from https://tangled.org/settings, or via DNS:
dig +short TXT _atproto.<your-handle> | tr -d '"' | sed 's/^did=//' - copy env:
cp deploy/env.example .envand fill inHCLOUD_TOKEN+TF_VAR_owner_did. - terraform init:
just init. - plan:
just plan— verify what's about to happen. - apply:
just apply— stands up the box. takes ~3–5 min including cloud-init. - point DNS:
just outputsshows the IP. add A records for both hostnames at your DNS provider. - verify on tangled:
- https://tangled.org/settings/knots → hit "verify" for your knot hostname
- https://tangled.org/settings/spindles → hit "verify" for your spindle hostname
daily ops#
just status # systemctl status for knot/spindle/caddy/docker
just ssh # ssh as root
just knot-logs # journalctl follow knot
just spindle-logs # journalctl follow spindle
just caddy-logs # journalctl follow caddy
just bootstrap-log # tail the initial cloud-init bootstrap log
just restart-knot # systemctl restart knot
just restart-spindle # systemctl restart spindle
just restart-caddy # systemctl reload caddy
just redeploy # scp deploy/* to box (no terraform re-apply)
migrating from an existing knot#
a knot's identity is its hostname — every sh.tangled.repo record on a PDS references it. that means changing hostname = standing up a new knot record + per-repo cutover. there's no built-in "rename knot" or "move repo" operation today.
rough shape of a migration (do these AFTER the new knot is verified):
- inventory the repos on the old knot. if you control the old host, sshing in and
lsing the repos dir is the simplest path.scripts/list-knot-repos.shdoes this for fly-hosted knots. - for each repo:
- clone from old:
git clone git@<old-host>:<did>/<name> /tmp/repo - create the repo on the new knot (via the tangled.org UI — it'll mint a fresh per-repo DID)
- push:
cd /tmp/repo && git remote set-url origin git@<new-host>:<repo-did> && git push --all && git push --tags
- clone from old:
- retire the old knot only after all repos are confirmed on the new one:
- delete the old
sh.tangled.knotrecord from the owner PDS - tear down the old host
- delete the old
upgrading the knot/spindle version#
bump TF_VAR_tangled_core_tag in .env (or infra/variables.tf default), then:
just redeploy
just ssh
# on the box:
/opt/tangled-infra/bootstrap.sh # idempotent — re-clones at new tag, rebuilds, restarts
per the migration guide, hit "retry" on the tangled.org settings page for both services after a major bump.
why not nix?#
the docs-prescribed path is NixOS via @tangled.org/infra. that's idiomatic but a different workflow from the rest of my hetzner infra (my-prefect-server is terraform + ubuntu + cloud-init). matching the existing pattern wins over idiom here. if nix proves useful later, the migration is contained — same box, swap the bootstrap.