Files
infra/README.md
2026-09-13 00:01:48 +02:00

24 KiB

Infra — Personal Infrastructure as Code

Italian version: README.it.md

This is my Ansible repo for keeping my personal machines and dotfiles in sync. It is the source of truth for packages, services, and user configuration. The setup is meant to stay modular, reproducible, and idempotent without getting too clever.

Layout

infra/
├── ansible/
│   ├── site.yml
│   ├── inventory/
│   │   ├── hosts.yml
│   │   ├── group_vars/
│   │   └── host_vars/
│   ├── templates/
│   └── roles/
├── dotfiles/
│   ├── common/
│   ├── desktop/
│   ├── fedora/
│   ├── server/
│   ├── workstation/
│   ├── workstation_dev_wsl/
│   └── nymph/
├── scripts/
├── secrets/
├── README.md
└── README.it.md
  • ansible/ holds provisioning and host configuration.
  • dotfiles/ holds versioned user configuration.

Managed machines

The repo currently covers Fedora/GNOME desktops, one Fedora WSL workstation, a Fedora IoT LAN node, a Rocky Linux 9 server, and a Rocky Linux 9 NAS. Configuration is layered instead of being tied to host names:

common user environment
+ platform-specific setup
+ role-specific software
+ independently selected desktop
+ host overrides
Host Platform Role Desktop
ikaros Fedora Personal workstation GNOME
nymph Fedora Desktop laptop GNOME
deadalus Fedora WSL Development workstation —
aegis Fedora IoT Always-on LAN node —
prometheus Rocky Linux Server —
atlas Rocky Linux NAS —
ikaros must be boring
nymph is allowed to break

ikaros is the stable personal Fedora/GNOME desktop. nymph is the laptop and gets the same shared desktop dotfiles while GNOME itself stays close to the Fedora defaults. The legacy void and desktop groups are compatibility parents; the main axes are platform_*, role_*, and desktop_*.

Desktop profiles

  • ikaros: stable Fedora Workstation + GNOME desktop.
  • nymph: Fedora Workstation + GNOME laptop.
  • Void desktops stay available as reusable future profiles through platform_void + graphical_desktop.

Void uses desktop_environment: minimal. Sway is the normal session; add a host to desktop_niri to select Niri. GNOME is only handled on Fedora through desktop_gnome.

The desktop setup includes shared desktop dotfiles, Sway/Niri support for future Void hosts, emptty, turnstile user services, a stable ssh-agent socket at ~/.local/state/ssh-agent/socket, Emacs authoring config, tmux bootstrapped through TPM, Flatpak, GNOME Keyring, Udiskie, and kanshi for Sway multi-monitor setups.

Void package buckets stay separate on purpose:

  • void_packages_base: system runtime and services.
  • desktop_common_packages: shared GUI infrastructure.
  • desktop_minimal_packages: GTK applications and emptty.
  • desktop_sway_packages: Sway-only binaries.

Workstation

deadalus is the only workstation target. It is Fedora running in WSL on the Windows machine with the same name. Flatpak and Snap are explicitly kept out of this profile.

The workstation receives two layers:

  • Fedora development setup through workstation_dev_fedora.
  • WSL setup with systemd through workstation_dev_wsl.

That gives it Fedora packages through DNF, Docker from the official repository, Mise from its official COPR repository with a pinned Eclipse Temurin Java 11 JDK, shared workstation dotfiles and templates, tmux helpers, and WSL systemd configuration. Windows applications are installed manually; the WSL profile does not manage Python remoting components for them.

WSL workflow

  1. Start Fedora WSL once and finish creating the Linux user.
  2. Install Ansible inside Fedora WSL.
  3. Run the playbook from that distribution with --limit deadalus.
  4. Use Windows-side VS Code with Remote WSL, Remote SSH, and Dev Containers if wanted.

Server

prometheus is the Rocky Linux 9 server. It has no graphical environment and gets server-specific dotfiles and templates. The profile provisions configuration only: it does not transfer data, start the Compose stack, update DNS, or perform a cutover.

The server profile installs platform-specific packages, Podman and podman-compose, declared systemd services, and firewalld. The manually activated podman-compose-server unit contains the existing Nginx Proxy Manager and Gitea services. The desired Compose file no longer includes Navidrome, Syncthing, or the obsolete Navidrome PostgreSQL database. Navidrome and Syncthing belong to Atlas; official Navidrome uses SQLite instead. Applying the profile does not stop or remove legacy containers and does not delete /opt/postgres/data.

