| Filename | Latest commit message | Latest commit date |
|---|---|---|
The cloud layer here is one server, a firewall, and a handful of DNS records. hetzner.hcloud covers that surface directly, so OpenTofu added a second language, a state backend, and another credential path without adding capability. Provisioning is now a playbook against localhost and host configuration is site.yml, so one tool and one execution model cover the whole stack. With no state file, adopting the existing server is a plain playbook run instead of an import step. Tasks, tool versions, REUSE metadata, and the documented migration order follow the same change. The .license sidecars are removed because REUSE.toml already covers documentation paths. Assisted-by: Crush:deepseek-v4.1-flash-1017 |
||
| ansible | ||
| LICENSES | ||
| .gitignore | ||
| mise.toml | ||
| README.md | ||
| REUSE.toml | ||
| server-inventory.md | ||
birdhouse-infra
Infrastructure-as-code for the b1rdh0use stack. This repository will eventually
hold the cloud resources, host configuration, and deploy tasks for everything that
currently runs by hand on av1ary.
Current server state is captured in server-inventory.md. That document is the source of truth for the "as it exists today" snapshot below.
Architecture today
One Hetzner Cloud VPS (av1ary, 4 vCPU / 7.6 GiB / 75 GiB) running Debian 12.
Every workload lives directly on the host as a systemd unit wrapped around an
application user. There is no container runtime.
Internet
|
[ Hetzner VPS ]
|
+---------------+---------------+
| |
Caddy :80/:443 Tailscale
ACME TLS 100.95.60.42
| |
+----+----+ +----+----+
| | | |
static git.* forgejo foundry
site foundry.* :3000 :30000
| Concern | How it works today |
|---|---|
| Compute | Hetzner Cloud, KVM, cloud-init image, primary IPv4 + static IPv6 /64 |
| OS | Debian 12 (bookworm), UTC, no swap |
| Ingress | Caddy 2.11.7, automatic ACME, HTTP/3; sites b1rdh0use.com, www, git.b1rdh0use.com, foundry.b1rdh0use.com |
| Static site | /var/www/b1rdh0use, generated output, source not on the host |
| Git forge | Forgejo 16.0.5 (SQLite), bound to the Tailscale IP only |
| VTT | Foundry VTT 5.x, bound to *:30000, unit present but not enabled at boot |
| Game server | Minecraft, tmux-backed Type=forking unit, currently down |
| Metrics | munin-node on :4949, local agent only |
| Remote access | Tailscale (MagicDNS, Tailscale SSH, exit node), sshd on :22 |
| Firewall | UFW enabled; rules not yet captured in the repo |
| DNS | deSEC zones |
| Purelymail | |
| Isolation | Per-service system users (git, foundry, minecraft, caddy) with per-user homes and data directories |
| Deploys | mise tasks drive rsync from a workstation to the host |
Pain points this migration targets
- Unreproducible host — 715 days of drift, no declaration of state anywhere.
- No backups — no restic/borg/rclone; a lost disk loses Forgejo, Foundry, and Minecraft.
- Fragile units — tmux-based Minecraft; Foundry not enabled at boot.
- Tailscale IP hardcoded in Caddy and Forgejo config, so node recreation breaks ingress.
- Secrets on the image — Forgejo
SECRET_KEY/DB, Foundry license, Tailscale auth key, DNS and mail credentials. - Manual, single-host deploys —
rsyncassumes whatever the host currently looks like.
Target state
Same single VPS (or a small set of them), but every piece of it is declared in this repo and rebuildable from scratch. Stack: Ansible for both the cloud and host layers, mise as the task runner and deploy interface.
deSEC DNS ---- Purelymail (mail stays external)
|
Ansible cloud layer: server, IPs, SSH keys,
provision firewall, DNS records, cloud-init
|
v
cloud-init bootstrap only: users, keys, packages
|
Ansible host layer: packages, users, units,
site.yml Caddyfile, app config, secrets
|
v
mise day-2 interface: provision, converge,
deploy, backup, restore, health
Layer responsibilities
| Layer | Tool | Owns |
|---|---|---|
| Cloud | Ansible (hosts: localhost, hetzner.hcloud) |
Server type/location/image, primary + IPv6, SSH keys, Hetzner Cloud Firewall, DNS records in deSEC, cloud-init user_data |
| Bootstrap | cloud-init | Base users, authorized keys, essential packages, Tailscale join. One-shot; no day-2 drift management. |
| Host | Ansible | Package state, service users and homes, systemd units, /etc/caddy/Caddyfile, Forgejo app.ini, Munin, UFW rules, Tailscale settings |
| Day-2 | mise tasks | Wrapping the above, plus deploys, backups, and restores so no one runs raw commands by hand |
Decisions
- Ansible for every layer. One tool, one language, one execution model across cloud and host. The cloud layer here is one server, a firewall, and a handful of records; a second tool for that is cost without capability.
- No OpenTofu, no state file. The provider APIs are the source of truth. Nothing to secure, nothing to lock, and nothing that can drift from reality. Adopting the existing server is a playbook run, not an import step.
hetzner.hcloudcovers the cloud layer.server,primary_ip,ssh_key,firewall,firewall_resource, plus thehcloudinventory plugin that feeds the host play.- deSEC needs a small amount of custom work.
community.dnsships providers for Hetzner, Hosttech, and Infomaniak, not deSEC. Default to a thin set ofuritasks against the deSEC REST API; reach for a third-party collection (tealk.desec,strfl89.desec_io) only if it earns the dependency. --check --diffis the review gate. No plan file, so the dry run is what gets read before anything mutates.- No containers initially. Keep per-user systemd isolation; folding Foundry/Minecraft/Forgejo into containers is a follow-up, not a prerequisite. This also fixes the tmux Minecraft unit when it happens.
- mise is the only entry point. Every operation is a named task, so the path is identical on a laptop, in CI, and on the host.
- Secrets never in the image. Ansible Vault by default, decrypting at run time. Forgejo
SECRET_KEY, Tailscale auth key, Foundry license, deSEC/mail tokens, Hetzner API token.
Deploy flow (target)
mise run provision --check --diff— dry run of cloud and DNS changes.mise run provision— create or update the server, IPs, firewall, and records.mise run converge— Ansible configures the host, idempotently.mise run deploy:<service>— build,rsyncthe artefact, restart the unit, health-check.mise run backup— restic snapshot of the declared data paths.
Manual SSH is for debugging only, not for change.
Repository layout (planned)
.
├── README.md
├── server-inventory.md
├── ansible/
│ ├── ansible.cfg
│ ├── requirements.yml # hetzner.hcloud, community.general, ansible.posix
│ ├── inventory/
│ ├── group_vars/
│ ├── playbooks/provision.yml # localhost: server, firewall, DNS
│ ├── roles/ # base, tailscale, caddy, forgejo, foundry, minecraft, munin, firewall
│ └── site.yml # host convergence
├── mise.toml # all tasks: provision, converge, deploy, backup
└── .mise/ # task scripts
Migration order
- Preserve first. Backups and a captured copy of every root-readable path currently marked BLOCKED in the inventory. No cutover before this.
- Codify the host. Ansible roles for the units and config that exist today, converging to a no-op on the live box. This pays off immediately and needs no new infrastructure.
- Codify the cloud layer. A
provisionplaybook that adopts the existing server by name and declares the firewall and DNS records. - Move deploys to mise. Replace ad-hoc
rsyncinvocations with tasks. - Rehearse a rebuild. Provision a clone from repo alone; diff against the live host.
- Cut over. Only after the rehearsal matches.
Licensing
This repository is REUSE compliant.
| Scope | License |
|---|---|
| Code and configuration (Ansible, mise, task scripts) | Mutualist License 1.2 (LicenseRef-MutuaL-1.2) |
Documentation (README.md, server-inventory.md, docs/) |
CC0-1.0 |
Run mise run license (or reuse lint) before committing.
Open questions
- Which deSEC zone(s) cover every hostname today, and can every record be declared without a gap?
- Is a third-party deSEC collection worth depending on, or is
uriagainst the REST API the right shape? - Do Foundry and Minecraft stay on this host, or move to dedicated/contained workloads?
- Which Tailscale binding strategy replaces the hardcoded
100.95.60.42—127.0.0.1plus a tailnet-only proxy, or a stable MagicDNS name? - What is the backup target and retention policy?
- Full severity/risk list lives in server-inventory.md.