OpenTofu (Terraform-compatible) and Ansible for the b1rdh0use stack: Hetzner VPS, deSEC DNS, Caddy and Forgejo, managed as code.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
cr0w 6349ecbad1
refactor(cloud): use Ansible instead of OpenTofu
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
2026-10-29 15:09:21 -06:00
ansible refactor(cloud): use Ansible instead of OpenTofu 2026-10-29 15:09:21 -06:00
LICENSES chore: license repo under MutuaL and CC0 2026-10-27 14:54:09 -06:00
.gitignore chore: license repo under MutuaL and CC0 2026-10-27 14:54:09 -06:00
mise.toml refactor(cloud): use Ansible instead of OpenTofu 2026-10-29 15:09:21 -06:00
README.md refactor(cloud): use Ansible instead of OpenTofu 2026-10-29 15:09:21 -06:00
REUSE.toml refactor(cloud): use Ansible instead of OpenTofu 2026-10-29 15:09:21 -06:00
server-inventory.md refactor(cloud): use Ansible instead of OpenTofu 2026-10-29 15:09:21 -06:00

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
Mail 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 — rsync assumes 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.hcloud covers the cloud layer. server, primary_ip, ssh_key, firewall, firewall_resource, plus the hcloud inventory plugin that feeds the host play.
  • deSEC needs a small amount of custom work. community.dns ships providers for Hetzner, Hosttech, and Infomaniak, not deSEC. Default to a thin set of uri tasks against the deSEC REST API; reach for a third-party collection (tealk.desec, strfl89.desec_io) only if it earns the dependency.
  • --check --diff is 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)

  1. mise run provision --check --diff — dry run of cloud and DNS changes.
  2. mise run provision — create or update the server, IPs, firewall, and records.
  3. mise run converge — Ansible configures the host, idempotently.
  4. mise run deploy:<service> — build, rsync the artefact, restart the unit, health-check.
  5. 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

  1. Preserve first. Backups and a captured copy of every root-readable path currently marked BLOCKED in the inventory. No cutover before this.
  2. 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.
  3. Codify the cloud layer. A provision playbook that adopts the existing server by name and declares the firewall and DNS records.
  4. Move deploys to mise. Replace ad-hoc rsync invocations with tasks.
  5. Rehearse a rebuild. Provision a clone from repo alone; diff against the live host.
  6. 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 uri against 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.1 plus 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.