Firewalld enables SSH, Cockpit (9090/tcp), HTTP and HTTPS. Nginx Proxy Manager publishes only 80/tcp and 443/tcp; its administration interface is bound to 127.0.0.1:81 and can be reached from Ikaros or Nymph with the npm-tunnel Bash alias. Nextcloud remains disabled and the profile does not provision any /srv/nextcloud directories.

The Atlas phase-one work does not change this NPM deployment or its persistent data. Once WireGuard and the Atlas services are active, configure the current NPM proxy hosts with Navidrome upstream http://10.0.0.2:4533 and Syncthing GUI upstream http://10.0.0.2:8384. Only the Syncthing web GUI uses NPM; synchronization traffic remains on explicitly published native ports bound only to the Atlas WireGuard address. Configure both Syncthing authentication and an appropriate NPM access policy before publishing its GUI.

Server identity comes from server_username, server_user_group, and server_user_home in ansible/inventory/group_vars/server.yml. server_username defaults to username, but it can be overridden, for example:

ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser
ansible-playbook ansible/site.yml --limit prometheus \
  -e server_username=myuser -e server_user_group=mygroup \
  -e server_user_home=/srv/myuser

The target must already provide server_username with local sudo access.

DuckDNS

profile_server renders ~/duckdns/duck.sh with mode 0700, keeping the existing updater path and duck.log. Set server_duckdns_domain in the server's host vars and store the rotated vault_duckdns_token in encrypted secrets/vault.yml (using ansible-vault edit secrets/vault.yml) or untracked secrets/vault.local.yml. Never commit the rendered script or put the token on a command line. Rendering hides secret output/diffs; the updater verifies TLS and passes the token to curl through stdin. The playbook neither runs the updater nor changes its external schedule.

ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns

An exposed token must be revoked/regenerated on DuckDNS: deleting it from Git history does not revoke it. After a history cleanup, re-clone other checkouts rather than merging the old history back in; preserve any uncommitted work separately without copying secrets.

Data migration

Provision Rocky first, then run the migration script on the retired Ubuntu source host. It is dry-run by default and requires an explicit source-stack stop before it can copy application data:

sudo ./scripts/migrate_prometheus_data.sh \
  --destination rocky@179.237.102.172 \
  --identity /root/.ssh/id_ed25519

sudo ./scripts/migrate_prometheus_data.sh \
  --destination rocky@179.237.102.172 \
  --identity /root/.ssh/id_ed25519 \
  --quiesce-source --execute

The script copies only Nginx Proxy Manager and Gitea data. It does not delete data, move Navidrome/Syncthing, copy /home/git/.ssh, start containers, update DNS, or perform a cutover. The destination SSH host key must already be trusted and the destination account needs passwordless sudo for rsync. It preserves ACLs but not extended attributes, so source SELinux labels are not transferred; the Rocky Compose bind mounts apply their own :Z labels when containers start.

DNS Filter

aegis is a Raspberry Pi 4 running Fedora IoT. Generate Ignition from ansible/bootstrap/aegis.bu with the included Podman/Butane helper, then write the SD card with arm-image-installer:

ansible/bootstrap/generate-aegis-ign.sh --write IMAGE DEVICE

The controller manages it remotely as pi@aegis; unlike local desktop profiles, Aegis is intentionally an SSH inventory target. profile_aegis manages rootful Podman Quadlets for AdGuard Home and iCloudPD, persistent data under /var/lib, the Podman auto-update timer, LAN-restricted firewalld rules, SSH key-only access for pi, and wake-ikaros. Set the host-local aegis_lan_subnet and aegis_adguard_web_port values before applying it. The playbook permits AdGuard Home HTTP on port 80; the initial wizard port 3000 is intentionally unmanaged and must be opened and closed manually during initial setup. The profile disables the local systemd-resolved DNS stub and points /etc/resolv.conf to its full resolver data, freeing port 53 for AdGuard while retaining DNS learned from the router. Define vault_aegis_icloudpd_apple_id in Vault before applying it. iCloudPD still requires interactive MFA initialization after its first deployment.

Validate the profile before deployment:

ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit aegis --check --diff --ask-become-pass

NAS

