Usage¶
How to run a lab that is already built. To build one, see setup.md.
The anvil CLI runs these make targets for you (cli.md).
Placeholders: <apps-address> is the apps VM's lab address. The others are
<apps-home-address>, <domain> and <gateway-domain>.
Make targets¶
| Target | What it does |
|---|---|
make lint |
terraform fmt -check, tflint, yamllint and ansible-lint. Needs no secrets. |
make secrets-scan |
gitleaks over the git history and the working tree (tracked and untracked files, not ignored ones). Needs no secrets. |
make bootstrap-ssh |
Installs ~/.ssh/id_ed25519.pub on the Proxmox host and the adopted guests, with passwords from the Keychain. |
make grow-disk HOST=<vm> ADD=<GB> |
Grows a VM's disk by ADD GB while it runs: Terraform (only that disk may change, and only grow), the site dict, then the partition and filesystem inside the VM. CHECK=1 plans only, MOUNT= names the LVM volume. anvil increase-disk wraps it. |
make rotate SECRET=<names> |
New API keys for radarr, sonarr, prowlarr (or arr): each app makes its own, Prowlarr's links follow, then Vault and the dashboard. CHECK=1 is check mode. anvil rotate wraps it. |
make pools HOST=<machine> |
Creates the ZFS data pools declared in site.provision.machines.<machine>.pools on a provisioned host. Never changes an existing pool. WIPE=<pool> wipes old signatures on that pool's disks first. anvil pools wraps it. |
make cluster |
The Proxmox cluster: the main host creates it, site.cluster.members join, the main host's votes decide quorum. Never removes a node. anvil apply cluster wraps it. |
make forget-host HOST=<name> |
Drops one host's stored SSH host key after you rebuilt it on purpose, so the next run records the new key. Prints what it removed. HOST=switch drops the key in build/switch/known_hosts. |
make host |
Proxmox host base: repositories, packages, time, /etc/hosts, the Terraform API token, storage content types, and the Homepage token (--tags homepage). |
make host-network |
Proxmox /etc/network/interfaces, with a timed rollback. |
make enclave |
The secrets enclave on the Proxmox host: firewall, SDN apply, apt cache, NTP, cloud-init vendor data. |
make vlans |
VLANs 10, 20 and 30 on the Proxmox host: the routed segments firewall, then the gateway addresses, DHCP and NTP per VLAN. |
make switch |
The Cisco switch's ports and VLANs. A dry run that prints the commands. ARGS="-e network_switch_apply=true" applies, checks and saves. |
make ceph |
Single-node Ceph, the S3 gateway, the tfstate bucket and the S3 users. The first real run wipes the OSD disk. |
make vms |
Every play in ansible/playbooks/vms.yml: the base roles and all workloads. |
make vm HOST=<name> |
The same for one VM: frigate, netbird, ragnarok, vault or apps. |
make test-netplan HOST=<name> |
Checks that guest_base rejects a broken netplan, in a temporary root. /etc/netplan is not touched. |
make vault |
Installs and configures the Vault server. Leaves Vault sealed and refuses a restart unless allowed. |
make vault-unseal |
Unseals Vault with 3 of the 5 keys in Keychain vault-init-json. |
make vault-policies |
Writes each vault/policies/<name>.hcl that differs from Vault. |
make site-to-vault |
Copies Keychain site-bootstrap to kv/homelab/site. Refuses to overwrite a different value without ARGS=--force. |
make tf-init |
terraform init, with the S3 backend endpoint built from PVE_IP. |
make tf-plan |
Saves terraform/homelab.tfplan (mode 600) and runs the plan guard on it. |
make tf-apply |
Applies only the saved plan, after running the guard again, then deletes the plan. |
make backup |
Encrypted Terraform state, a Vault Raft snapshot and the newest Vaultwarden backup, into ~/homelab-backups. |
make vaultwarden-drill |
Restores the newest Vaultwarden backup into a temporary container on the vaultwarden VM, checks it, then removes it. |
make provision-iso NAME=<box> |
Builds the Proxmox VE automated install ISO for site.provision.machines.<box> into build/provision/<box>.iso, mode 600, or for media: pxe the netboot payload on the Proxmox host (provisioning.md). |
make provision NAME=<box> |
A dry run: the guard, read only BMC reads and every planned Redfish or IPMI call. ARGS="-e provision_confirm=<box>" installs. |
make provision-clean NAME=<box> |
Deletes the ISO and the netboot payload on the Proxmox host. Both hold the root password hash. |
make docs |
Builds this documentation into build/site with the mkdocs on your PATH. |
make docs-publish |
Builds the same site in a throwaway venv and publishes it at https://docs.<gateway-domain>. |
make cli |
Builds the anvil CLI into build/anvil (cli.md). |
Daily commands¶
The make targets load what they need from the Keychain. To run vault, terraform or
the scripts by hand, source env.sh first:
source scripts/env.sh
It exports PVE_IP, VAULT_ADDR, VAULT_CACERT, VAULT_TOKEN (the root token), the
Proxmox and S3 credentials and TF_VAR_site_json. It prints no values.
Dry-run, then apply:
make vm HOST=apps ARGS="--check --diff"
make vm HOST=apps
ARGS goes to ansible-playbook, or to terraform for the tf-* targets. Tags and
extra vars work:
make vm HOST=ragnarok ARGS="--tags iptv"
make vm HOST=apps ARGS="--check --diff --tags pihole,caddy"
make host ARGS="--tags homepage"
make vault ARGS="-e vault_server_allow_restart=true"
The tags for make vms and make vm are at the top of ansible/playbooks/vms.yml.
Adding a service behind the gateway¶
A service is one entry in site.services in Vault. From that entry:
- Caddy serves
https://<name>.<gateway-domain>. - Pi-hole answers that name with
<apps-home-address>. - Homepage shows a tile.
The fields are documented in ansible/roles/caddy/defaults/main.yml. The optional
dashboard fields (title, group, icon, description, widget) are in
ansible/roles/homepage/defaults/main.yml.
Write the change with check-and-set. A concurrent write then fails instead of getting lost:
source scripts/env.sh
cur=$(vault kv get -format=json -mount=kv homelab/site)
ver=$(jq -r .data.metadata.version <<<"$cur")
jq -c '.data.data | .site.services += [{name: "wiki", host: "hosts.ragnarok.ip", port: 8090,
scheme: "http", title: "Wiki", group: "Other", description: "Notes"}]' <<<"$cur" |
vault kv put -mount=kv -cas="$ver" homelab/site -
unset cur ver
host is a dotted path into the site dict. For a new upstream host, add it under
site.hosts in the same write. For a container on a network Caddy shares, use
container: <name> instead.
Then refresh the Keychain copy and apply:
scripts/site-to-keychain.sh --yes
make vm HOST=apps ARGS="--check --diff"
make vm HOST=apps
The run checks every entry before it touches Pi-hole. tests/test_gateway.sh and
tests/test_homepage.sh cover the rules. To take a service down but keep its entry, set
enabled: false. That removes its route and DNS record.
A site that Caddy serves itself, with no upstream, is a static entry: {name: docs,
static: docs}. static names a key of caddy_static_sites, the directory built on the
controller. A static entry takes no port, scheme or other upstream field. See
Publishing the docs.
To undo a bad write:
vault kv metadata get -mount=kv homelab/site # list the versions
vault kv rollback -mount=kv -version=<n> homelab/site # restore one
Publishing the docs¶
The docs are also served privately at https://docs.<gateway-domain>, from the LAN and
over NetBird like the other open services. After a docs change:
make docs-publish # ARGS=--check for a dry run
What it does:
- Builds the same site as the GitHub Pages workflow (
scripts/docs-build.sh, the pinneddocs/requirements.txt), in a venv underbuild/that it removes afterwards. The build date is the last commit's, so an unchanged tree builds identical files. - Runs
make vm HOST=apps ARGS="--tags docs". This copiesbuild/siteto/opt/caddy/sites/docsonapps, only when its content hash changed. - Caddy serves those files from a read-only mount (
file_server). No container restarts and nothing reloads.
The docs hold no secrets, addresses or domain.
The route is the site.services entry {name: docs, static: docs}. Pi-hole's record and
the Homepage tile (Infrastructure) come from that entry, like any other service.
Secrets in Vault¶
secrets.md lists every path, key and writer. Keep values off the command line and out of the terminal.
Write one key from the clipboard. - reads the value from stdin:
source scripts/env.sh
pbpaste | vault kv patch -mount=kv homelab/apps/vaultwarden smtp_password=-
pbcopy </dev/null # clear the clipboard
patch adds or changes one key at a path that exists. put creates a path and replaces
every key at it:
pbpaste | vault kv put -mount=kv homelab/apps/caddy cloudflare_dns_token=-
Read one key into the clipboard:
vault kv get -field=admin_token -mount=kv homelab/apps/vaultwarden | pbcopy
List what a path holds without printing values:
scripts/vault-kv-digests.sh homelab/apps/vaultwarden # "<key> <sha256>" per key
scripts/vault-kv-digests.sh homelab/apps/vaultwarden kv # the mount, kv when none is given
After you change a secret, rerun the role that uses it, for example make vm HOST=apps.
scripts/import-secrets.sh --verify compares the secrets imported from the live VMs with
Vault. It writes nothing.
Backups¶
make backup
It writes to ~/homelab-backups (directory mode 700, files 600) and keeps the newest 30
of each kind:
| File | Content |
|---|---|
tfstate-YYYYMMDD-HHMM.json.age |
Terraform state, encrypted to Keychain backup-age-recipient |
vault-YYYYMMDD-HHMM.snap |
A Vault Raft snapshot, encrypted by Vault's barrier. Restoring it needs the unseal keys. |
vaultwarden-YYYYMMDD-HHMMSS.tar.age |
The newest nightly Vaultwarden backup, copied from the Ceph bucket vaultwarden-backups |
authentik-YYYYMMDD-HHMMSS.dump.age |
The newest nightly Authentik database dump, copied from the Ceph bucket authentik-backups |
The Vaultwarden job runs on the vaultwarden VM every night at about 03:30
(details). To run it by hand and fetch the result:
ssh -J root@<proxmox-address> ubuntu@<vaultwarden-address> 'sudo systemctl start vaultwarden-backup && journalctl -u vaultwarden-backup -n 20'
make backup
The Authentik job runs on the identity VM every night at about 03:45
(details). By hand:
ssh ubuntu@<identity-address> 'sudo systemctl start authentik-backup && journalctl -u authentik-backup -n 20'
make backup
The restore drill uses the newest local copy. It must also be the newest object in the
bucket, so run make backup first:
make vaultwarden-drill
Read a Terraform state backup. It decrypts to stdout only.
scripts/backup-restore-tfstate.sh | jq .serial # newest
scripts/backup-restore-tfstate.sh ~/homelab-backups/tfstate-<YYYYMMDD-HHMM>.json.age | jq .serial
Push one back into the bucket.
source scripts/env.sh
scripts/backup-restore-tfstate.sh <file> | terraform -chdir=terraform state push -
Terraform refuses a push with a different lineage or a lower serial than the current state. To restore Vault and Vaultwarden on a new box, see setup.md.
Safety rails¶
Plan guard¶
make tf-plan and make tf-apply run scripts/plan-guard.sh on the saved plan. It
refuses a plan that:
- deletes or replaces anything
- changes the storage behind the disks of
ragnarok(the media library),vault,appsorvaultwarden(Vaultwarden's data) - errored, or is incomplete, for example because it was made with
-target
Don't pass -target in ARGS. All six VMs, and the VLAN zone with its VNets and
subnets, also carry prevent_destroy.
The saved plan holds site values, so it is mode 600. It is removed:
- at the next
tf-plan - when the guard rejects it
- after
tf-apply, whether that succeeds or fails
The guard reads the plan's JSON through a pipe, so the JSON never lands on disk.
tests/test_plan_guard.sh tests the guard.
Timed rollbacks¶
Network changes arm a rollback before they apply. They disarm it only after the Mac confirms the change.
| Change | Rollback |
|---|---|
make host-network |
The host restores its old /etc/network/interfaces after 120 s, unless it can still reach the Mac after the reload. |
make enclave |
The enclave firewall and the SDN apply each have a 120 s rollback. |
make vlans |
The firewall and the VLAN gateway addresses each have a 120 s rollback. |
The apps firewall |
ufw disables itself after 180 s, unless a fresh SSH connection and the DNS and HTTP checks from the Mac succeed first. |
| Guest netplan | Applied only with -e guest_base_netplan_apply=true, with a 120 s rollback. |
No surprise restarts¶
Restarting Vault seals it. make vault refuses any change that would restart a running
Vault: config, systemd unit or package version, including a bump of
vault_server_version. To allow it, rerun with ARGS="-e vault_server_allow_restart=true",
then run make vault-unseal. The play fails at the end if it left Vault sealed. A
certificate change only reloads Vault and does not seal it.
A normal run never restarts, re-enrolls or upgrades the NetBird client on netbird. That
VM is the emergency VPN path. Never run make host-network together with a netbird VM
change.
Scoped compose¶
Every app comes up through the compose_project role:
- It compares the config hash compose stores on each container with the rendered file.
- It runs
docker compose up -d --no-depsonly for the services that differ. - It never runs
downor removes a container. - It reports containers that are not in the file, or whose labels it cannot read, and leaves them alone.
- Check mode names the services a run would recreate.
The apps VM carries the home network's DNS. A project-wide up would recreate Pi-hole,
Vaultwarden and the gateway together. To restart something by hand, do the same: run
sudo docker compose up -d --no-deps <service> in the project directory, or
sudo docker restart <container>. For Pulse on netbird, don't run a plain
docker compose up. It recreates the locally built container every time.
Other guards¶
make cephrefuses an OSD disk that holds the root or EFI file system, is mounted, or is used by a guest, a non-Ceph LVM volume group or a ZFS pool. The full list is in Ceph.tests/test_media_untouched.shchecks that nothing inansible/orscripts/can reach the media library.- Secrets travel through pipes and stdin. They never go on argv and are never logged.
Self-hosted NetBird¶
The server runs next to NetBird's cloud service on the netbird VM
(services.md).
A client of the self-hosted network does not connect, but the dashboard loads: gRPC is not
getting through the Cloudflare tunnel. Check the hand steps in
setup.md: HTTP2 connection on the route, gRPC on in the
zone. docker logs netbird-caddy on netbird shows whether gRPC requests arrive. Until it
works, use the cloud profile.
Peers connect, but a connection that needs the relay fails (Relayed with no traffic in
netbird status -d): the relay's WebSocket may not pass the HTTP/2 route. Add a second
public hostname for the same name with path ^/relay. Give it the same service and TLS
settings, with HTTP2 connection off, and move it above the first.
Status of the routing peer on the VM:
sudo netbird status -d --daemon-addr unix:///var/run/netbird-home.sock.
Upgrading pinned versions¶
Edit the pin, dry-run, then apply. Check mode lists the containers a new image would recreate.
| What | Where | Notes |
|---|---|---|
| Container images | <role>_image in ansible/roles/<role>/defaults/main.yml (pihole, unbound, homepage, socket proxy, vaultwarden, filestash, threadfin, iptv python) |
Pinned as tag@sha256:digest. Change both. |
| Caddy | caddy_version, caddy_cloudflare_version, caddy_xcaddy_version, caddy_go_version, caddy_builder_image, caddy_base_image |
Builds a new image on ragnarok and loads it on apps. Read the Go note in the defaults before you change Go. |
| Vault | vault_server_version |
Restarts and seals Vault. Use ARGS="-e vault_server_allow_restart=true", then make vault-unseal. |
| cloudflared | cloudflared_version |
Held at that version. make vms ARGS="--tags cloudflared" upgrades one connector at a time and stops if one does not come back ready. |
| NetBird client | netbird_peer_version |
The package is held. Bumping this is the upgrade, and it restarts the client. |
| Pulse | Nothing within a major version: pulse-update deploys each new release by itself. pulse_track picks the major (or pins a tag) |
A failed release is rolled back and skipped until a newer one. Images for its Caddy, mkdocs, cosign and data copies are pulse_*_image. |
| Self-hosted NetBird | netbird_server_image, netbird_server_dashboard_image, netbird_server_caddy_image |
Pinned as tag@sha256:digest. Read NetBird's release notes first. make vm HOST=netbird ARGS="--tags netbird_server" |
| IPTV tools | iptv_streamlink_version, iptv_mpegts_version, iptv_threadfin_db_version |
make vm HOST=ragnarok ARGS="--tags iptv" |
| node_exporter | node_exporter_version |
|
| Proxmox provider | terraform/versions.tf |
make tf-init ARGS=-upgrade, then make tf-plan |
| Ubuntu cloud image | terraform/vm_vault.tf |
Don't edit it in place, or the provider replaces the download. Follow the comment at the top of the file. |
| gitleaks | .github/workflows/ci.yml, .pre-commit-config.yaml |
Keep both on the same version. |
| Lint hooks | .pre-commit-config.yaml |
pre-commit autoupdate |
Docker on the guests is not pinned. apt and unattended-upgrades keep it current.
Tests¶
CI runs make lint secrets-scan, every tests/test_*.sh and the CLI's Go tests. Locally:
make lint secrets-scan
for t in tests/test_*.sh; do "$t" || echo "FAILED: $t"; done
(cd cli && go vet ./... && go test ./...)
tests/test_vaultwarden.sh needs age and sqlite3.
gitleaks also runs in pre-commit. On top of its
default rules, .gitleaks.toml catches private IPv4 addresses,
Cloudflare tunnel tokens, NetBird setup keys, password hashes and literal passwords.
tests/test_secrets_scan.sh checks that each rule fires on generated fixtures.
Troubleshooting¶
site-source: cannot read kv/homelab/site: Vault is sealed (make vault-unseal)¶
Vault restarted, for example after a reboot or a make vault run with
vault_server_allow_restart. Run make vault-unseal.
To repair Vault itself while it is down, make vault falls back to the Keychain copy on
its own. For other targets, set HOMELAB_SITE_SOURCE=keychain.
the Keychain bootstrap copy (site-bootstrap) is stale¶
kv/homelab/site changed after the Keychain copy was made. The run used Vault. Refresh
the copy:
scripts/site-to-keychain.sh --yes
env.sh: Keychain item pve-ip (service homelab) not found¶
Add it (see setup.md), or unlock the Keychain.
hvac is missing in the Python that runs Ansible¶
Run the pip install command the message prints.
Vaultwarden: data/config.json (saved from the admin page) overrides <keys>¶
Someone saved settings on the admin page. They override the role's environment. The run warns and continues. If a key weakens the setup, the run stops before it touches the container.
Fix: open https://pass.<gateway-domain>/admin, choose Reset defaults, then rerun
make vm HOST=apps. The role never edits that file itself.
Vaultwarden: admin=429¶
Each run's admin-login check counts against Vaultwarden's limit of 3 tries per 5 minutes. Wait 5 minutes and rerun.
Homepage: WARNING: widgets with an error: [<name>=<code> error]¶
The widget's app is down, or its credential is missing or stale. 000 means no
connection. Rerun the role that owns the credential:
make host ARGS="--tags homepage" # Proxmox and Ceph widgets
make vm HOST=ragnarok ARGS="--tags homepage" # Sonarr, Radarr, Prowlarr
make vm HOST=frigate ARGS="--tags homepage" # Frigate
make vm HOST=apps # Pi-hole application password
To make widget errors fail the run, add ARGS="-e homepage_strict_widgets=true".
Pi-hole¶
The role checks that Pi-hole answers through Unbound on both addresses, and that its web UI answers. To check by hand from a home device:
dig +short @<apps-home-address> example.com # resolution through Unbound
dig +short @<apps-home-address> dns.<gateway-domain> # a gateway name
dig +short @<apps-home-address> <name>.lan.<domain> # a local record
scutil --dns | grep nameserver # the Mac uses Pi-hole first
If a gateway name does not resolve, check that its entry is in site.services and not
enabled: false, then rerun make vm HOST=apps ARGS="--tags pihole". Over NetBird,
check the nameserver entry in the dashboard and netbird status -d.
Gateway: routes not answering: [<name>=<code>]¶
000 means no TLS or no connection. Check the upstream from the Mac:
curl -sS -o /dev/null -w '%{http_code}\n' https://<name>.<gateway-domain>/
The first run on a new box waits for the wildcard certificate. That needs:
cloudflare_dns_tokeninkv/homelab/apps/caddy- public resolvers that can see the
_acme-challengerecord (site.dns.acme_resolvers)
A container upstream whose project has not run on apps yet is skipped with a warning.
The next run picks it up.
plan-guard: refusing delete/replace¶
Find out why Terraform wants to replace the resource. Usually a changed attribute forces a new one. Change the code so it doesn't. Don't work around the guard.
site.sdn.subnets.{servers,cameras,personal} need cidr ...¶
The site dict lacks keys Terraform checks. Add them in Vault (see setup.md).