- Jinja 53%
- CSS 20.3%
- HCL 11.2%
- Shell 6.5%
- Python 3.8%
- Other 5.2%
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 |
||
|---|---|---|
| cloud-init | ||
| inventory | ||
| playbooks | ||
| recovery | ||
| roles | ||
| tofu | ||
| .envrc | ||
| .gitignore | ||
| .vault_pass.sh | ||
| ansible.cfg | ||
| README.md | ||
| requirements.yml | ||
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 VMgierz-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
commonrole (XDG dirs)server_clirole (modern CLI subset for Rocky 10, upstream-binary aware)caddyrole (per-host Caddyfile templates, byte-for-byte capture of live)wireguardrole (hub-and-spoke, vault-keyed)cloudflare_dnsrole (multi-zone, records seeded fromdig; helperplaybooks/cloudflare_dump.ymlto fetch the rest)bootstraprole (creates deploy user + sudoers + authorized_keys on a fresh server)libvirt_hostrole (gateway VM definition + default network captured)headless_hostrole (closet-server mode for laptops: ignore lid, mask sleep targets, optional TLP charge limits)workstation_baserole (packages, kickstart.nvim, chsh viaansible_user_id, OpenTofu install, baseline cargo CLIs like worktrunk)rust_devrole (rustup + Rust toolchain — extracted from workstation_base, runs first so its cargo is available to baseline cargo installs)python_devrole (Python 3.13 + headers, wheel build deps, uv from upstream release)golang_devrole (Go toolchain + XDG GOPATH)fortran_devrole (gfortran + BLAS/LAPACK/FFTW + cmake/meson; MPI opt-in)docker_devrole (Docker CE upstream RPM repo + compose plugin + docker group)apptainer_devrole (Apptainer container runtime)- Prototype mount hook:
Caddyfile.gierz-gateway.j2imports/etc/caddy/conf.d/prototypes/*.caddyinside thehttp://gierz.xyzsite so prototype repos can register their ownhandle_pathblocks without main infra knowing about them act_runnerrole (Forgejo Actions runner on gierz-services, host-mode native execution, registered viavault_act_runner_registration_token; prototype repos use it for auto-deploy on push)workstation_fontsrole (Maple Mono NF)dotfilesrole (yadm bootstrap — local checkout on workstations, forge-over-HTTP on service VMs)edge_baserole (timezone, gated SSH hardening)gateway_baserole (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-hocvirt-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.ymlcapture (A/AAAA already incloudflare_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.