atlas is a Rocky Linux 9 NAS reached through SSH. Normally its pool already exists and the profile only manages child datasets. A one-time RAIDZ2 bootstrap is available only with explicit confirmation (atlas_create_pool=true) and exactly four verified /dev/disk/by-id/... paths in atlas_zpool_disks. It never partitions, forces, destroys, rolls back, or changes the vdev layout of an existing pool. Linux clients use NFSv4 and Windows/WSL clients use SMB; both are restricted to the configured LAN.

For the first run, provide vault_atlas_authorized_ssh_keys, vault_atlas_admin_password_hash, vault_atlas_samba_password, and vault_atlas_immich_db_password. Bootstrap the host through its existing administrator:

ansible-playbook ansible/site.yml --limit atlas \
  -e atlas_connection_username=<existing-admin>

vault_atlas_admin_password_hash must be an /etc/shadow-compatible hash, not a clear-text Cockpit password. Subsequent runs use atlas_admin_username. Atlas declares storage, sharing, and its LAN firewall rules enabled. Before the first apply, check the existing pool and mountpoints, LAN subnet, and active firewalld zone. atlas_manage_media_stack remains disabled until /dev/dri, the container paths, and the Immich database secret are validated.

With storage management enabled, Atlas creates the complete dataset hierarchy below the existing or explicitly bootstrapped zpool: work, archive, archive/app_data, the separate archive/app_data/navidrome and archive/app_data/syncthing application datasets, media, media/music, media/photobook, backups, backups/services, and backup_prometheus. Application/archive datasets use zstd, while media, Syncthing and service-backup datasets use lz4; backups/services also has a 500G refreservation. Atlas enforces targeted SELinux persistently and reports, without initiating, any reboot required to activate it. It assigns its primary LAN interface explicitly to the managed firewalld zone and applies persistent kernel network hardening: redirects and source routes are rejected, martians logged, reverse-path filtering remains loose for WireGuard, and IPv4 forwarding is disabled. SSH permits only the declared administrator using public-key authentication; root login, passwords, agent and remote forwarding are disabled, while local forwarding remains available for private administrative tunnels. SMB3 exposes Archive only to the configured Vault-backed Samba accounts on encrypted, signed SMB3 over TCP/445 only and admits the configured LAN without host-specific exclusions. NFSv4 exports only media/photobook to the configured Aegis IP over TCP/2049, using all_squash with anonymous UID/GID 1100.

The immich system account is fixed to UID/GID 1100, has no login shell or wheel membership, and receives video and render access. The rootful Immich Server, ML, Redis-compatible cache, PostgreSQL, and NPM Quadlets share one Podman network. Immich runs as 1100:1100; Server and ML receive /dev/dri, and Photobook is mounted read-only at /external/photobook. NPM publishes ports 80 and 443; its administration interface remains restricted to 127.0.0.1:81 for SSH-tunnel access.

Phase 1 is limited to rootless Navidrome and Syncthing user Quadlets on Atlas. It is enabled in the Atlas host configuration and can be set to false only for a deliberate suspension. Official Navidrome 0.63.2 uses its SQLite database below /data; it does not support ND_DATABASE_URL or an external PostgreSQL backend. The obsolete navidromedb service was therefore removed from Prometheus instead of being reproduced on Atlas. The role derives all storage paths from the zpool mounted at /zpool: music is read-only at /zpool/media/music, Navidrome application state and navidrome.db are stored at /zpool/archive/app_data/navidrome, and Syncthing persists at /zpool/archive/app_data/syncthing. profile_atlas creates these datasets when atlas_manage_storage is enabled; the backend role verifies their exact mountpoints before starting containers. The backend role never creates the pool. The separate wireguard_overlay role manages wg0 between Prometheus (10.0.0.1) and Atlas (10.0.0.2), generating private keys once on their respective hosts and exchanging only public keys through Ansible. Prometheus alone opens 51820/udp publicly. Backend ports are admitted only in the WireGuard firewalld zone.

backend_phase1_start_services stays false during the application-state transfer, so the first real backend run renders the Quadlets without creating an empty Atlas database. After stopping Navidrome on Prometheus, copy the complete /opt/navidrome/data/ directory into /zpool/archive/app_data/navidrome/, preserving navidrome.db and any SQLite sidecar files. Then set this variable to true and rerun the role to enable and start Navidrome and Syncthing. The playbook never copies or deletes application data.

Validate and render the Atlas services with:

ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags storage

ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus,atlas --tags wireguard

ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff

ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1

