18 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 andemptty.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
systemdthroughworkstation_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
- Start Fedora WSL once and finish creating the Linux user.
- Install Ansible inside Fedora WSL.
- Run the playbook from that distribution with
--limit deadalus. - 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, the server Compose stack behind the podman-compose-server systemd unit, and firewalld. The Rocky server excludes
Syncthing. Rocky bind mounts use private SELinux relabeling for application data while host system
files remain unchanged.
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.
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 PostgreSQL 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 Navidrome, music, Nginx Proxy Manager, PostgreSQL and Gitea data. It does not
delete data, move 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. Its pool already exists: the profile only
manages child datasets and must never create, partition, destroy, roll back, or otherwise alter the
pool itself. Linux clients use NFSv4 and Windows/WSL clients use SMB; both are restricted to the
configured LAN.
For the first run, replace the Atlas placeholders and provide
vault_atlas_authorized_ssh_keys, vault_atlas_admin_password_hash, and
vault_atlas_samba_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. Enable
atlas_manage_storage only after checking the existing pool and mountpoints; enable
atlas_manage_firewall only after checking the LAN subnet and active firewalld zone.
Snapshot retention, Syncthing topology, VPN access, Prometheus 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_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
role_aegis -> profile_aegis
atlas -> profile_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_fedoraconfiguresikaros,nymph, anddeadalus.deadalusgets the Fedora development layer followed by the WSL layer.rocky_serverconfigures the Rocky 9 server,prometheus.atlasreceives the Rocky platform layer and the NAS profile through SSH.aegisreceives only the immutable Fedora IoT profile through SSH; it does not receive mutable Fedora package or common dotfile roles.- Empty
platform_voidgroups do nothing until they get a host. - The playbook never restarts the display manager during a run.
secrets/vault.ymland thensecrets/vault.local.ymlare 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.ymlcan hold shared local vault values.secrets/vault.local.ymlcan hold untracked local overrides.secrets/vault.yml.exampleis the example template.- If no vault file exists, the playbook still runs without those optional values.
secrets/.vault_pass.gpgis used when available;secrets/.vault_passis 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
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. |
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. |
npm |
Global npm packages. |
packages |
Package installation and updates. |
services |
runit and systemd services. |
tmux |
tmux configuration and plugins. |
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:
- Add it to
platform_void. - Add it to
graphical_desktop. - Use Sway, or add it to
desktop_nirifor Niri. - 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.