38 KiB
Homelab IaC — Project Notes
Manages apps as infrastructure-as-code across three platforms — Unraid server(s), Proxmox, and a bare-metal k3s cluster — with secrets sourced from HashiCorp Vault and CI/CD via Forgejo Actions (self-hosted, on the Forgejo instance this repo deploys).
Ansible is the deployment tool on all three; what differs is the deployment primitive and who creates the host:
| Unraid | Proxmox | k3s | |
|---|---|---|---|
| Provision the host | already exists | Terraform (bpg/proxmox) creates an LXC |
already exists (4 Raspberry Pis) |
| Install the app | Ansible + compose_stack → docker compose up -d |
Ansible + lxc_app → native install + systemd |
Ansible + k3s_app → manifests into k3s's auto-deploy dir |
| App config source | common/vars.yml + ansible/unraid/vars.yml |
common/vars.yml + ansible/proxmox/vars.yml |
common/vars.yml + ansible/kubernetes/vars.yml |
| App list lives in | host_vars/<host>.yml stacks: |
host_vars/<guest>.yml apps: |
group_vars/k3s_cluster.yml k3s_apps: |
The k3s column is the newest and reached that shape late: homelab-utils
started as cluster-bootstrap-only, with no src/<app>/ entry, and the third
column was added when Authentik moved onto it (see
docs/authentik-migration.md). Two things about it don't reduce to the
table:
- The cluster itself has no
src/entry. Terraform never provisions it and it isn't an app, so it gets an inventory group (k3s_cluster) androles/k3s_node, the same way the Proxmox node getsroles/pve_backupwhile its guests getlxc_app. - Cluster services aren't apps.
k3s_metallb,k3s_monitoring,k3s_postgresandk3s_cert_managerare the cluster's own infrastructure — one hard-coded role each, config ingroup_vars. Apps go through the generick3s_appand live insrc/. Both end up asHelmChartCRs, so they look alike on disk; the distinction is whether anything would still need it if no app were deployed. See "K3s cluster (homelab-utils)" below.
Hosts
Two Unraid entries are gone from the original plan: nas2 was retired, and its
address (192.168.50.2) was reassigned to turtle-proxmox-01 — so a stale copy
of it fails by reaching the wrong machine rather than by timing out. What
remains: nas01 (Unraid, 192.168.50.1), turtle-proxmox-01/-02 (Proxmox,
.2/.3), the guests on them from the API inventory, and the four k3s Pis
(.60–.63, MetalLB pool .80–.99). Gateway is 192.168.50.254.
Proxmox LXC guests live in 192.168.50.50–.59 — Forgejo .52, shared
Postgres .54. Addresses here are assigned by hand from that band, not derived
from anything, so a new guest's ip_address is a question for whoever owns
the network rather than a value to pick.
The shared Postgres for everything outside the cluster is now the Proxmox LXC
at 192.168.50.54 — see docs/postgres-proxmox.md. The Unraid Compose
deployment under src/shared/postgres/ansible/unraid/ is kept for the
platform-shape reason every unused platform folder is kept, not because it is
running anywhere.
This repo supersedes the planning done in ../homelab/CLAUDE.md — that file
has the original decision log if you want the "why" behind the architecture
in more detail. Multi-host support, the shared-Postgres pattern, and the
Forgejo-Actions-hosts-itself bootstrap problem were decided after that doc
was written; this file and README.md are the current source of truth.
Layout
src/<app>/common/vars.yml—env_defaults:that hold regardless of where the app runs (version, ports, domain, DB name/user).src/<app>/ansible/unraid/— the Compose deployment:docker-compose.yml,.env.example, avars.ymlof Unraid-specific overrides (appdata paths, theshared-postgresDocker network alias), and optionally anicon.pngfor the Unraid Docker page.src/<app>/ansible/proxmox/— the native install:vars.ymlof Proxmox-specific overrides,install.ymlof app-specific install steps, and templates for the app's own config file and systemd unit. Two apps ship one:src/forgejo/(binary + systemd unit, the shape the role was written for) andsrc/shared/postgres/(distro packaging, so the unit comes from the package andinstall.ymlonly reconfigures the cluster).src/<app>/ansible/kubernetes/— the k3s deployment:vars.ymlof cluster-specific overrides, and one or more*.yaml.j2manifests (normally aHelmChartCR, plus anything the chart won't create — seesrc/authentik/). "kubernetes" rather than "k3s": the contents are plain Kubernetes apart from theHelmChartCR.src/<app>/terraform/— a module defining this app's LXC (template, cores, memory, disk, IP), called frombuild/config/terraform/main.tf.src/shared/<service>/— shared services (databases, etc.) used by multiple apps, same shape.build/config/ansible/— inventory, playbooks, and the roles. Three of them deploy apps, one per platform, and share the same layering andstate: present|absentcontract:compose_stack(Unraid),lxc_app(Proxmox guests),k3s_app(the cluster).compose_stackandk3s_appeach split intodeploy.yml/remove.yml, dispatched bymain.ymlon the entry'sstate:. The rest configure infrastructure rather than apps:k3s_node(bootstraps a Pi as k3s server or agent, picked by inventory group),k3s_metallb,k3s_monitoring,k3s_postgres,k3s_cert_manager(cluster services),k3s_maintenance(drain/reboot/ uncordon),unattended_upgrades, andpve_backup(the Proxmox node's backup storage and vzdump schedule).build/config/terraform/— Proxmox provider +backend "pg"state config, and the module calls saying what infrastructure exists..forgejo/workflows/— CI/CD, runs once Forgejo + a self-hosted runner exist (see "Bootstrapping" in README.md — chicken-and-egg on the first deploy).
Key decisions
- Compose files are static and reference
${VAR}; only the rendered.envchanges per deploy. Nothing secret is ever committed. - Config is layered, not duplicated:
common/vars.ymlmerged withansible/<platform>/vars.yml(platform wins), then Vault secrets on top (secrets win). Both roles do this the same way, so moving an app between platforms means writing a new platform vars file, not restating its config. - One Vault KV path per app (
homelab/<app>), fetched as a whole dict viacommunity.hashi_vault.vault_kv2_get. The prefix ishomelab/, notunraid/, because an app's secrets don't change with the platform it lands on. - Unraid draws a Compose deployment's stack row and its containers from
unrelated sources, so one committed
src/<app>/ansible/unraid/icon.pngis copied to two places. The stack row is Compose Manager serving<project dir>/icon.pngoff disk — a file or nothing, no label and no URL, and only in the maintained fork (Compose Manager Plus); on the original plugin the copy is inert. The containers get icon, WebUI link and console shell from the dockerMan template that created them, which a Compose stack doesn't have, so each service carriesnet.unraid.docker.*labels (6.10+ falls back to them). The label path is a second copy under/mnt/user/appdata/icons/rather than the project-folder one, because the webgui resolves it on every Docker page load and that shouldn't hit the flash drive. - Of those labels only the icon is role-supplied:
compose_stackinjectsSTACK_ICONunderneath both vars.yml layers, so an app can override it with a hosted URL. WebUI and shell stay literal in the compose file — the WebUI port has to be the published one and the shell is a property of the base image, so both belong next toports:andimage:rather than in a variable. Details, including where SVG does and doesn't work, in README.md → "Icons on the Unraid Docker page". - Shared Postgres is provisioned once per host; per-app DB/user creation
is idempotent via
community.postgresql, notdocker-entrypoint-initdb.d(which only runs once on an empty volume — wrong fit for a shared service apps get added to over time). The Unraid instance was pinned to 13 because its data directory predated this repo; the Proxmox one is 17 from the PGDG archive, a fresh cluster with nothing to stay compatible with. - The Proxmox shared Postgres lives on the
AppDataZFS pool with aproxmox_replicationjob to the second node, and everything is on one volume — nomount_pointsplitting PGDATA from the WAL. Unlike Forgejo's "no bind mounts", which is about whatvzdumpincludes, this is a correctness requirement: replication snapshots each of a guest's volumes independently, so two volumes replicate as two snapshots taken at different instants, and the far node can end up with a data directory and a WAL that disagree. One volume means one atomic snapshot — exactly the crash-consistent image WAL replay is designed for. It also means running out of space has one fix and not two: raisedisk_sizeon the module call (256 GiB as of this writing, up from the original 32 once Forgejo's database made the OS-sized rootfs look optimistic), never add a second disk. Cheap to do — on ZFS the resize is a refquota change applied to a running guest — but one-way, since shrinking is a replacement andprevent_destroyblocks it. Runbook indocs/postgres-proxmox.md. Two further consequences worth keeping straight: replication is configured per guest, not per pool, so a container created onAppDatawithout a job is a single point of failure that looks identical in the storage view (hence Terraform owning the job rather than a hand-runpvesr); and it's asynchronous, so a failover loses up toreplication_schedule— five minutes — of commits. It is not a backup;pve_backup's vzdump is. full_page_writes = offon the Proxmox instance, and only there. It guards against a torn page, which copy-on-write storage cannot produce, so on ZFS the full-page images are pure write amplification that also becomes replication traffic. The same setting on ext4 or xfs risks unrecoverable corruption — which is why it's inansible/proxmox/vars.ymland must never migrate tocommon/vars.yml.- Terraform provisions the guest and stops there — it has no way to deploy an app into an LXC, so Ansible picks up from the point the container exists.
- Proxmox guests keep all their state on their own rootfs; no bind mounts, no
NFS shares mounted into a container. Counter-intuitive if the goal is "back
the repos up to the NAS", but
vzdumpexcludes bind mounts by design, so a bind-mounted repo directory is the one thing missing from the archive. Everything local means one archive is a complete filesystem restore point. Getting a copy off-box is the node's job (pve_backup→ NFS storage on the NAS + a vzdump schedule), not the container's. - State an app keeps outside its container is that app's problem. Forgejo's
database is on the shared Postgres LXC — a different guest, and so a
different vzdump archive — so a systemd timer in the
container
pg_dumps it onto the container's own disk, and the vzdump archive carries both halves. The dump schedule and the vzdump window are a pair — move one, move the other. - Proxmox hosts come from the
community.proxmoxdynamic inventory plugin querying the Proxmox API, soterraform applycreating an LXC doesn't also require an inventory edit. - The
terraformtag is what marks a Proxmox guest as this repo's to manage. The dynamic inventory returns every LXC on the cluster, hand-made ones included, soinventory/proxmox.ymlfilters on that tag — both Terraform modules set it (tagsin theirvariables.tf), nothing else does. It filters rather than narrowing theproxmox_guestscondition, so an unmanaged guest is absent fromallas well and no future play can reach it by accident. The failure mode is silent in one direction: a new module that forgets the tag creates a container Ansible simply never visits, with no error anywhere — same shape as an omittedstacks:entry, and the first thing to check if a freshly-applied guest is never deployed to. - Terraform state lives in Postgres (
backend "pg"), not a local file, so CI and a workstation see the same state — specifically the CloudNativePG cluster on k3s, not the shared Postgres this configuration provisions on Proxmox. That would be circular: Terraform would need the database to exist in order to create the container the database runs in. State goes somewhere Terraform has no hand in building, which breaks the cycle outright at the cost of a dependency the rest of the repo avoids —terraform applynow needs the Pis up. Worth it over the alternative (local state, apply,init -migrate-state), which has to be got right exactly once. Bootstrap ordering isdocs/postgres-proxmox.md. - Every app keeps the same
common/+ansible/+terraform/shape even when only one platform is in use, so adding the other later doesn't require restructuring. - Removal on Unraid is explicit (
state: absenton the stack entry), never implied by deleting the entry. Ansible stores no record of the last run, so an omitted stack isn't removed — it's just never visited again, and keeps running unmanaged. The entry stays as a tombstone documenting that the app was deliberately removed. Volumes, images and the app's database are further opt-ins (remove_volumes,remove_images,remove_database) rather than being implied bystate: absent, and the Vault path is never touched — a default teardown is meant to be reversible.lxc_apphas no counterpart yet; it would need a per-appuninstall.yml. - The k3s cluster's join token is a fixed value from Vault
(
homelab/k3s-homelab-utils→K3S_TOKEN), not the onek3s servergenerates for itself on first install. Both the control-plane play and the worker play look it up independently, so a full rebuild — wipe both SD cards, reinstall — reproduces the same cluster identity instead of needing a freshly-generated token fetched off the server and redistributed by hand. Same "generate once, store it, never let a redeploy invent a new one" reasoning as Forgejo's fixed secrets, applied to cluster identity instead of app state. - The SSH key the k3s plays connect with is fetched from Vault
(
homelab/ci/ssh-k3s) byplaybooks/k3s_ssh_key.ymland written tolocal/, which bothk3s.ymlandk3s_maintenance.ymlimport as their first play — rather than the operator loading it intossh-agentfirst, as the Unraid manual flow still expects. Same arrangement CI already uses for the Unraid key, just moved into the playbook because these two are manual-only and there's no workflow to hold the fetch step. It's a separate playbook, not apre_tasksblock, because both entry points need it and neither can assume the other ran. It targetsk3s_clusterrather thanlocalhostfor a non-obvious reason: the implicit localhost isn't a member ofall, so it wouldn't inheritvault_addr/vault_kv_mount/vault_auth_methodfromgroup_vars/all.yml. Nothing in it connects to a Pi —gather_facts: falseplusdelegate_to: localhost, necessarily, since the key isn't on disk yet at that point. Falling back to a local key takes two overrides (-e k3s_ssh_key_fetch=falsealongside-e ansible_ssh_private_key_file=...): pointing at another key alone doesn't help if Vault is what's broken, because the fetch play fails before any other play runs. playbooks/k3s.ymlis deliberately not wired intodeploy.ymlor.forgejo/workflows/deploy.yml. Converging 4 physical nodes on every push is a bigger blast radius than restarting a Compose stack — the same reasoning that keepsterraform applymanual for Proxmox. Run it by hand.playbooks/k3s.yml's plays are tagged so one piece can be converged on its own —nodes,metallb,monitoring,postgres,services(those three cluster services together),upgrades,kubeconfig. Play-level tags, one per play, not per-task tags inside the roles: the plays already are the units, and tagging tasks would mean every role carrying tags for a selectivity only this playbook needs. This is the k3s equivalent ofonly_stacksfor Unraid — a cluster service isn't astacks:entry, soonly_stackscan't reach it. The SSH-key import play is taggedalwaysrather than getting a name of its own, since it's the prerequisite for anything else connecting and has to survive every--tagsfilter. Note what a tagged service run does not cover: the roles only drop manifests for k3s's controllers to reconcile, so a green playbook means the files landed, not that the workload came up.k3s_nodeis one role, not two, even though a server and an agent install differently. Both need the same OS prep (cgroups, swap), and which install path runs is a group var (k3s_node_role: server/agent, set per inventory group) rather than a second role — so the shared prep can't drift between the two nodes types the way copy-pasted tasks eventually would.- Extra
INSTALL_K3S_EXECflags split across two vars, not one:k3s_extra_args(both roles) andk3s_server_extra_args(control-plane only).k3s agentdoesn't understand server-only flags like--disableand fails to start if handed one, so a flag like--disable=servicelb(whichk3s_metallbneeds to avoid fighting k3s's bundled ServiceLB for the same IPs) can't safely live in the shared list. k3s_node's install tasks track the exec line they last installed a node with (a small marker file next to k3s's own config) and reinstall — restarting thek3s/k3s-agentservice, not the node — whenever it drifts from whatk3s_extra_args/k3s_server_extra_args/k3s_api_tls_sancurrently say, even ifk3s_versionhasn't changed. Deliberately folded into the ordinary install task rather than a separate maintenance playbook:playbooks/k3s.ymlis already manual-only and documented as safe to re-run, and unlike the rebootsk3s_maintenanceguards, bouncing the k3s process doesn't take pods down — containerd keeps them running underneath it. The worker play still runs withserial: 1so at most one node's kubelet is ever mid-restart at once.k3s server/k3s agentare installed with an explicit--node-name {{ inventory_hostname }}, not left to default to the OS hostname. This is what letsroles/k3s_maintenanceaddress a node byinventory_hostnamewhen draining/uncordoning — the k8s node object and the Ansible host are guaranteed to be the same string.- Patching a k3s Pi is split into two roles that don't know about each other
directly, only through a file:
unattended_upgradesinstalls updates hands-off but withAutomatic-Reboot "false", andk3s_maintenanceis the only thing that actually reboots a node — triggered by the presence of/var/run/reboot-required, which is the OS's own signal, not something either role invents. Splitting it this way means the risky half (taking a node out of the cluster) is exactly one thing (playbooks/k3s_maintenance.yml,serial: 1), not entangled with the routine half (installing packages), which runs unattended every day on every node without anyone thinking about it. k3s_maintenance's kubectl calls (drain,wait,uncordon) are delegated to the control-plane host and run ask3s kubectl, k3s's own bundled client, rather than requiring a kubectl install or a kubeconfig anywhere else — including on whatever eventually runs this on a schedule. This also has to work when the node currently being processed is the control plane: delegating a host to itself is just a normal SSH connection, used right up until the moment that connection reboots out from under it.k3s_monitoring(a lean kube-prometheus-stack, for OpenLens and similar tools) is installed as aHelmChartcustom resource dropped into k3s's own auto-deploying manifests directory, not run through ahelmbinary or thekubernetes.core.helmcollection — k3s ships a helm-controller that reconciles anything found there, the same mechanism it uses to install its own bundled Traefik and ServiceLB. That keeps the pattern this repo already uses everywhere else (Ansible renders a file, something else converges on it) instead of adding a second, unrelated way to reach the cluster from the controller. Grafana and Alertmanager are left disabled — OpenLens brings its own dashboards and this cluster doesn't page anyone — and the control-plane scrape targets (kubeControllerManager,kubeScheduler,kubeProxy,kubeEtcd) are disabled too, since k3s bundles those into one static binary instead of exposing them the way the chart expects; leaving them on just produces permanently-"down" targets, not a working scrape.k3s_metallb(MetalLB, L2 mode) follows the sameHelmChartCR pattern ask3s_monitoring, plus a second, plain manifest for itsIPAddressPool/L2Advertisementconfig dropped in the same auto-deploying directory — k3s's deploy controller applies any manifest it finds there, not onlyHelmChartCRs, and retries one referencing CRDs that don't exist yet rather than failing outright, so the config doesn't need to wait on the chart install finishing first. It replaces k3s's bundled ServiceLB rather than running next to it — both would otherwise hand out IPs for the sameLoadBalancerServices — sok3s_server_extra_argsininventory/group_vars/k3s_cluster.ymlcarries--disable=servicelb(server-only —k3s_extra_args, applied to both server and agent, can't carry a server-only flag like--disablewithout breaking agent installs). The IP pool (k3s_metallb_address_range, same file) has no built-in default; the role fails fast rather than silently advertising an empty pool.k3s_postgres(CloudNativePG) is the k3s cluster's own shared Postgres — a third platform forsrc/shared/postgres/, alongside Unraid and Proxmox, deployed via Helm rather than Compose or a native install. Chosen over Bitnami's postgresql-ha (repmgr+pgpool, and Bitnami's free chart/image catalog was restructured into a "legacy" repo in 2025) and the Zalando operator (Patroni-based, heavier) because it fits the sameHelmChartCR pattern already established: the operator installs as a chart CR, same shape ask3s_metallb/k3s_monitoring, and the actual cluster is a plainClusterCR manifest — same "config manifest that outlives the CRDs it references" trick ask3s_metallb'sIPAddressPool. It's a separate physical instance from the Docker-based shared/postgres (pinned to 13), but reuses the same Vault path (homelab/shared/postgres) rather than a k3s-specific one — one superuser identity for the "shared postgres" concept regardless of which platform it's running on, consistent with how every host already draws from that same path. 1 primary + 1 replica, scheduled on worker Pis only vianodeAffinity— the control plane stays free of app pods, same boundaryk3s_metallb/k3s_monitoringalready keep. HA replication only for now, no backups.- The CNPG cluster is published on the LAN through MetalLB at a pinned
k3s_postgres_loadbalancer_ip, on top of the threeClusterIPServices CNPG makes for everyCluster. Declared inside theClusterCR as a managed service (.spec.managed.services.additional,selectorType: rw) rather than as a Service manifest of this repo's own, so the operator owns the selector and the address follows a failover instead of needing to be re-pointed by hand. Pinned rather than auto-assigned because the address is meant to be written down in config elsewhere; it comes out of the bottom ofk3s_metallb_address_rangeand would need a secondautoAssign: falsepool if anything else on this cluster ever wants a fixed IP. This is what closes the reachability half of per-app DB provisioning — thecommunity.postgresqltaskscompose_stack/lxc_appuse can now reach this instance the same way they reach the Unraid one.roles/k3s_appis the other half, and uses exactly those tasks; note the asymmetry it relies on, which is easy to misread as a mistake. The provisioning runs from the Ansible controller and therefore uses the LoadBalancer address (k3s_postgres_loadbalancer_ip), becausedelegate_to: localhostis off the cluster and can't route to a ClusterIP. The app uses the in-clustershared-postgres-rwDNS name, because sending pod traffic out to the LAN and back would put MetalLB's L2 speaker in the path of every query for no benefit. Two addresses for one database, each correct for its caller. roles/k3s_appis the cluster's counterpart tocompose_stackandlxc_app: the generic role that deploys an app, driven byk3s_apps:ingroup_vars/k3s_cluster.yml, as distinct from thek3s_*roles that each deploy one cluster service. It renders whatever*.yaml.j2the app ships undersrc/<app>/ansible/kubernetes/into k3s's auto-deploying manifests directory, so adding a manifest to an app is dropping a file next to the others rather than a role change. Secrets go into a separate Kubernetes Secret rendered from Vault, never interpolated into the manifests — the same committed-config/generated-secrets split as Unraid's static compose file plus rendered.env, which is why the manifests can stay 0644 on the node while the Secret is 0600.k3s_apps:lives ingroup_vars/k3s_cluster.yml, nothost_vars/, unlike both other platforms. An app is deployed to the cluster, not to a node: the play runs againstk3s_control_planeonly because that's where the manifests directory is, which is an implementation detail of how k3s is reached rather than a statement about where the app runs.- Removal on k3s is one pass, not two.
compose_stackneeds the compose file still on disk to rundocker compose down, sostate: absentand deletingsrc/<app>/have to be separate commits (see "Removing an app" below). k3s's deploy controller tracks which resources each manifest file created, so deleting the file is the teardown andsrc/<app>/can go in the same commit. The tombstone convention still applies for the same reason as everywhere else — an entry dropped fromk3s_apps:is never visited again and keeps running unmanaged. k3s_cert_manageris the fourth cluster service and the first that exists purely for apps rather than for the cluster — nothing in the cluster needs a certificate,k3s_app's tenants do. It's still a cluster service rather than an app, on the same reasoning as the shared Postgres: oneClusterIssuerthat every app's Ingress annotates itself against, rather than each app carrying its own ACME account and DNS credentials. DNS-01, not HTTP-01, because HTTP-01 needs Let's Encrypt to reach the cluster from the internet on port 80 and this LAN deliberately isn't reachable — which has the useful side effect that a certificate can be issued before DNS points at the cluster, so a migration's TLS is settled before its cutover. The solver stanza itself has no default and the role fails fast without one: it depends on who runs the DNS, which the repo can't know. Same fail-fast-on-unset treatment ask3s_metallb_address_range.- Apps on k3s reach the outside through the Traefik k3s already bundles,
on the MetalLB address its Service holds, rather than each getting its own
LoadBalancer IP. One entrypoint and host-based routing means adding an app
costs a DNS record, not a pool address — the opposite of the choice made
for
k3s_postgres, which is pinned to its own IP precisely because Postgres isn't HTTP and can't be name-routed. k3s_traefikis the one cluster-service role that installs nothing: k3s installs Traefik itself, so the role only adjusts it, via aHelmChartConfigmerged over k3s's ownHelmChartrather than an edit to thetraefik.yamlk3s rewrites on every server start. Its scope is the dashboard, which a stock k3s 404s — Traefik still builds it, but the chart stopped shipping the router that reaches it in v28. The role restores that router on Traefik's internaltraefikentrypoint (port 9000, unpublished → port-forward only), and optionally publishes the dashboard on a hostname behind an Authentik forward-auth middleware whenk3s_traefik_dashboard_hostis set. Three things worth keeping straight: the internal route stays on even when the hostname one exists, because authenticated access depends on Authentik → CNPG → a healthy cluster, exactly what you'd open the dashboard to diagnose; the published route is anIngressRoutewith an explicit cert-managerCertificaterather than an annotatedIngress, because the dashboard isapi@internaland has no Service to point anIngressat; and the role fails fast if a hostname is set without an auth address, since the failure mode is publishing every router, service and middleware on the cluster to the LAN unauthenticated. Authentik's provider must be forward auth (domain level) — single-application mode needs/outpost.goauthentik.io/routed on the dashboard's own host, a cross-namespace service reference Traefik rejects unlessallowCrossNamespaceis on.
Adding a new app
src/<app>/common/vars.yml—env_defaults:for the portable config.- Whichever platform(s) it targets:
- Unraid:
src/<app>/ansible/unraid/withdocker-compose.yml(static,${VAR}-driven),vars.ymlof overrides,.env.exampledocumenting every var. - Proxmox:
src/<app>/ansible/proxmox/withvars.yml,install.yml, and config/systemd templates; plussrc/<app>/terraform/defining its LXC, wired intobuild/config/terraform/main.tf. - k3s:
src/<app>/ansible/kubernetes/withvars.ymland at least one*.yaml.j2manifest — normally aHelmChartCR, plus anything the chart won't create for you. Seesrc/authentik/for a worked example, including the two things a chart usually leaves out: a PVC, and secrets referenced bysecretKeyRefrather than set as values.
- Unraid:
- Populate its Vault path (
homelab/<app>) with real secret values. - Declare it on the target:
- Unraid →
host_vars/<host>.ymlstacks:list. - Proxmox →
host_vars/<guest>.ymlapps:list. - k3s →
group_vars/k3s_cluster.ymlk3s_apps:list. All three take the same entry shape (name, src, vault_path, optionaldb:, optionalstate:). Never declare the same app on more than one at once — they share a Vault path and a database, so two live deployments corrupt each other's state. Moving an app between platforms is therefore a cutover with a verification step in the middle, not an edit;docs/authentik-migration.mdis the worked example.
- Unraid →
- Leave the unused platforms' folders as README placeholders rather than deleting them — the shape is the point.
Removing an app
Unraid. Set state: absent on its stacks: entry and run
playbooks/unraid.yml — don't delete the entry, which removes nothing. Then
delete src/<app>/ in a second pass, never before: docker compose down
needs the compose file to know what it's tearing down. Full detail, including
the opt-in flags for volumes/images/database, is in README.md → "Removing an
app".
k3s. Same state: absent tombstone rule, but one pass — deleting the
manifests is the teardown, because k3s's deploy controller garbage-collects
what each file created, so src/<app>/ can go in the same commit. The
database, PVCs and Vault path deliberately survive; there's no
remove_volumes/remove_database equivalent yet, and adding one is a
"when it's actually wanted" job rather than a guess at the shape.
Proxmox. No teardown path at all — lxc_app would need a per-app
uninstall.yml, and neither app that ships an install.yml has one. Note
this is worse for the shared Postgres than for an ordinary app: tearing it
down means every other app's database as well.
K3s cluster (homelab-utils)
4 Raspberry Pis, inventory/hosts.yml → k3s_cluster (k3s_control_plane:
1 host, k3s_workers: 3). No Terraform — the Pis already exist. The cluster
itself has no src/<app>/ entry (it isn't an app), but apps now land on it:
k3s_apps: in group_vars/k3s_cluster.yml, deployed by roles/k3s_app from
src/<app>/ansible/kubernetes/. playbooks/k3s.yml is manual-only (see "Key
decisions" above); run it with:
cd build/config/ansible
ansible-playbook playbooks/k3s.yml
# or one piece of it — see "Key decisions" above for the tag list
ansible-playbook playbooks/k3s.yml --tags postgres
# apps only, or one app
ansible-playbook playbooks/k3s.yml --tags apps
ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik
--tags services covers the cluster's own infrastructure and --tags apps
what runs on top of it; neither implies the other, so "everything except
reinstalling k3s" is --tags services,apps.
Prerequisites, once, before the first run: the ansible user exists on every
Pi with NOPASSWD sudo and the public half of this repo's k3s SSH key
installed, and homelab/k3s-homelab-utils → K3S_TOKEN plus
homelab/ci/ssh-k3s → PRIVATE_KEY are set in Vault. Nothing needs setting
up on the controller beyond VAULT_ADDR/VAULT_TOKEN — the private key comes
from Vault at run time (see "Key decisions" above). Full detail in README.md →
"K3s (Raspberry Pi)" and docs/vault-secrets.md.
Rebuilding: reimage the SD card(s), redo the ansible user + SSH key
step, then re-run the playbook — the fixed Vault token means the rebuilt
node(s) rejoin the same cluster identity rather than needing a token hunted
down from a live server. Adding a fifth Pi: add it under
k3s_control_plane or k3s_workers in hosts.yml; no host_vars/ entry
needed, k3s_node_role comes from the group. This repo's k3s_node/server.yml
assumes a single control-plane node — adding a second one for HA needs
changes there, not just an inventory edit.
Patching: playbooks/k3s.yml also applies roles/unattended_upgrades to
every node, so updates install themselves daily with no login required — but
never reboot themselves (Automatic-Reboot "false"). Run
playbooks/k3s_maintenance.yml to actually apply a pending reboot: it checks
/var/run/reboot-required per node and, only where set, drains → reboots →
waits for Ready → uncordons, serial: 1 so only one node is ever down.
Manual-only for now, same as playbooks/k3s.yml — meant to eventually run on
a schedule from a self-hosted Forgejo Actions runner rather than by hand.
Metrics: playbooks/k3s.yml also applies roles/k3s_monitoring, a lean
kube-prometheus-stack (no Grafana/Alertmanager) so OpenLens and similar tools
can show node/pod metrics — see "Key decisions" above for why it's a
HelmChart CR rather than a helm install, and README.md → "Metrics
(Prometheus)" for pointing OpenLens at it.
LoadBalancer IPs: playbooks/k3s.yml also applies roles/k3s_metallb,
MetalLB in L2 mode, so type: LoadBalancer Services get real LAN IPs instead
of staying ClusterIP-only. Set the pool it hands out from —
k3s_metallb_address_range in inventory/group_vars/k3s_cluster.yml — to a
range your DHCP scope and every static assignment in hosts.yml steer clear
of before the first run; see "Key decisions" above for why it replaces
ServiceLB rather than running alongside it, and README.md → "LoadBalancer
IPs (MetalLB)" for more.
There's no node teardown path, unlike state: absent for apps — removing a
Pi today means wiping its SD card and deleting its inventory entry by hand.
Shared Postgres: playbooks/k3s.yml also applies roles/k3s_postgres,
CloudNativePG — the cluster's own shared Postgres, a third platform for
src/shared/postgres/ alongside Unraid and Proxmox. See "Key decisions"
above for why CloudNativePG and the topology, and README.md → "Postgres
(CloudNativePG)" for connecting to it. Per-app database provisioning works
now (roles/k3s_app, db: on a k3s_apps: entry) — note the two-address
asymmetry described in "Key decisions". Still no backups: HA replication
only, which protects against a dead SD card and not against a dropped table.
That gap matters more now that Authentik's database lives here rather than
inside whatever covers Unraid's /mnt/user/appdata; a CNPG ScheduledBackup
is the obvious next piece of work.
TLS: playbooks/k3s.yml also applies roles/k3s_cert_manager —
cert-manager plus one ClusterIssuer that every app's Ingress annotates
itself against. Two values have no default and the role refuses to run
without them: k3s_cert_manager_acme_email and k3s_cert_manager_solver
(the DNS-01 stanza, which depends on your DNS provider — a commented
Cloudflare example is in group_vars/k3s_cluster.yml). Use the Let's Encrypt
staging directory while working a solver out; production allows five failed
validations per hostname per hour.
Traefik dashboard: playbooks/k3s.yml also applies roles/k3s_traefik —
the only cluster-service role that installs nothing, since k3s installs
Traefik itself. Out of the box the dashboard is reachable only by
port-forward (kubectl -n kube-system port-forward deploy/traefik 9000:9000,
then http://127.0.0.1:9000/dashboard/ — trailing slash required; without
this role a stock k3s 404s there). Publishing it on a hostname behind
Authentik is one commented-out line, k3s_traefik_dashboard_host in
group_vars/k3s_cluster.yml, plus a domain-level forward-auth Proxy Provider
on the Authentik side. See "Key decisions" above and README.md → "Dashboard
(Traefik)".
Apps: k3s_apps: in group_vars/k3s_cluster.yml, deployed by
roles/k3s_app. Authentik is the first and currently only one — it moved off
a hand-made Unraid container rather than being deployed fresh, so
src/authentik/ansible/unraid/ is a README explaining why there's no compose
file there. The cutover procedure is docs/authentik-migration.md; the part
worth knowing without reading it is that the deployed AUTHENTIK_VERSION
must match the version a restored database was dumped from, because
Authentik's migrations run on startup and are one-way.