For the cutover, stop the old Navidrome writer before copying its data directory, verify ownership by the Atlas admin account and confirm that the copied SQLite database is present before changing backend_phase1_start_services: true in host_vars/atlas.yml. Keep the source data and the stopped legacy navidromedb container until Navidrome on Atlas and a restore test have been validated.

Snapshot retention, Syncthing topology, WireGuard/firewall validation, Prometheus backup pulls, encrypted Borg backups to a Hetzner Storage Box, USB backup, monitoring, and disaster-recovery tests remain follow-up work. The detailed operational backlog is kept in AGENTS.md.

How layering works

A host can intentionally belong to more than one inventory group. The final configuration is the combination of the host and its groups, not a one-host/one-play mapping.

common configuration
+ platform configuration
+ role configuration
+ desktop configuration
+ host overrides

Current examples:

ikaros   -> common + platform_fedora + role_personal_workstation + graphical_desktop + desktop_gnome + ikaros
nymph    -> common + platform_fedora + graphical_desktop + desktop_gnome + nymph
deadalus -> common + platform_fedora + workstation_dev_fedora + workstation_dev_wsl + deadalus

This keeps shared configuration reusable, lets host overrides stay small, and leaves the Void desktop profile ready for a future host using platform_void + graphical_desktop + desktop_sway.

Emacs is enabled on Fedora/GNOME and workstation profiles. dotfiles_common deploys the canonical authoring setup, including ~/Org/, versioned templates, and PDF/HTML/Markdown/DOCX/ODT export support. To turn it on temporarily elsewhere:

ansible-playbook ansible/site.yml --limit <host> --tags emacs -e emacs_enabled=true

AI coding agents

The shared npm-managed agents are OpenCode, Claude Code, Codex, Gemini CLI, and GitHub Copilot; IBM Bob is also managed on deadalus. Each agent has its own lifecycle flags in ansible/inventory/group_vars/all.yml, so one agent can be installed, configured, or removed without affecting the others:

ai_agents:
  opencode:
    npm_package: opencode-ai
    install_enabled: true
    deploy_enabled: true
    uninstall_enabled: false

Installation uses the npm latest state; deployment copies/renders only the configuration belonging to each enabled agent. Removal deletes only the selected managed npm package or, for IBM Bob, /usr/local/bin/bob; it preserves dotfiles, instructions, credentials, and user data. Installation and removal are mutually exclusive per agent: the playbook fails before making changes when both flags are true for the same agent. Servers set ai_agents: {} and therefore manage none.

Run a focused dry run with:

ansible-playbook ansible/site.yml --limit ikaros --tags ai_agents --check --diff
ansible-playbook ansible/site.yml --limit deadalus --tags ai_agents --check --diff

To preview removal, set install_enabled: false and uninstall_enabled: true only in the entry for the agent being removed, then run:

ansible-playbook ansible/site.yml --limit deadalus --tags ai_agents --check --diff

Main roles

Role What it does
packages_void Installs packages on Void.
packages_fedora Installs packages on Fedora.
packages_rocky Installs packages on Rocky Linux 9.
services_runit Manages runit services.
services_systemd Manages systemd services.
profile_desktop_common Shared Void desktop bootstrap.
profile_desktop_gnome Shared Fedora/GNOME desktop dotfiles.
profile_desktop_sway Sway / SwayFX Wayland session.
profile_desktop_niri Niri Wayland session on Void.
profile_desktop_host Host-specific desktop overrides.
profile_personal_workstation Stable personal-workstation layer.
profile_workstation_dev_common Shared workstation development setup.
profile_workstation_dev_wsl WSL development setup.
profile_server Server setup.
profile_atlas Rocky Linux 9 NAS setup.
profile_backend_phase1 Rootless Navidrome and Syncthing on Atlas.
wireguard_overlay Prometheus/Atlas WireGuard overlay.
profile_aegis Fedora IoT always-on LAN node.
dotfiles_common Shared user dotfiles.

What site.yml runs

all except platform_rocky -> dotfiles_common
platform_void -> packages_void + services_runit
platform_void & graphical_desktop -> profile_desktop_common + profile_desktop_sway + profile_desktop_niri + profile_desktop_host
platform_fedora -> packages_fedora + services_systemd
platform_rocky -> packages_rocky + services_systemd
wireguard_overlay -> wireguard_overlay (after platform_rocky)
role_aegis -> profile_aegis
atlas -> profile_atlas
role_backend_phase1 -> profile_backend_phase1 (after atlas)
rocky_server -> dotfiles_common + profile_server (after platform_rocky)
platform_fedora & role_personal_workstation -> profile_personal_workstation
platform_fedora & desktop_gnome -> profile_desktop_gnome
workstation_dev_fedora -> profile_workstation_dev_common
workstation_dev_wsl -> profile_workstation_dev_wsl (after platform_fedora + workstation_dev_fedora)

