Homelab platform infrastructure (Ansible + OpenTofu)
  • Jinja 53%
  • CSS 20.3%
  • HCL 11.2%
  • Shell 6.5%
  • Python 3.8%
  • Other 5.2%
Find a file
Paul Gierz 05b4846349
Merge pull request 'caddy: recover the familie.gierz.eu vhosts on main' (#4) from erwin/familie-caddy-recover into main
Rehearsed diff gate by hand before merge: rendered vs deployed on both
hosts shows exactly the familie blocks, pure additions, zero removals.
Merged on Paul's explicit ack (erwin has read-only on this repo).

Signed-off-by: erwin <erwin@gierz.eu>
Co-Authored-By: LLM <noreply@invalid>
Claude-Session: https://claude.ai/code/session_01UVBwAUedQi9YJ6S9UJomMS
2026-07-08 23:00:33 +02:00
cloud-init scaffold gierz-cloud VM (family Nextcloud + OnlyOffice) 2026-06-07 21:30:55 +02:00
inventory caddy: recover the familie.gierz.eu vhosts on main 2026-07-08 18:44:39 +02:00
playbooks pi: vendor the Pi coding agent role + install it on workstations 2026-06-16 14:06:48 +02:00
recovery feat(playbooks): one-shot services data-volume migration 2026-06-02 15:22:06 +02:00
roles caddy: recover the familie.gierz.eu vhosts on main 2026-07-08 18:44:39 +02:00
tofu tofu: clone base volume + VNC + qemu+ssh for gierz-cloud/hoering-services 2026-06-16 13:51:29 +02:00
.envrc .envrc: auto-install ansible collections on cd into the repo 2026-05-13 12:05:05 +02:00
.gitignore tofu: track provider locks for gierz-cloud/hoering-services, ignore state 2026-06-16 13:44:40 +02:00
.vault_pass.sh Encrypt vault + wire pass-backed retrieval 2026-05-04 23:13:00 +02:00
ansible.cfg Encrypt vault + wire pass-backed retrieval 2026-05-04 23:13:00 +02:00
README.md dotfiles: role rename + remote_http mode for service VMs 2026-06-06 06:17:19 +02:00
requirements.yml matrix: höring tenant (Synapse + MAS) with MSC3861 delegated auth 2026-06-16 13:52:01 +02:00

Infrastructure for Paul's corner of the internet

Ansible deployment for everything Paul-flavored:

  • gierz-edge — VPS at gierz.xyz (public face)
  • gierz-gateway — home network VM
  • gierz-laptop — workstation (this repo can configure itself)

Layout

infra/
├── ansible.cfg
├── inventory/
│   ├── hosts.yml
│   ├── group_vars/
│   │   ├── all/
│   │   │   ├── vars.yml          # plaintext vars for all groups
│   │   │   └── vault.yml         # ansible-vault encrypted secrets
│   │   ├── workstations.yml
│   │   ├── edge.yml
│   │   └── gateway.yml
│   └── host_vars/
│       ├── gierz-laptop.yml
│       ├── gierz-edge.yml
│       └── gierz-gateway.yml
├── playbooks/
│   ├── site.yml          # top-level orchestrator
│   ├── workstation.yml   # laptops/desktops
│   ├── edge.yml          # public VPS
│   ├── gateway.yml       # home network VM
│   └── cloudflare.yml    # DNS records (runs against localhost)
├── roles/
│   ├── common/                   # shared baseline (XDG dirs, ...)
│   ├── server_cli/               # modern CLI subset for Rocky 10 servers
│   ├── caddy/                    # reverse proxy + TLS termination
│   ├── wireguard/                # hub-and-spoke mesh, vault-keyed
│   ├── cloudflare_dns/           # DNS records via Cloudflare API
│   ├── libvirt_host/             # laptop owns gateway VM declaratively
│   ├── workstation_base/         # packages, COPR, kickstart.nvim, baseline cargo CLIs
│   ├── rust_dev/                 # rustup + Rust toolchain (runs before workstation_base)
│   ├── python_dev/               # Python 3.13 + uv + wheel build deps
│   ├── golang_dev/               # Go toolchain + XDG GOPATH
│   ├── fortran_dev/              # gfortran + BLAS/LAPACK/FFTW + cmake/meson
│   ├── docker_dev/               # Docker CE upstream + compose plugin
│   ├── apptainer_dev/            # Apptainer (HPC container runtime)
│   ├── workstation_fonts/        # Maple Mono NF (extensible)
│   ├── dotfiles/                 # yadm bootstrap (local checkout or forge over SSH)
│   ├── edge_base/                # baseline for the public VPS
│   └── gateway_base/             # baseline for the home VM
├── cloud-init/
│   └── gateway/         # user-data + meta-data for first-boot setup
├── tofu/
│   └── gateway/         # OpenTofu module: libvirt VM provisioning
└── requirements.yml     # Ansible collection requirements

First-time bootstrap on a fresh laptop

sudo dnf install -y ansible-core git direnv
git fanclone git@code.gierz.xyz:homelab/infra.git
cd ~/code/code.gierz.xyz/homelab/infra/main
direnv allow      # runs .envrc → ansible-galaxy collection install -p ./collections

# Vault password: get it from your password manager / pass / paper
echo 'YOUR_VAULT_PASSWORD' > .vault_pass    # gitignored
chmod 600 .vault_pass

ansible-playbook -K playbooks/workstation.yml

The .envrc auto-installs collections into ./collections/ (where ansible.cfg expects them) on first cd into the repo. Without direnv, run the equivalent by hand:

ansible-galaxy collection install -r requirements.yml -p ./collections

The -K flag prompts for the BECOME password (sudo) needed by package install and shell-change tasks.

Persistent data not managed by this repo (must be restored separately):

What Where Why
Dotfiles repo ~/code/code.gierz.xyz/paul/dots/ (local) · git@code.gierz.xyz:paul/dots.git (forge) dotfiles role clones from the local checkout on workstations and from the forge over host-local HTTP on service VMs; the forge is the canonical remote
Vault password .vault_pass (gitignored) Required to decrypt secrets; lose it = lose all encrypted vault data

The gateway VM disk used to be on this list. It isn't anymore — scripts/provision-gateway.sh rebuilds it from a fresh Rocky 10 cloud image + cloud-init, so the qcow2 is fully ephemeral.

First-time bootstrap on a fresh server (edge or gateway)

The servers are designed to be ephemeral.

Gateway (libvirt VM on the laptop)

Provisioned declaratively with OpenTofu (dmacvicar/libvirt provider). Specs (4 vCPU, 6 GiB RAM, virtio disk, pinned MAC) live in tofu/gateway/main.tf; cloud-init does first-boot setup using the files in cloud-init/gateway/.

cd tofu/gateway
tofu init                # first time only — downloads the libvirt provider
tofu apply               # creates the VM (or shows a plan if it exists)
# tofu destroy           # nuke the VM to rebuild from scratch

# Wait for cloud-init to finish (~1-2 min), then converge from infra/:
cd -
ansible-playbook -K playbooks/gateway.yml

bootstrap.yml isn't needed for this path — cloud-init creates paul with sudo + key directly. The dmacvicar/libvirt provider is community-maintained (not HashiCorp official); fine for our usage but expect occasional friction with bleeding-edge libvirt versions.

Edge (Hetzner VPS)

# Initial connection is root + password (no cloud-init in this path).
ansible-playbook -u root --ask-pass playbooks/bootstrap.yml --limit gierz-edge
ansible-playbook -K playbooks/edge.yml

The role assertions will fail loudly if vault keys aren't available. For a brand-new edge or gateway (not just a re-run against a working host), you'd need new WireGuard keys generated and rotated through the entire mesh — see the wireguard role's vault.yml notes for the key-generation procedure.

XDG strictness

This setup is aggressively XDG Base Directory compliant:

  • $XDG_CONFIG_HOME~/.config
  • $XDG_DATA_HOME~/.local/share
  • $XDG_STATE_HOME~/.local/state
  • $XDG_CACHE_HOME~/.cache

Cargo, rustup, gnupg, npm, less, wget, etc. all have their state redirected into the appropriate XDG location via env vars in .zshenv. See the dotfiles repo for the canonical list.

Per-host playbook commands

# Fresh server only — creates 'paul' with sudoers + authorized_keys.
# Run once, as root, before edge.yml / gateway.yml will work.
ansible-playbook -u root --ask-pass playbooks/bootstrap.yml --limit gierz-edge

# Workstation (this laptop)
ansible-playbook -K playbooks/workstation.yml

# Edge VPS — needs SSH access to gierz.xyz as user 'paul'
ansible-playbook -K playbooks/edge.yml

# Gateway VM — needs SSH access to 192.168.122.10 as user 'paul'
ansible-playbook -K playbooks/gateway.yml

# Everything (site-wide)
ansible-playbook -K playbooks/site.yml

Status

Complete

  • Inventory + ansible.cfg
  • common role (XDG dirs)
  • server_cli role (modern CLI subset for Rocky 10, upstream-binary aware)
  • caddy role (per-host Caddyfile templates, byte-for-byte capture of live)
  • wireguard role (hub-and-spoke, vault-keyed)
  • cloudflare_dns role (multi-zone, records seeded from dig; helper playbooks/cloudflare_dump.yml to fetch the rest)
  • bootstrap role (creates deploy user + sudoers + authorized_keys on a fresh server)
  • libvirt_host role (gateway VM definition + default network captured)
  • headless_host role (closet-server mode for laptops: ignore lid, mask sleep targets, optional TLP charge limits)
  • workstation_base role (packages, kickstart.nvim, chsh via ansible_user_id, OpenTofu install, baseline cargo CLIs like worktrunk)
  • rust_dev role (rustup + Rust toolchain — extracted from workstation_base, runs first so its cargo is available to baseline cargo installs)
  • python_dev role (Python 3.13 + headers, wheel build deps, uv from upstream release)
  • golang_dev role (Go toolchain + XDG GOPATH)
  • fortran_dev role (gfortran + BLAS/LAPACK/FFTW + cmake/meson; MPI opt-in)
  • docker_dev role (Docker CE upstream RPM repo + compose plugin + docker group)
  • apptainer_dev role (Apptainer container runtime)
  • Prototype mount hook: Caddyfile.gierz-gateway.j2 imports /etc/caddy/conf.d/prototypes/*.caddy inside the http://gierz.xyz site so prototype repos can register their own handle_path blocks without main infra knowing about them
  • act_runner role (Forgejo Actions runner on gierz-services, host-mode native execution, registered via vault_act_runner_registration_token; prototype repos use it for auto-deploy on push)
  • workstation_fonts role (Maple Mono NF)
  • dotfiles role (yadm bootstrap — local checkout on workstations, forge-over-HTTP on service VMs)
  • edge_base role (timezone, gated SSH hardening)
  • gateway_base role (timezone, gated SSH hardening)
  • ansible-vault encrypted with password sourced from pass (see .vault_pass.sh)
  • Cloudflare API token in vault, applied successfully against gierz.xyz + gierz.eu zones
  • tofu/gateway/ — declarative VM provisioning via OpenTofu + cloud-init (replaces ad-hoc virt-install)
  • playbooks/cloudflare_check.yml — diagnostic for token scope/validity

Blocked on user-side prep

  • WireGuard private keys captured from live hosts into vault (needed before any destroy + rebuild of edge or gateway)
  • Live TXT/MX/proxied records still need cloudflare_dump.yml capture (A/AAAA already in cloudflare_zones)

Not yet started (need design / decisions)

  • Forge VM provisioning (gierz-forge) + GitLab role
  • DNS resolver on gateway (Unbound / AdGuard Home / Blocky)
  • Firewalld configuration once service ports are known
  • Automatic security updates (dnf-automatic)
  • Backup story for VM disk images and persistent data

Investigated, parked

  • Cosmic-comp dim-on-unfocused: not currently a feature in cosmic-comp v1 (mid-2026); only the "active window hint" (border/glow) exists. Re-open if upstream lands per-window-state opacity.

ansible-vault

Secrets (Cloudflare API token, WireGuard private keys, …) live in inventory/group_vars/all/vault.yml. The file is plaintext until you encrypt it:

# Pick a strong vault password and store it somewhere durable
# (password manager preferred). Then:
echo 'your-vault-password' > .vault_pass    # gitignored
ansible-vault encrypt inventory/group_vars/all/vault.yml \
  --vault-password-file .vault_pass

After encryption, edit secrets via ansible-vault edit (never with a plain text editor, since each save re-encrypts the whole file). Add this to ansible.cfg so playbook runs find the password automatically:

[defaults]
vault_password_file = .vault_pass

(The path is repo-root-relative.) Vault values are referenced from non-vault vars files via the vault_ prefix convention — keeps secrets greppable and the diff between encrypted and plaintext clean.

SSH hardening note

edge_base and gateway_base ship with SSH hardening off by default (edge_ssh_harden: false / gateway_ssh_harden: false). Hardening disables root login + password auth — flip the toggle in inventory/group_vars/{edge,gateway}.yml only after confirming key-based access works, otherwise you'll lock yourself out.