homelab/CLAUDE.md
Russell Seymour 1250c9cef6
Some checks are pending
deploy / deploy (push) Waiting to run
Initial checkin of code for managing homelab
2026-08-24 20:52:36 +01:00

597 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`) and
`roles/k3s_node`, the same way the Proxmox *node* gets `roles/pve_backup`
while its guests get `lxc_app`.
- **Cluster services aren't apps.** `k3s_metallb`, `k3s_monitoring`,
`k3s_postgres` and `k3s_cert_manager` are the cluster's own
infrastructure — one hard-coded role each, config in `group_vars`. Apps go
through the generic `k3s_app` and live in `src/`. Both end up as
`HelmChart` CRs, 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`, a `vars.yml` of Unraid-specific overrides (appdata paths,
the `shared-postgres` Docker network alias), and optionally an `icon.png`
for the Unraid Docker page.
- `src/<app>/ansible/proxmox/` — the native install: `vars.yml` of
Proxmox-specific overrides, `install.yml` of 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) and `src/shared/postgres/` (distro packaging, so the unit
comes from the package and `install.yml` only reconfigures the cluster).
- `src/<app>/ansible/kubernetes/` — the k3s deployment: `vars.yml` of
cluster-specific overrides, and one or more `*.yaml.j2` manifests (normally
a `HelmChart` CR, plus anything the chart won't create — see
`src/authentik/`). "kubernetes" rather than "k3s": the contents are plain
Kubernetes apart from the `HelmChart` CR.
- `src/<app>/terraform/` — a module defining this app's LXC (template, cores,
memory, disk, IP), called from `build/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 and
`state: present|absent` contract: `compose_stack` (Unraid), `lxc_app`
(Proxmox guests), `k3s_app` (the cluster). `compose_stack` and `k3s_app`
each split into `deploy.yml`/`remove.yml`, dispatched by `main.yml` on the
entry's `state:`. 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`, and `pve_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 `.env`
changes per deploy. Nothing secret is ever committed.
- Config is layered, not duplicated: `common/vars.yml` merged with
`ansible/<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 via
`community.hashi_vault.vault_kv2_get`. The prefix is `homelab/`, not
`unraid/`, 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.png` is
copied to two places. The **stack row** is Compose Manager serving
`<project dir>/icon.png` off 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 carries `net.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_stack` injects
`STACK_ICON` *underneath* 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 to `ports:` and `image:` 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`, not `docker-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 `AppData` ZFS pool with a
`proxmox_replication` job to the second node, and **everything is on one
volume** — no `mount_point` splitting PGDATA from the WAL. Unlike Forgejo's
"no bind mounts", which is about what `vzdump` includes, 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: raise `disk_size` on 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 and
`prevent_destroy` blocks it. Runbook in `docs/postgres-proxmox.md`. Two
further consequences worth keeping straight: replication is configured *per guest*, not per pool, so a
container created on `AppData` without a job is a single point of failure
that looks identical in the storage view (hence Terraform owning the job
rather than a hand-run `pvesr`); and it's asynchronous, so a failover loses
up to `replication_schedule` — five minutes — of commits. It is not a
backup; `pve_backup`'s vzdump is.
- `full_page_writes = off` on 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 in `ansible/proxmox/vars.yml` and must never
migrate to `common/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 `vzdump` excludes 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_dump`s 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.proxmox` dynamic inventory plugin
querying the Proxmox API, so `terraform apply` creating an LXC doesn't also
require an inventory edit.
- The `terraform` tag 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, so `inventory/proxmox.yml` filters on that tag — both Terraform
modules set it (`tags` in their `variables.tf`), nothing else does. It
filters rather than narrowing the `proxmox_guests` condition, so an
unmanaged guest is absent from `all` as 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 omitted `stacks:` 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 apply` now
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 is `docs/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: absent` on 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 by `state: absent`, and the Vault path is never
touched — a default teardown is meant to be reversible. `lxc_app` has no
counterpart yet; it would need a per-app `uninstall.yml`.
- The k3s cluster's join token is a fixed value from Vault
(`homelab/k3s-homelab-utils` → `K3S_TOKEN`), not the one `k3s server`
generates 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`) by `playbooks/k3s_ssh_key.yml` and written to
`local/`, which both `k3s.yml` and `k3s_maintenance.yml` import as their
first play — rather than the operator loading it into `ssh-agent` first, 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 a `pre_tasks` block, because both entry points need it and
neither can assume the other ran. It targets `k3s_cluster` rather than
`localhost` for a non-obvious reason: the implicit localhost isn't a member
of `all`, so it wouldn't inherit `vault_addr`/`vault_kv_mount`/
`vault_auth_method` from `group_vars/all.yml`. Nothing in it connects to a
Pi — `gather_facts: false` plus `delegate_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=false` alongside
`-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.yml` is deliberately **not** wired into `deploy.yml` or
`.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 keeps `terraform apply` manual 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 of
`only_stacks` for Unraid — a cluster service isn't a `stacks:` entry, so
`only_stacks` can't reach it. The SSH-key import play is tagged `always`
rather than getting a name of its own, since it's the prerequisite for
anything else connecting and has to survive every `--tags` filter. 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_node` is 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_EXEC` flags split across two vars, not one:
`k3s_extra_args` (both roles) and `k3s_server_extra_args` (control-plane
only). `k3s agent` doesn't understand server-only flags like `--disable`
and fails to start if handed one, so a flag like `--disable=servicelb`
(which `k3s_metallb` needs 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 the `k3s`/`k3s-agent` service, not the node — whenever it
drifts from what `k3s_extra_args`/`k3s_server_extra_args`/`k3s_api_tls_san`
currently say, even if `k3s_version` hasn't changed. Deliberately folded
into the ordinary install task rather than a separate maintenance
playbook: `playbooks/k3s.yml` is already manual-only and documented as
safe to re-run, and unlike the reboots `k3s_maintenance` guards, bouncing
the k3s process doesn't take pods down — containerd keeps them running
underneath it. The worker play still runs with `serial: 1` so at most one
node's kubelet is ever mid-restart at once.
- `k3s server`/`k3s agent` are installed with an explicit `--node-name
{{ inventory_hostname }}`, not left to default to the OS hostname. This is
what lets `roles/k3s_maintenance` address a node by
`inventory_hostname` when 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_upgrades` installs updates
hands-off but with `Automatic-Reboot "false"`, and `k3s_maintenance` is 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 as `k3s 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 a `HelmChart` custom resource dropped into k3s's own
auto-deploying manifests directory, not run through a `helm` binary or the
`kubernetes.core.helm` collection — 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 same `HelmChart` CR pattern as
`k3s_monitoring`, plus a second, plain manifest for its
`IPAddressPool`/`L2Advertisement` config dropped in the same
auto-deploying directory — k3s's deploy controller applies any manifest it
finds there, not only `HelmChart` CRs, 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 same `LoadBalancer` Services — so
`k3s_server_extra_args` in `inventory/group_vars/k3s_cluster.yml`
carries `--disable=servicelb` (server-only — `k3s_extra_args`, applied to
both server and agent, can't carry a server-only flag like `--disable`
without 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 for `src/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 same `HelmChart` CR
pattern already established: the operator installs as a chart CR, same
shape as `k3s_metallb`/`k3s_monitoring`, and the actual cluster is a plain
`Cluster` CR manifest — same "config manifest that outlives the CRDs it
references" trick as `k3s_metallb`'s `IPAddressPool`. 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 via `nodeAffinity` — the control plane stays
free of app pods, same boundary `k3s_metallb`/`k3s_monitoring` already
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 three `ClusterIP` Services
CNPG makes for every `Cluster`. Declared inside the `Cluster` CR 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 of
`k3s_metallb_address_range` and would need a second `autoAssign: false`
pool if anything else on this cluster ever wants a fixed IP. This is what
closes the reachability half of per-app DB provisioning — the
`community.postgresql` tasks `compose_stack`/`lxc_app` use can now reach
this instance the same way they reach the Unraid one. `roles/k3s_app` is
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`), because `delegate_to: localhost` is off
the cluster and can't route to a ClusterIP. The *app* uses the in-cluster
`shared-postgres-rw` DNS 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_app` is the cluster's counterpart to `compose_stack` and
`lxc_app`: the generic role that deploys an *app*, driven by `k3s_apps:`
in `group_vars/k3s_cluster.yml`, as distinct from the `k3s_*` roles that
each deploy one cluster service. It renders whatever `*.yaml.j2` the app
ships under `src/<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 in `group_vars/k3s_cluster.yml`, not `host_vars/`, unlike
both other platforms. An app is deployed to the cluster, not to a node:
the play runs against `k3s_control_plane` only 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_stack` needs the compose file
still on disk to run `docker compose down`, so `state: absent` and deleting
`src/<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 and `src/<app>/` can go in the same
commit. The tombstone convention still applies for the same reason as
everywhere else — an entry dropped from `k3s_apps:` is never visited again
and keeps running unmanaged.
- `k3s_cert_manager` is 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: one
`ClusterIssuer` that 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 as `k3s_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_traefik` is the one cluster-service role that installs nothing: k3s
installs Traefik itself, so the role only adjusts it, via a
`HelmChartConfig` merged over k3s's own `HelmChart` rather than an edit to
the `traefik.yaml` k3s 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 internal `traefik` entrypoint (port 9000, unpublished →
port-forward only), and optionally publishes the dashboard on a hostname
behind an Authentik forward-auth middleware when
`k3s_traefik_dashboard_host` is 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
an `IngressRoute` with an explicit cert-manager `Certificate` rather than
an annotated `Ingress`, because the dashboard is `api@internal` and has no
Service to point an `Ingress` at; 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
unless `allowCrossNamespace` is on.
## Adding a new app
1. `src/<app>/common/vars.yml` — `env_defaults:` for the portable config.
2. Whichever platform(s) it targets:
- **Unraid:** `src/<app>/ansible/unraid/` with `docker-compose.yml`
(static, `${VAR}`-driven), `vars.yml` of overrides, `.env.example`
documenting every var.
- **Proxmox:** `src/<app>/ansible/proxmox/` with `vars.yml`,
`install.yml`, and config/systemd templates; plus
`src/<app>/terraform/` defining its LXC, wired into
`build/config/terraform/main.tf`.
- **k3s:** `src/<app>/ansible/kubernetes/` with `vars.yml` and at least
one `*.yaml.j2` manifest — normally a `HelmChart` CR, plus anything the
chart won't create for you. See `src/authentik/` for a worked example,
including the two things a chart usually leaves out: a PVC, and secrets
referenced by `secretKeyRef` rather than set as values.
3. Populate its Vault path (`homelab/<app>`) with real secret values.
4. Declare it on the target:
- Unraid → `host_vars/<host>.yml` `stacks:` list.
- Proxmox → `host_vars/<guest>.yml` `apps:` list.
- k3s → `group_vars/k3s_cluster.yml` `k3s_apps:` list.
All three take the same entry shape (name, src, vault_path, optional
`db:`, optional `state:`). 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.md` is the worked example.
5. 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:
```sh
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.