So, in practice:

  • platform_fedora configures ikaros, nymph, and deadalus.
  • deadalus gets the Fedora development layer followed by the WSL layer.
  • rocky_server configures the Rocky 9 server, prometheus.
  • atlas receives the Rocky platform layer and the NAS profile through SSH.
  • aegis receives only the immutable Fedora IoT profile through SSH; it does not receive mutable Fedora package or common dotfile roles.
  • Empty platform_void groups do nothing until they get a host.
  • The playbook never restarts the display manager during a run.
  • secrets/vault.yml and then secrets/vault.local.yml are loaded only when present.

Requirements

You will need Python 3, Ansible, ansible-lint, yamllint, shellcheck, and the collections in ansible/collections/requirements.yml.

python3 -m pip install ansible ansible-lint yamllint shellcheck-py
ansible-galaxy collection install -r ansible/collections/requirements.yml

Secrets are optional:

  • secrets/vault.yml can hold shared local vault values.
  • secrets/vault.local.yml can hold untracked local overrides.
  • secrets/vault.yml.example is the example template.
  • If no vault file exists, the playbook still runs without those optional values.
  • secrets/.vault_pass.gpg is used when available; secrets/.vault_pass is a legacy local fallback. Without either one, Ansible asks for the password interactively.

Running it

Run the whole playbook:

ansible-playbook ansible/site.yml

Useful checks before applying changes:

ansible-playbook ansible/site.yml --syntax-check
ansible-playbook ansible/site.yml --limit ikaros,nymph --check --diff
ansible-playbook ansible/site.yml --limit ikaros --check --diff
ansible-playbook ansible/site.yml --limit nymph --check --diff
ansible-playbook ansible/site.yml --limit deadalus --check --diff
ansible-playbook ansible/site.yml --limit prometheus --check --diff
ansible-playbook ansible/site.yml --limit atlas --check --diff
ansible-playbook ansible/site.yml --limit aegis --check --diff
ansible-lint ansible/site.yml
ansible-lint ansible/roles
yamllint ansible/

For focused checks:

ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --diff
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml
podman-compose -f /opt/docker/server/docker-compose.yml config
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff

Tags

Use Ansible as the source of truth for the current tag list:

ansible-playbook ansible/site.yml --list-tags
Tag Main scope
always Common pre-tasks, including optional vault loading.
ai_agents AI coding-agent install, configuration deployment, and managed-binary removal.
atlas Atlas NAS account, storage, sharing, and container configuration.
backend_phase1 Rootless Atlas Navidrome and Syncthing Quadlets.
containers Rootful Atlas Quadlets.
dotfiles User configuration across all profiles.
dotfiles:common Shared dotfiles.
dotfiles:desktop Void and Fedora/GNOME desktop dotfiles.
dotfiles:host Host-specific Void desktop overrides.
dotfiles:server Server dotfiles.
dotfiles:workstation Personal workstation and WSL dotfiles.
emacs Shared Emacs setup and authoring dependencies.
gnome Fedora/GNOME desktop configuration.
immich Atlas Immich account and Quadlets.
npm Global npm packages.
packages Package installation and updates.
podman Podman Compose and rootless Quadlet integration.
services runit and systemd services.
sharing Atlas NFSv4 and SMB3 configuration.
storage Atlas child ZFS datasets.
tmux tmux configuration and plugins.
wireguard Prometheus/Atlas WireGuard overlay.
wsl WSL bootstrap and configuration.

Bootstrapping a new machine

git clone <repo>
cd <repo-dir>
ansible-galaxy collection install -r ansible/collections/requirements.yml
ansible-playbook ansible/site.yml

For a future Void desktop host:

  1. Add it to platform_void.
  2. Add it to graphical_desktop.
  3. Use Sway, or add it to desktop_niri for Niri.
  4. Put hardware-specific details in host_vars/<host>.yml.

The legacy void and desktop groups remain compatibility parents, so hosts in platform_void and graphical_desktop still receive the existing Void and desktop variables.