966 lines
48 KiB
Markdown
966 lines
48 KiB
Markdown
# Homelab IaC
|
|
|
|
Deploy apps as code across two platforms: Unraid server(s), where apps run as
|
|
Docker Compose stacks, and Proxmox, where Terraform creates an LXC and the
|
|
app is installed into it natively. Ansible is the deployment tool on both.
|
|
Secrets are pulled from HashiCorp Vault at deploy time; CI/CD is Forgejo
|
|
Actions once Forgejo itself is running.
|
|
|
|
There's also a third piece of infrastructure alongside those two: a bare-metal
|
|
k3s cluster (`homelab-utils`) across 4 Raspberry Pis, bootstrapped by Ansible
|
|
too. It doesn't fit the `src/<app>/` table above the way Unraid/Proxmox apps
|
|
do — Terraform never provisions it, and no `stacks:`/`apps:` list drives
|
|
what's on it — so it gets its own inventory group and role instead. It does
|
|
run shared services deployed via Helm, though: `k3s_monitoring`, `k3s_metallb`,
|
|
and the cluster's own shared Postgres (`k3s_postgres`, a third platform for
|
|
`src/shared/postgres/`). See `playbooks/k3s.yml` and "K3s (Raspberry Pi)"
|
|
below.
|
|
|
|
See `CLAUDE.md` for the short version of the architecture decisions.
|
|
|
|
## Layout
|
|
|
|
```
|
|
src/
|
|
<app>/
|
|
common/vars.yml config that holds on either platform
|
|
ansible/
|
|
unraid/ docker-compose.yml + .env.example + platform overrides
|
|
(+ optional icon.png for the Unraid Docker page)
|
|
proxmox/ install.yml + config/systemd templates + platform overrides
|
|
kubernetes/ vars.yml + *.yaml.j2 manifests (normally a HelmChart CR)
|
|
terraform/ this app's LXC definition (Proxmox only)
|
|
shared/<service>/ same shape, for services multiple apps use
|
|
build/config/
|
|
ansible/
|
|
inventory/
|
|
hosts.yml static: unraid_servers, proxmox_nodes, k3s_cluster
|
|
proxmox.yml dynamic: proxmox_guests, from the Proxmox API
|
|
group_vars/all.yml Vault connection, Compose Manager project path
|
|
group_vars/k3s_*.yml k3s cluster name/version, per-role vars (server/agent)
|
|
host_vars/<host>.yml which stacks/apps deploy there
|
|
(k3s apps are in group_vars/k3s_cluster.yml instead)
|
|
roles/compose_stack/ Unraid: sync compose + render .env + provision DB + up
|
|
(deploy.yml / remove.yml, picked by the entry's state:)
|
|
roles/lxc_app/ Proxmox: install natively + render config + systemd
|
|
roles/k3s_app/ k3s: render app manifests + Secret + provision DB
|
|
(deploy.yml / remove.yml, picked by the entry's state:)
|
|
roles/pve_backup/ Proxmox node: NAS backup storage + vzdump schedule
|
|
roles/k3s_node/ k3s Pi: cgroups/swap prep + install server or agent
|
|
roles/k3s_monitoring/ k3s control plane: lean kube-prometheus-stack via HelmChart CR
|
|
roles/k3s_metallb/ k3s control plane: MetalLB (LoadBalancer IPs) via HelmChart CR
|
|
roles/k3s_postgres/ k3s control plane: shared Postgres (CloudNativePG) via HelmChart CR
|
|
roles/k3s_cert_manager/ k3s control plane: cert-manager + ClusterIssuer via HelmChart CR
|
|
roles/unattended_upgrades/ any apt host: hands-off patching, no auto-reboot
|
|
roles/k3s_maintenance/ k3s Pi: drain + reboot-if-required + uncordon, one at a time
|
|
playbooks/
|
|
deploy.yml everything (Unraid + Proxmox — not k3s, see below)
|
|
unraid.yml just Unraid
|
|
proxmox.yml just the Proxmox guests
|
|
pve_host.yml just the Proxmox node (backups)
|
|
k3s.yml bootstraps the k3s cluster (manual-only, not in deploy.yml)
|
|
k3s_maintenance.yml rolls pending reboots across it, serial: 1 (manual-only)
|
|
terraform/ Proxmox provider, pg state backend, LXC module calls
|
|
.forgejo/workflows/ CI/CD (needs a self-hosted runner, see below)
|
|
docs/vault-secrets.md Vault path/key reference
|
|
docs/authentik-migration.md hand-run cutover: Authentik from Unraid to k3s
|
|
```
|
|
|
|
Terraform's scope stops at the guest — it creates the LXC and nothing more,
|
|
because the Proxmox provider has no way to install an application. Ansible
|
|
picks up from there, which is why it's the common tool and Terraform is
|
|
Proxmox-only.
|
|
|
|
## How config is layered
|
|
|
|
An app's settings are split by whether they survive a change of platform:
|
|
|
|
- `src/<app>/common/vars.yml` — version, ports, domain, database name/user.
|
|
- `src/<app>/ansible/<platform>/vars.yml` — the rest. Data paths differ
|
|
(`/mnt/user/appdata/forgejo` vs `/var/lib/forgejo`), and so does how the
|
|
database is reached (the `shared-postgres` Docker network alias only exists
|
|
on Unraid).
|
|
|
|
Both roles merge them the same way: common, then platform overrides, then
|
|
Vault secrets last. Nothing is restated between platforms.
|
|
|
|
## How a deploy works
|
|
|
|
**Unraid.** For each stack in a host's `stacks:` list, `compose_stack`:
|
|
|
|
1. Copies `src/<stack>/ansible/unraid/docker-compose.yml` to the host's
|
|
Compose Manager project folder, unmodified — it's static and only
|
|
references `${VAR}`.
|
|
2. Merges the two `vars.yml` layers with the stack's Vault KV path and
|
|
renders the project's `.env`. Nothing secret ever touches git.
|
|
3. If the stack declares a `db:` block, ensures its database/role exist on
|
|
the shared Postgres instance (idempotent — safe to add new apps to the
|
|
shared service over time without touching existing data).
|
|
4. Copies `icon.png`, if the stack ships one, to both the project folder and
|
|
`/mnt/user/appdata/icons/` (see "Icons on the Unraid Docker page" below).
|
|
5. Runs `docker compose up -d` for the stack.
|
|
|
|
**Proxmox.** `terraform apply` creates the LXC first. Then, for each app in a
|
|
guest's `apps:` list, `lxc_app` does the same config merge and database
|
|
provisioning, runs that app's `ansible/proxmox/install.yml`, and manages its
|
|
systemd unit instead of a Compose project.
|
|
|
|
**k3s.** For each app in `k3s_apps:` (`group_vars/k3s_cluster.yml`, not
|
|
host_vars — an app belongs to the cluster, not a node), `k3s_app`:
|
|
|
|
1. Does the same two-layer config merge, using
|
|
`src/<app>/ansible/kubernetes/vars.yml` as the platform layer.
|
|
2. Provisions the app's database/role on the CloudNativePG cluster if it
|
|
declares a `db:` block — the same `community.postgresql` tasks the other
|
|
two platforms use, pointed at MetalLB's LoadBalancer address for the CNPG
|
|
primary.
|
|
3. Renders the app's Vault path into a Kubernetes `Secret` (plus the
|
|
`Namespace`), keys passed through verbatim.
|
|
4. Renders every `*.yaml.j2` the app ships into
|
|
`/var/lib/rancher/k3s/server/manifests/`, where k3s's own deploy and helm
|
|
controllers pick them up.
|
|
|
|
Nothing here talks to the Kubernetes API — no `helm`, no kubeconfig, no
|
|
`kubernetes.core`. Ansible writes files; k3s reconciles them. The consequence
|
|
worth remembering: **a green run means the manifests landed, not that the
|
|
workload came up.** Check with `kubectl -n <namespace> get pods`.
|
|
|
|
Secrets land on disk in the rendered `.env` (root-readable, on the Unraid
|
|
box) or, on k3s, in a 0600 manifest and then in etcd as an ordinary
|
|
Kubernetes Secret — this is "fetch from Vault at deploy time," not a
|
|
zero-secrets-at-rest model. Fine for a homelab; revisit if that changes.
|
|
|
|
## Icons on the Unraid Docker page
|
|
|
|
There are two icons per stack, and they come from completely different places.
|
|
Commit **one** `src/<app>/ansible/unraid/icon.png` and the deploy feeds both.
|
|
A stack running more than one container can commit
|
|
`icon-<service>.png` alongside it for the extra services — see "More than one
|
|
container" below.
|
|
|
|
### The stack row
|
|
|
|
Compose Manager draws the collapsible header row for the whole project, and
|
|
serves its icon straight off disk from `icon.png` in the project folder —
|
|
`/boot/config/plugins/compose.manager/projects/<stack>/icon.png`, the same
|
|
directory the compose file and rendered `.env` land in. No label, no
|
|
template, no URL: the file is there or the row has no icon. `.jpg`, `.gif`,
|
|
`.svg` and an extensionless `icon` also work, since the plugin serves the file
|
|
itself rather than handing a path to the webgui — but stick to PNG so the same
|
|
file can serve the labels below.
|
|
|
|
This is a feature of **Compose Manager Plus** (`mstrhakr/compose_plugin`), the
|
|
maintained fork. The original Docker Compose Manager plugin (`dcflachs`,
|
|
archived April 2026) has no icon support at all — on that one the copied file
|
|
is simply ignored, so the deploy is safe either way, you just won't see it.
|
|
|
|
### The containers under it
|
|
|
|
Unraid's Docker page gets each *container's* icon, WebUI link and console
|
|
shell out of the dockerMan template that created it, under
|
|
`/boot/config/plugins/dockerMan/templates-user/`. A Compose stack never
|
|
creates one, which is why compose-deployed containers show a question mark and
|
|
no WebUI entry. Unraid 6.10+ falls back to Docker labels when there's no
|
|
template, so each service sets them itself:
|
|
|
|
```yaml
|
|
labels:
|
|
net.unraid.docker.icon: ${STACK_ICON}
|
|
net.unraid.docker.webui: "http://[IP]:${FORGEJO_HTTP_PORT}/"
|
|
net.unraid.docker.shell: sh
|
|
```
|
|
|
|
They're per **service**, not per stack — a stack with two containers labels
|
|
each one separately, and can point them at different icons.
|
|
|
|
- **icon** — `${STACK_ICON}` is set by the role, not by hand. The same
|
|
committed `icon.png` is copied a second time to
|
|
`/mnt/user/appdata/icons/<stack>.png` and the label points there; ship no
|
|
`icon.png` and the variable renders empty, leaving the placeholder. To use a
|
|
hosted image instead, set `STACK_ICON` in the app's
|
|
`ansible/unraid/vars.yml` — it's merged over the role's value.
|
|
|
|
A second copy rather than reusing the project-folder one because this label
|
|
is a path the *webgui* resolves on every Docker page load, and that path
|
|
shouldn't run through the flash drive.
|
|
|
|
**PNG only here** — unlike the stack row, this goes through the webgui's own
|
|
icon handling, where SVG renders as the question-mark fallback and WebP
|
|
renders nothing at all. A remote URL is fetched when the page renders, so an
|
|
unreachable host breaks the icon — the reason a committed file is the
|
|
default.
|
|
|
|
- **webui** — `[IP]` is substituted with the host's address. The port must be
|
|
the *published* one, so interpolate the stack's own port variable rather
|
|
than the container port. Omit the label entirely for something with no web
|
|
interface (as `shared/postgres` does) — an empty value still draws a WebUI
|
|
entry that goes nowhere.
|
|
|
|
- **shell** — `sh` or `bash`, whichever the image actually has. Alpine-based
|
|
images (Forgejo) need `sh`; Debian-based ones (Postgres, Shelfarr) can take
|
|
`bash`.
|
|
|
|
One caveat: editing a compose container's labels through the Unraid UI writes
|
|
a template for it, and the template then wins over the labels until you clear
|
|
it. Change these in the compose file and redeploy, not in the webgui.
|
|
|
|
### More than one container
|
|
|
|
`${STACK_ICON}` is a single value — the stack's `icon.png` — so a stack with
|
|
two services would otherwise have to give both the same image. Since the
|
|
labels are per-service, commit `icon-<service>.png` next to `icon.png` for
|
|
each additional container, named for the **compose service**:
|
|
|
|
```
|
|
src/arr/ansible/unraid/
|
|
icon.png → ${STACK_ICON} → /mnt/user/appdata/icons/arr.png
|
|
icon-prowlarr.png → ${STACK_ICON_PROWLARR} → /mnt/user/appdata/icons/arr-prowlarr.png
|
|
```
|
|
|
|
The variable is the service name uppercased with `-` folded to `_`
|
|
(`icon-shelfarr-libation.png` → `${STACK_ICON_SHELFARR_LIBATION}`). These get
|
|
the appdata copy only — there is one Compose Manager stack row and `icon.png`
|
|
already has it — and, like `STACK_ICON`, each can be overridden with a hosted
|
|
URL by setting the same name in the app's `ansible/unraid/vars.yml`.
|
|
|
|
The variable only exists when the file does. Interpolating
|
|
`${STACK_ICON_FOO}` with no `icon-foo.png` committed gets Compose's
|
|
unset-variable warning and the question-mark placeholder — so if the icon is
|
|
meant to be a URL, set it in `vars.yml` rather than relying on the file.
|
|
|
|
## Backups (Proxmox)
|
|
|
|
Proxmox guests keep **all** their state on their own rootfs. Nothing is
|
|
bind-mounted in from the NAS, which is the opposite of the obvious instinct —
|
|
the reason is that `vzdump` deliberately excludes bind mounts, so a
|
|
bind-mounted repository directory would be the one thing missing from the
|
|
backup that was made to protect it. Keeping everything local means one archive
|
|
is a complete copy of the guest's filesystem.
|
|
|
|
`playbooks/pve_host.yml` (role: `pve_backup`) configures the off-box copy on
|
|
the node: an NFS storage pointing at the NAS, and a scheduled `vzdump` job
|
|
writing to it. Retention is the storage's `prune-backups` settings. Point it at
|
|
a Proxmox Backup Server instead by setting `pve_backup_storage_type: pbs` in
|
|
`host_vars/<node>.yml`.
|
|
|
|
**Data that lives outside a container is that app's problem, not vzdump's.**
|
|
Forgejo's database is on the shared Postgres over on Unraid, so restoring its
|
|
container alone would give back every repository with no issues, pull requests,
|
|
users or permissions. The fix is a `pg_dump` on a systemd timer *inside* the
|
|
container, writing into its own filesystem — which puts the dump inside the
|
|
same archive as the repositories it belongs to. That only works if it finishes
|
|
before the backup window, so the two schedules are a pair:
|
|
|
|
| | Set in | Default |
|
|
|---|---|---|
|
|
| Forgejo database dump | `src/forgejo/ansible/proxmox/vars.yml` → `FORGEJO_DB_DUMP_ONCALENDAR` | 01:30 |
|
|
| vzdump job | `inventory/host_vars/pve.yml` → `pve_backup_schedule` | 02:00 |
|
|
|
|
Any future app with external state should follow the same pattern. Restore
|
|
steps are in `src/forgejo/ansible/proxmox/README.md`.
|
|
|
|
None of this covers the Unraid side, which has its own backup arrangements
|
|
outside this repo.
|
|
|
|
## Prerequisites
|
|
|
|
### Both platforms
|
|
|
|
- Ansible control node needs to be Linux — **on Windows, run it from WSL2**,
|
|
not natively.
|
|
- If this repo is checked out on a Windows mount (e.g. under `/mnt/c/...`,
|
|
which is where a Seafile-synced folder ends up) rather than the native WSL
|
|
filesystem, Ansible will warn that `ansible.cfg` is being ignored because
|
|
the directory looks world-writable — that's DrvFs reporting `777` on
|
|
everything, not a real permissions problem. Either `export
|
|
ANSIBLE_CONFIG="$(pwd)/ansible.cfg"` before running (from the
|
|
`build/config/ansible/` dir), or fix it permanently by adding to
|
|
`/etc/wsl.conf` inside WSL:
|
|
```ini
|
|
[automount]
|
|
options = "metadata,umask=22,fmask=11"
|
|
```
|
|
then `wsl --shutdown` from PowerShell and reopen the shell.
|
|
- `pip install ansible hvac psycopg2-binary`
|
|
- `ansible-galaxy collection install -r build/config/ansible/requirements.yml`
|
|
- A reachable Vault instance, with secrets populated per
|
|
`docs/vault-secrets.md`.
|
|
|
|
### Unraid
|
|
|
|
- Root SSH enabled on the Unraid box(es) (Settings → Management Access).
|
|
- Compose Manager (Docker Compose plugin) installed on Unraid — already
|
|
done.
|
|
- **Python3 on each Unraid host itself**, not just the controller: the
|
|
`compose_stack` role's tasks (`file`, `copy`, `template`,
|
|
`docker_compose_v2`) run on the target, and Ansible modules — unlike
|
|
`raw`/ad hoc shell — need a Python interpreter there to execute at all.
|
|
Stock Unraid doesn't ship one. Installing it by hand into the running
|
|
system doesn't survive a reboot, same reason as the SSH-key gotcha above —
|
|
Unraid rebuilds its root filesystem from the flash drive on every boot.
|
|
Install the **NerdTools** plugin (Community Applications → search
|
|
"NerdTools", formerly "NerdPack") and enable `python3` in its package
|
|
list — it reinstalls whatever you've selected on every boot itself, same
|
|
mechanism Compose Manager already relies on. `ansible.cfg` already sets
|
|
`interpreter_python = auto_silent`, so Ansible finds it automatically
|
|
wherever NerdTools puts it; no path to hardcode. Verify after installing:
|
|
```sh
|
|
ansible unraid_servers -m ping
|
|
```
|
|
|
|
### Proxmox
|
|
|
|
- Terraform CLI (>= 1.6) on whatever runs `terraform apply`.
|
|
- A Proxmox API token with rights to create containers, stored in Vault at
|
|
`homelab/ci/proxmox` (see `docs/vault-secrets.md`). Both the Terraform
|
|
provider and Ansible's dynamic inventory authenticate with it.
|
|
- A `terraform_state` database and `terraform` role on the **CloudNativePG
|
|
cluster on k3s** (192.168.50.81), for `backend "pg"` — not on either shared
|
|
Postgres this repo deploys, which would be circular. See "Bootstrapping"
|
|
below.
|
|
- Container templates downloaded on the Proxmox node for whatever OS the
|
|
LXCs are built from.
|
|
- An NFS export on the NAS for backup archives, reachable from the Proxmox
|
|
node — set it in `inventory/host_vars/pve.yml`. See "Backups (Proxmox)".
|
|
- Root SSH from the Ansible controller to the Proxmox node itself, not just
|
|
the guests: `playbooks/pve_host.yml` configures the node over SSH.
|
|
- Unlike Unraid, nothing special is needed *inside* the guests: they're
|
|
ordinary Linux containers with Python already present, so Ansible works
|
|
without the NerdTools workaround above.
|
|
|
|
### K3s (Raspberry Pi)
|
|
|
|
- 4 Raspberry Pis running Ubuntu Server (64-bit), already imaged and on the
|
|
network at the addresses in `inventory/hosts.yml` → `k3s_cluster`.
|
|
- An `ansible` user on each Pi with **NOPASSWD sudo** and this repo's k3s SSH
|
|
key installed as an authorized key — see "SSH access" below. Not automated
|
|
by this repo; create it by hand (or via cloud-init at image time) before the
|
|
first run of `playbooks/k3s.yml`.
|
|
- `homelab/k3s-homelab-utils` → `K3S_TOKEN` set in Vault *before* the first
|
|
run — see `docs/vault-secrets.md`. Both the server and every agent read
|
|
this same fixed value rather than one generating it and handing it to the
|
|
other, which is what makes a rebuild reproducible.
|
|
- Nothing else special: `python3` ships with Ubuntu Server, and the k3s
|
|
install script (`get.k3s.io`) handles containerd, the systemd unit, and
|
|
everything else that isn't Pi-specific. The one Pi-specific thing —
|
|
ensuring the memory cgroup controller is on — is handled by
|
|
`roles/k3s_node` itself, not a prerequisite here.
|
|
|
|
#### Keeping it patched
|
|
|
|
`playbooks/k3s.yml` also applies `roles/unattended_upgrades` to every node —
|
|
apt updates install themselves on their own daily schedule, no login
|
|
required. It sets `Unattended-Upgrade::Automatic-Reboot "false"` though, so a
|
|
kernel or containerd update that needs a reboot to take effect just sits
|
|
applied-but-inactive until one happens; blindly auto-rebooting a k3s node
|
|
takes its pods down with no warning.
|
|
|
|
`playbooks/k3s_maintenance.yml` is the other half: it checks
|
|
`/var/run/reboot-required` on each node and, only where it's set, cordons and
|
|
drains the node, reboots it, waits for it to report `Ready` again, then
|
|
uncordons it — one node at a time (`serial: 1`), so patching the cluster never
|
|
means dropping every workload at once. Manual-only for now, same as
|
|
`playbooks/k3s.yml`:
|
|
|
|
```sh
|
|
ansible-playbook playbooks/k3s_maintenance.yml
|
|
```
|
|
|
|
Eventually this is meant to run on a schedule from a self-hosted Forgejo
|
|
Actions runner rather than by hand — not wired up yet, see the header comment
|
|
in the playbook.
|
|
|
|
#### Metrics (Prometheus)
|
|
|
|
`playbooks/k3s.yml` also deploys `roles/k3s_monitoring`: a lean
|
|
kube-prometheus-stack (Prometheus + prometheus-operator + node-exporter +
|
|
kube-state-metrics — no Grafana, no Alertmanager) so tools like OpenLens can
|
|
show node/pod metrics. It's installed as a `HelmChart` custom resource
|
|
dropped into k3s's own auto-deploying manifests directory
|
|
(`/var/lib/rancher/k3s/server/manifests/`) rather than run through a `helm`
|
|
binary — k3s's bundled helm-controller reconciles it the same way it
|
|
installs its own Traefik and ServiceLB, so this needs no extra Ansible
|
|
collection or kubeconfig on the controller. See
|
|
`roles/k3s_monitoring/defaults/main.yml` for the resource sizing (tuned for
|
|
a Raspberry Pi 4, not a real node) and why the control-plane component
|
|
scrapers (`kubeControllerManager`, `kubeScheduler`, `kubeProxy`, `kubeEtcd`)
|
|
are disabled — k3s doesn't expose those the way kube-prometheus-stack
|
|
expects, so leaving them on just produces permanently-"down" targets.
|
|
|
|
To point OpenLens at it: open the cluster's Settings → Metrics, set
|
|
Prometheus to "Auto detect" or explicitly to the `Operator` provider — it
|
|
should find the `kube-prometheus-stack-prometheus` service in the
|
|
`monitoring` namespace via the API server proxy, the same path OpenLens
|
|
already uses to reach the cluster, so nothing needs to be exposed outside
|
|
it.
|
|
|
|
#### LoadBalancer IPs (MetalLB)
|
|
|
|
`playbooks/k3s.yml` also deploys `roles/k3s_metallb`: MetalLB in L2 mode,
|
|
handing out real LAN IPs to `type: LoadBalancer` Services instead of the
|
|
`ClusterIP`-only world k3s would otherwise leave homelab-utils apps in. It's
|
|
installed the same way as monitoring — a `HelmChart` CR for k3s's
|
|
helm-controller to reconcile — plus a plain `IPAddressPool`/`L2Advertisement`
|
|
manifest dropped in the same directory; k3s's deploy controller applies both
|
|
kinds of file and retries the config manifest until the HelmChart's CRDs
|
|
exist.
|
|
|
|
MetalLB replaces k3s's bundled ServiceLB (Klipper), it doesn't run alongside
|
|
it — both would otherwise compete to satisfy the same Services. That's why
|
|
`inventory/group_vars/k3s_cluster.yml` sets `k3s_server_extra_args:
|
|
['--disable=servicelb']` — a separate, server-only var from `k3s_extra_args`,
|
|
since `k3s agent` doesn't understand `--disable` and would fail to start if
|
|
it were passed there too. The IP pool itself is
|
|
`k3s_metallb_address_range` in the same file — a range on the cluster's LAN
|
|
(`192.168.50.0/24`) that DHCP and every static assignment in
|
|
`inventory/hosts.yml` steer clear of. There's no built-in default: the role
|
|
fails fast if it's still empty.
|
|
|
|
Enabling `--disable=servicelb` on an already-running cluster doesn't need a
|
|
separate step: `roles/k3s_node` notices its exec line has changed and
|
|
reinstalls (restarting just the `k3s` service, not the node) the next time
|
|
`playbooks/k3s.yml` runs — see `CLAUDE.md` → "Key decisions".
|
|
|
|
#### Postgres (CloudNativePG)
|
|
|
|
`playbooks/k3s.yml` also deploys `roles/k3s_postgres`: CloudNativePG (CNPG),
|
|
a Postgres operator, installed the same way as monitoring and MetalLB — a
|
|
`HelmChart` CR for k3s's helm-controller to reconcile — plus a plain
|
|
`Cluster` CR (the actual database) and a `Secret` (superuser credentials),
|
|
dropped in the same auto-deploying manifests directory. This is a third
|
|
platform for `src/shared/postgres/`, alongside the Unraid Compose stack and
|
|
the Proxmox placeholder: `roles/k3s_postgres` layers
|
|
`src/shared/postgres/common/vars.yml` with
|
|
`src/shared/postgres/ansible/kubernetes/vars.yml` the same way
|
|
`compose_stack`/`lxc_app` layer an app's config, and pulls the superuser
|
|
password from the same `homelab/shared/postgres` Vault path those platforms
|
|
already use (see `docs/vault-secrets.md`) — a separate physical instance,
|
|
not a shared login across platforms, just the same identity.
|
|
|
|
1 primary + 1 replica, kept off the control-plane Pi via `nodeAffinity` (see
|
|
`roles/k3s_postgres/templates/postgres-cluster.yaml.j2`) — CNPG's own pod
|
|
anti-affinity then spreads the two across the 3 worker Pis. Storage is k3s's
|
|
default `local-path` StorageClass; that's node-local, but resilience here
|
|
comes from CNPG's own streaming replication between instances, not from
|
|
shared storage, so losing one instance's disk doesn't lose the data as long
|
|
as the other instance is up. HA replication only for now — no
|
|
`ScheduledBackup` to the NAS or anywhere else yet.
|
|
|
|
To connect: `kubectl -n shared-postgres get svc` lists four Services. Three
|
|
are CNPG's own, all `ClusterIP` — `shared-postgres-rw` (the current primary),
|
|
`-ro` (replicas only) and `-r` (any instance). An app running on the cluster
|
|
uses the first of those by DNS and needs nothing else:
|
|
|
|
```
|
|
shared-postgres-rw.shared-postgres.svc.cluster.local:5432
|
|
```
|
|
|
|
The fourth, `shared-postgres-lb`, is a `LoadBalancer` on
|
|
`k3s_postgres_loadbalancer_ip` (`inventory/group_vars/k3s_cluster.yml` —
|
|
inside the MetalLB pool above), which is how the cluster is reached from
|
|
outside it:
|
|
|
|
```sh
|
|
psql -h 192.168.50.81 -U postgres # password: homelab/shared/postgres
|
|
```
|
|
|
|
It's declared in the `Cluster` CR as a CNPG *managed service* rather than as
|
|
a Service manifest of our own, so the operator keeps its selector pointed at
|
|
whichever instance is currently primary — a hand-written Service would need
|
|
re-pointing by hand after a failover. Credentials are the
|
|
`shared-postgres-superuser-vault` Secret Ansible renders; CNPG separately
|
|
auto-creates `shared-postgres-app` for the default `app` database it
|
|
bootstraps, which nothing uses yet.
|
|
|
|
The `-vault` suffix keeps that Secret clear of `<cluster>-superuser`, the
|
|
name CNPG uses for the superuser secret it generates itself. Name our own
|
|
object that and the operator treats it as one it already authored, so a
|
|
rotated password reaches the Secret and never reaches the database — the
|
|
Secret reads correctly, `psql` from the LAN keeps failing authentication, and
|
|
only `select rolpassword is null from pg_authid where rolname='postgres'`
|
|
inside the pod shows the disagreement.
|
|
|
|
That LoadBalancer is what makes the controller-reachable `host:port` the
|
|
`community.postgresql` tasks need — the same ones `compose_stack` and
|
|
`lxc_app` use to create an app's database and role — available here too, and
|
|
`roles/k3s_app` now uses exactly those tasks for any app declaring a `db:`
|
|
block.
|
|
|
|
Note the deliberate asymmetry, which reads like a mistake until you know why:
|
|
provisioning uses `192.168.50.81` (the LoadBalancer) because it runs on the
|
|
Ansible controller, which is off-cluster and can't route to a ClusterIP;
|
|
apps use `shared-postgres-rw.shared-postgres.svc.cluster.local` because
|
|
sending pod traffic out to the LAN and back would put MetalLB's L2 speaker in
|
|
the path of every query for nothing. Two addresses, one database, each
|
|
correct for its caller.
|
|
|
|
Without `kubectl` to hand, or if MetalLB is having a bad day, the in-cluster
|
|
routes still work:
|
|
|
|
```sh
|
|
kubectl -n shared-postgres port-forward svc/shared-postgres-rw 5432:5432
|
|
kubectl -n shared-postgres exec -it shared-postgres-1 -- psql -U postgres
|
|
```
|
|
|
|
**No backups.** HA replication only — a streaming replica on another Pi,
|
|
which covers a dead SD card and not a dropped table, a bad migration, or a
|
|
mistyped `DROP`. This mattered less when the cluster held nothing; it matters
|
|
now that Authentik's database lives here rather than on Unraid. A CNPG
|
|
`ScheduledBackup` to the NAS is the obvious next piece of work and isn't
|
|
built yet.
|
|
|
|
#### TLS (cert-manager)
|
|
|
|
`playbooks/k3s.yml` also applies `roles/k3s_cert_manager`: cert-manager plus
|
|
one `ClusterIssuer` named `letsencrypt`, both via the usual `HelmChart` CR +
|
|
plain manifest pair. An app's Ingress opts in with a single annotation —
|
|
|
|
```yaml
|
|
annotations:
|
|
cert-manager.io/cluster-issuer: letsencrypt
|
|
```
|
|
|
|
— and cert-manager creates and renews the certificate on its own.
|
|
|
|
Two values have no default and the role refuses to run without them, both in
|
|
`inventory/group_vars/k3s_cluster.yml`:
|
|
|
|
- `k3s_cert_manager_acme_email` — an ACME account is registered against it.
|
|
- `k3s_cert_manager_solver` — the DNS-01 stanza for your DNS provider,
|
|
rendered into the issuer as-is. A commented Cloudflare example is in that
|
|
file; for anything else take the stanza from
|
|
[cert-manager's docs](https://cert-manager.io/docs/configuration/acme/dns01/).
|
|
Its API token goes in Vault at `homelab/k3s-cert-manager`.
|
|
|
|
DNS-01 rather than HTTP-01 because HTTP-01 needs Let's Encrypt to reach this
|
|
cluster from the internet on port 80, which it can't. That has a useful
|
|
consequence: a certificate can be issued **before** DNS points at the
|
|
cluster, so a migration's TLS is settled before its cutover rather than
|
|
after.
|
|
|
|
While working out a solver, point `k3s_cert_manager_acme_server` at Let's
|
|
Encrypt staging — production allows five failed validations per hostname per
|
|
hour, and exhausting it means waiting, not retrying. Check it registered:
|
|
|
|
```sh
|
|
kubectl get clusterissuer letsencrypt -o jsonpath='{.status.conditions[*].message}'
|
|
```
|
|
|
|
#### Dashboard (Traefik)
|
|
|
|
Traefik is the one chart here this repo doesn't install — k3s installs it
|
|
itself. `roles/k3s_traefik` only adjusts it, through a `HelmChartConfig`
|
|
merged over k3s's own `HelmChart`. Editing
|
|
`/var/lib/rancher/k3s/server/manifests/traefik.yaml` on the node instead
|
|
works until the next server restart rewrites it.
|
|
|
|
A stock k3s answers **404** on the dashboard, which looks like a broken
|
|
install and isn't: Traefik still builds the dashboard, but the Traefik chart
|
|
stopped creating the router that reaches it in v28. The role puts that router
|
|
back on Traefik's internal `traefik` entrypoint, which isn't published on the
|
|
Service — so it's reachable by port-forward and nothing else:
|
|
|
|
```sh
|
|
kubectl -n kube-system port-forward deploy/traefik 9000:9000
|
|
# then http://127.0.0.1:9000/dashboard/ — the trailing slash is required
|
|
```
|
|
|
|
Two things to check if that still 404s. `curl -i http://127.0.0.1:9000/ping`
|
|
returning 200 means the port-forward is fine and only the router is missing
|
|
(so the role hasn't run, or its `HelmChartConfig` hasn't reconciled yet —
|
|
`kubectl -n kube-system get ingressroute` should list `traefik-dashboard`).
|
|
And forward to `deploy/traefik`, not `svc/traefik`: the Service only publishes
|
|
80/443, so going through it lands you on the `web` entrypoint, where an
|
|
unmatched request also returns 404.
|
|
|
|
**Publishing it on a hostname** is one commented line in
|
|
`inventory/group_vars/k3s_cluster.yml`:
|
|
|
|
```yaml
|
|
k3s_traefik_dashboard_host: traefik.turtlesystems.uk
|
|
```
|
|
|
|
That renders an `IngressRoute` on `websecure` with a cert-manager certificate
|
|
and an Authentik forward-auth middleware in front. `k3s_traefik_dashboard_auth_address`
|
|
must be set too — the role refuses to run otherwise rather than publishing an
|
|
unauthenticated view of every router, service and middleware on the cluster.
|
|
It's already set in that file, so in practice this is the single line above
|
|
plus a DNS record pointing at Traefik's MetalLB address
|
|
(`kubectl -n kube-system get svc traefik`).
|
|
|
|
On the Authentik side, first: a Proxy Provider in **forward auth (domain
|
|
level)** mode, assigned to an application, added to the embedded outpost.
|
|
Domain level rather than single-application because the latter needs
|
|
`/outpost.goauthentik.io/` routed to Authentik on the dashboard's own
|
|
hostname, which from `kube-system` is a cross-namespace service reference
|
|
Traefik rejects by default.
|
|
|
|
An `IngressRoute` rather than an `Ingress`, unlike every app here, because
|
|
the dashboard is served by Traefik's internal `api@internal` and has no
|
|
Kubernetes Service for an `Ingress` to point at. That's also why its
|
|
certificate is an explicit `Certificate` resource — cert-manager watches
|
|
`Ingress`, not `IngressRoute`.
|
|
|
|
Clearing the hostname again is a real teardown: the role deletes the
|
|
manifest, and k3s's deploy controller garbage-collects the route, middleware
|
|
and certificate it created.
|
|
|
|
The port-forward stays enabled alongside the published route on purpose. Once
|
|
the dashboard is behind Authentik it depends on Authentik, which depends on
|
|
CNPG, which depends on a healthy cluster — the things you'd open the
|
|
dashboard to diagnose. Port-forward talks to the pod and traverses none of
|
|
it, so it's the break-glass path, not a leftover.
|
|
|
|
#### Apps on the cluster
|
|
|
|
Apps go in `k3s_apps:` in `inventory/group_vars/k3s_cluster.yml` — the k3s
|
|
equivalent of a host's `stacks:`/`apps:` list, in group_vars because an app
|
|
is deployed to the cluster rather than to a node. Same entry shape as the
|
|
other platforms (name, src, vault_path, optional `db:`, optional `state:`).
|
|
|
|
```sh
|
|
ansible-playbook playbooks/k3s.yml --tags apps
|
|
ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik
|
|
```
|
|
|
|
Ingress goes through the Traefik k3s already bundles, on the MetalLB address
|
|
its Service holds (`kubectl -n kube-system get svc traefik`) — so adding an
|
|
app costs a DNS record pointed at that one address, not a pool IP each.
|
|
|
|
Removal is `state: absent` on the entry, same tombstone rule as Unraid: keep
|
|
the entry, don't delete it, or nothing is torn down and the workload keeps
|
|
running unmanaged. Unlike Unraid it's a single 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.
|
|
|
|
**Authentik** is currently the only app, and it got here by migrating off a
|
|
hand-made Unraid container rather than being deployed fresh. If you're
|
|
repeating that for something else, `docs/authentik-migration.md` is the
|
|
worked procedure; the part worth knowing up front is that the deployed
|
|
version must match the version a restored database was dumped from, because
|
|
Authentik runs its migrations on startup and they don't go backwards.
|
|
|
|
## SSH access
|
|
|
|
Ansible connects as `ansible_user: root` (set in
|
|
`build/config/ansible/inventory/hosts.yml`) over SSH. Password auth would mean either an interactive prompt every run —
|
|
which doesn't work from CI at all — or a plaintext password somewhere, so
|
|
use a dedicated key pair instead:
|
|
|
|
```sh
|
|
ssh-keygen -t ed25519 -f ~/.ssh/unraid_ansible -C "ansible@unraid-iac" -N ""
|
|
```
|
|
|
|
Install the public half as an authorized key on **every** Unraid host this
|
|
repo targets. Don't just `ssh-copy-id` it into `~/.ssh/authorized_keys` —
|
|
Unraid boots from the flash drive and `/root` lives on a RAM-backed overlay,
|
|
so anything written there is gone on the next reboot. Persist it through
|
|
`/boot/config/ssh/root.authorized_keys` instead, which Unraid copies into
|
|
place at every boot:
|
|
|
|
```sh
|
|
cat ~/.ssh/unraid_ansible.pub | ssh root@<unraid-host> \
|
|
'cat >> /boot/config/ssh/root.authorized_keys'
|
|
```
|
|
|
|
For manual/bootstrap runs, load the key into `ssh-agent` and Ansible picks it
|
|
up automatically — no config file changes needed:
|
|
|
|
```sh
|
|
eval "$(ssh-agent -s)"
|
|
ssh-add ~/.ssh/unraid_ansible
|
|
```
|
|
|
|
For CI, rather than adding yet another place secrets live, the private key
|
|
is stored in Vault (`homelab/ci/ssh`, see `docs/vault-secrets.md`) and the
|
|
workflow fetches it at the start of each run:
|
|
|
|
```sh
|
|
vault kv put kv/homelab/ci/ssh PRIVATE_KEY=@~/.ssh/unraid_ansible
|
|
```
|
|
|
|
**K3s Pis** work the same way but as a non-root `ansible` user, not root, and
|
|
with their own key pair (`~/.ssh/k3s_ansible`, Vault path
|
|
`homelab/ci/ssh-k3s`) — see `docs/vault-secrets.md` for the full commands.
|
|
Three differences from the Unraid steps above. The public key goes in the
|
|
ordinary `~/.ssh/authorized_keys` for the `ansible` user (Ubuntu's root
|
|
filesystem isn't rebuilt from flash on every boot, so there's no persistence
|
|
quirk to work around), and that user needs NOPASSWD sudo configured in
|
|
`/etc/sudoers.d/` — `playbooks/k3s.yml` installs k3s via `become: true`.
|
|
|
|
And the private half doesn't need loading into `ssh-agent` at all, even for a
|
|
manual run: `playbooks/k3s_ssh_key.yml` fetches it from Vault and writes it to
|
|
`local/k3s/homelab-utils.key`, and both `playbooks/k3s.yml` and
|
|
`playbooks/k3s_maintenance.yml` import that as their first play. So a k3s run
|
|
needs `VAULT_ADDR`/`VAULT_TOKEN` in the environment and nothing else — the
|
|
same "Vault is the only place secrets live" arrangement CI already uses for
|
|
the Unraid key, rather than a second setup step to remember. To fall back to a
|
|
local copy (Vault down, say), skip the fetch **and** point at the key —
|
|
overriding the path alone isn't enough, since the fetch play would still fail
|
|
before anything else ran:
|
|
|
|
```sh
|
|
ansible-playbook playbooks/k3s.yml \
|
|
-e k3s_ssh_key_fetch=false \
|
|
-e ansible_ssh_private_key_file=~/.ssh/k3s_ansible
|
|
```
|
|
|
|
## Bootstrapping (chicken-and-egg on the very first deploy)
|
|
|
|
CI/CD runs on Forgejo Actions — but Forgejo doesn't exist yet on the first
|
|
run, and Actions needs a self-hosted runner besides. So the first deploy is
|
|
manual:
|
|
|
|
1. Fill in `build/config/ansible/inventory/hosts.yml` with nas2's real
|
|
address.
|
|
2. Set up the SSH key pair per "SSH access" above and install the public key
|
|
on nas2 (and nas1, once you migrate anything to it).
|
|
3. `vault kv put` the secrets in `docs/vault-secrets.md` for
|
|
`homelab/shared/postgres`, `homelab/forgejo`, and `homelab/ci/ssh`.
|
|
4. From WSL, with `VAULT_ADDR` / `VAULT_TOKEN` exported (`vault login` first)
|
|
and the key loaded in `ssh-agent`:
|
|
```sh
|
|
cd build/config/ansible
|
|
ansible-playbook playbooks/unraid.yml --limit nas2
|
|
```
|
|
This brings up shared Postgres, provisions the `forgejo` database/role,
|
|
and brings up Forgejo. Use `unraid.yml` rather than `deploy.yml` at this
|
|
point — no Proxmox guests exist yet, so the Proxmox play has nothing to
|
|
target.
|
|
5. Log into Forgejo, create this repo there, push it.
|
|
6. Register a self-hosted Forgejo Actions runner reachable to Vault and to
|
|
both Unraid hosts (labelled `unraid-deploy` — see
|
|
`.forgejo/workflows/deploy.yml`). Give it an AppRole (`VAULT_ROLE_ID` /
|
|
`VAULT_SECRET_ID`) scoped to read the `kv/homelab/*` paths it needs
|
|
(including `homelab/ci/ssh`), stored as Forgejo Actions secrets alongside
|
|
`VAULT_ADDR`.
|
|
7. From then on, pushes to `main` touching `src/**` or `build/config/ansible/**`
|
|
deploy automatically — and only the stack(s) whose `src/<stack>/` folder
|
|
actually changed (see "Deploying one platform, one host, one stack"
|
|
below); a change under `build/config/ansible/` still triggers a full
|
|
deploy of every stack on every host, since that's a change to how *all* of
|
|
them get deployed.
|
|
|
|
### Bringing Proxmox online later
|
|
|
|
The shared Postgres and its LXC are the first thing to stand up here, and the
|
|
ordering is fiddly enough to have its own runbook: **`docs/postgres-proxmox.md`**.
|
|
The short version, and the reason it isn't just "run Terraform":
|
|
|
|
Terraform stores its state in Postgres, so provisioning the Postgres LXC with
|
|
Terraform would be circular. It's broken by keeping state on the
|
|
CloudNativePG cluster on the k3s Pis instead — something this configuration
|
|
has no hand in building — which means `terraform apply` now depends on the
|
|
cluster being up. That's a cross-platform dependency the rest of the repo
|
|
avoids, and it's worth it because the alternative is a local-state-then-migrate
|
|
dance that has to be got right exactly once.
|
|
|
|
After that, `docs/postgres-proxmox.md` walks through the template download,
|
|
`terraform apply -target=module.postgres`, checking the ZFS replication job
|
|
actually exists, installing Postgres with
|
|
`ansible-playbook playbooks/proxmox.yml -e only_stacks=postgres`, and
|
|
`playbooks/pve_host.yml` for node backups. It also covers failing over between
|
|
the two nodes and repointing clients afterwards.
|
|
|
|
Forgejo comes after all of that, on **turtle-proxmox-02** — its database is
|
|
the shared Postgres the runbook above creates, so none of it works until that
|
|
one is finished. `docs/forgejo-proxmox.md` walks through the extra Vault key
|
|
the native install needs, `terraform apply -target=module.forgejo`,
|
|
`ansible-playbook playbooks/proxmox.yml -e only_stacks=forgejo`, and the parts
|
|
Ansible deliberately does not do — moving repository data, and the DNS/SSH-port
|
|
cutover.
|
|
|
|
## Deploying one platform, one host, one stack
|
|
|
|
`playbooks/deploy.yml` runs everything; `playbooks/unraid.yml`,
|
|
`playbooks/proxmox.yml` and `playbooks/pve_host.yml` do one slice each
|
|
(Unraid stacks, Proxmox guests, and the Proxmox node's backup config
|
|
respectively). Within any of them, `--limit <host>`
|
|
narrows by host and `-e only_stacks=<name>[,<name>...]` narrows by stack or
|
|
app (name = the `name:` in the host's `stacks:`/`apps:` list, e.g. `forgejo`
|
|
or `postgres` — not the `src:` path):
|
|
|
|
```sh
|
|
# everything, both platforms
|
|
ansible-playbook playbooks/deploy.yml
|
|
|
|
# everything declared for nas2
|
|
ansible-playbook playbooks/unraid.yml --limit nas2
|
|
|
|
# just forgejo on nas2
|
|
ansible-playbook playbooks/unraid.yml --limit nas2 -e only_stacks=forgejo
|
|
|
|
# forgejo and its database, skipping any other stack on nas2
|
|
ansible-playbook playbooks/unraid.yml --limit nas2 -e only_stacks=forgejo,postgres
|
|
```
|
|
|
|
Note `only_stacks` doesn't resolve dependencies for you — if you limit to
|
|
`forgejo` before `postgres` has ever been deployed, the DB provisioning step
|
|
will fail because there's nothing to connect to. CI doesn't hit this: it
|
|
computes `only_stacks` from which `src/**` paths actually changed in the
|
|
push, so an unrelated app's push never touches Postgres or Forgejo.
|
|
|
|
`deploy.yml` runs Unraid before Proxmox by convention rather than necessity —
|
|
the two platforms no longer depend on each other. Proxmox-side apps provision
|
|
their databases against the Proxmox shared Postgres (192.168.50.54), and
|
|
Terraform keeps its state on the CloudNativePG cluster on k3s; neither goes
|
|
through Unraid. The node's backup config goes last, since a vzdump job pinned
|
|
to specific VMIDs needs those guests to exist. Note that `only_stacks` has no
|
|
meaning for `pve_host.yml` — it configures a node, not an app, and ignores it.
|
|
|
|
`playbooks/k3s.yml` is separate from all of the above — it isn't included in
|
|
`deploy.yml` and doesn't take `only_stacks`, because a cluster service there
|
|
isn't a `stacks:` entry to filter down to. It has two selectors of its own
|
|
instead: `--limit` narrows *which nodes*, and `--tags` narrows *which piece*.
|
|
|
|
```sh
|
|
# the whole cluster: nodes, then the cluster services, then the kubeconfig
|
|
ansible-playbook playbooks/k3s.yml
|
|
|
|
# re-converge one worker, e.g. after reimaging it
|
|
ansible-playbook playbooks/k3s.yml --limit k3s-node-02
|
|
|
|
# just one cluster service, no node install across 4 Pis
|
|
ansible-playbook playbooks/k3s.yml --tags postgres
|
|
|
|
# every cluster service, still no node install
|
|
ansible-playbook playbooks/k3s.yml --tags services
|
|
```
|
|
|
|
The tags, one per play (see the playbook's header comment): `nodes` (both
|
|
node plays), `metallb`, `monitoring`, `postgres`, `cert-manager`, `traefik`,
|
|
`services` (all five of those together), `apps`, `upgrades`, `kubeconfig`.
|
|
`services` and `apps` don't imply each other, so "everything except
|
|
reinstalling k3s" is `--tags services,apps`. The SSH-key fetch play is tagged
|
|
`always` rather than named, so it survives every filter — it's what any of
|
|
the others connect with, not something you'd pick.
|
|
|
|
Deploying a single service this way skips the `kubeconfig` play too, so
|
|
`local/`'s copy isn't refreshed. That only matters on a cluster rebuild;
|
|
add `--tags postgres,kubeconfig` if you want both.
|
|
|
|
Worth knowing what a tagged run does and doesn't tell you: every service role
|
|
just templates manifests into `/var/lib/rancher/k3s/server/manifests/`
|
|
for k3s's own controllers to reconcile, so the playbook finishing means the
|
|
files landed, not that the workload is up. Watch that separately, e.g.
|
|
`kubectl -n shared-postgres get cluster,pods -w`.
|
|
|
|
`playbooks/k3s_maintenance.yml` is separate again — see "Keeping it patched"
|
|
above. It ignores `--limit` grouping in one sense worth knowing: `serial: 1`
|
|
still applies to whatever `--limit` narrows the run to, so limiting to two
|
|
nodes still patches them one at a time, not together.
|
|
|
|
## Adding a new app
|
|
|
|
1. `src/<app>/common/vars.yml` — `env_defaults:` for the config that doesn't
|
|
depend on where it runs.
|
|
2. Build the platform side(s) you need:
|
|
|
|
**Unraid** — `src/<app>/ansible/unraid/` containing
|
|
`docker-compose.yml` (static, `${VAR}`-driven, joining `unraid_shared`
|
|
with `external: true` if it needs the shared Postgres), `vars.yml` of
|
|
overrides, and `.env.example` documenting every var with secrets blank.
|
|
Optionally an `icon.png` and the `net.unraid.docker.*` labels, so the
|
|
Docker page shows something other than a question mark.
|
|
|
|
**Proxmox** — `src/<app>/ansible/proxmox/` containing `vars.yml`,
|
|
`install.yml`, and templates for the app's config file and systemd unit;
|
|
plus `src/<app>/terraform/` defining its LXC, called from
|
|
`build/config/terraform/main.tf`.
|
|
|
|
3. Add its `homelab/<app>` path to Vault (`docs/vault-secrets.md`).
|
|
4. Declare it on the target host — `stacks:` in an Unraid host's
|
|
`host_vars/<host>.yml`, or `apps:` in a Proxmox guest's. Same entry shape
|
|
either way (name, src, vault_path, optional `db:` block).
|
|
5. Push — or run the relevant playbook manually before CI exists.
|
|
6. Leave the platform you didn't build as a README placeholder rather than
|
|
deleting the folder.
|
|
|
|
## Removing an app (Unraid)
|
|
|
|
**Deleting the entry from `stacks:` does not remove anything.** Ansible keeps
|
|
no record of what it deployed last run, so an app that disappears from the
|
|
list is simply never visited again — its containers keep running on the host,
|
|
now unmanaged and invisible to the playbook. This is the one place the Unraid
|
|
side doesn't behave like the Terraform side, where deleting a module call does
|
|
destroy the LXC.
|
|
|
|
Removal is therefore an instruction, not an absence. Set `state: absent` on
|
|
the entry and leave it in place:
|
|
|
|
```yaml
|
|
stacks:
|
|
- name: arr
|
|
src: arr
|
|
vault_path: homelab/arr
|
|
state: absent
|
|
```
|
|
|
|
The next run of `playbooks/unraid.yml` runs `docker compose down`, then
|
|
deletes the project folder (and with it the rendered `.env`). Because a
|
|
`host_vars/` edit is a change under `build/config/ansible/`, CI treats it as a
|
|
full converge — so pushing that change is enough to action the teardown.
|
|
|
|
Keep the entry as a tombstone until you're sure. It's the only record that
|
|
the app was deliberately removed rather than never deployed, and flipping
|
|
`state:` back to `present` redeploys it.
|
|
|
|
### What survives, and how to remove the rest
|
|
|
|
Bind mounts are untouched, so `/mnt/user/appdata/<app>` — which for a
|
|
SQLite-backed app like Shelfarr *is* the application — survives a teardown and
|
|
makes it reversible. External networks (`caddy-net`, `unraid_shared`) are left
|
|
alone too; they belong to Unraid or to another stack. Both copies of the
|
|
stack's icon *are* deleted without needing to be asked — the project-folder
|
|
one goes with the folder, and the one under `/mnt/user/appdata/icons/` is
|
|
removed explicitly. Neither is state; they're copies of a file in the repo,
|
|
and a redeploy puts them back.
|
|
|
|
Three things are destructive enough to stay opt-in, per entry:
|
|
|
|
| Key | Effect |
|
|
|---|---|
|
|
| `remove_volumes: true` | Also delete the project's named volumes. Bind mounts are unaffected either way. |
|
|
| `remove_images: local` | Also delete its images (`local` or `all`, as `docker compose down --rmi`). |
|
|
| `remove_database: true` | Drop the Postgres database **and** its role. Only for stacks with a `db:` block. |
|
|
|
|
Nothing removes the app's `homelab/<app>` path from Vault — that has its own
|
|
lifecycle, and destroying it would make the teardown irreversible. Delete it
|
|
by hand with `vault kv metadata delete` once you're finished with the app.
|
|
|
|
### Order of operations
|
|
|
|
`docker compose down` reads the compose file to know what it's removing, so
|
|
the teardown has to run **before** you delete `src/<app>/` or the host's
|
|
project folder. Delete those first and there's nothing left to tell Docker
|
|
what belonged to the project — you're cleaning up by hand with `docker rm` and
|
|
`docker network rm` instead. So: set `state: absent`, run the playbook, then
|
|
delete files in a second pass.
|
|
|
|
There's no equivalent on the Proxmox side yet. `lxc_app` only ever installs,
|
|
and a symmetric teardown needs a per-app `uninstall.yml` convention that no
|
|
app implements — the role is still a skeleton. For now, removing a Proxmox app
|
|
means stopping and disabling its systemd unit by hand, or destroying the
|
|
container via Terraform.
|
|
|
|
## Adding a host
|
|
|
|
**Unraid:** add it under `unraid_servers` in
|
|
`build/config/ansible/inventory/hosts.yml`, then create
|
|
`build/config/ansible/inventory/host_vars/<name>.yml` with its own `stacks:`
|
|
list — each host only runs what it's assigned.
|
|
|
|
**Proxmox:** define the LXC as a module in `src/<app>/terraform/`, call it
|
|
from `build/config/terraform/main.tf`, and `terraform apply`. The guest
|
|
appears in inventory automatically via the API — no `hosts.yml` edit — but
|
|
it still needs a `host_vars/<guest>.yml` declaring its `apps:` list.
|
|
|
|
**K3s:** add it as a new host under `k3s_control_plane` or `k3s_workers` (per
|
|
`inventory/hosts.yml` → `k3s_cluster`) — no `terraform apply`, the Pi has to
|
|
physically exist and have SSH access set up first (see "K3s (Raspberry Pi)"
|
|
above). No `host_vars/` entry needed beyond that: `roles/k3s_node` reads
|
|
`k3s_node_role` off the group, not the host, so which group a new Pi joins is
|
|
the only thing that decides whether it becomes another agent or a second
|
|
control-plane node — and this repo's role doesn't support the latter (single
|
|
server, no HA etcd) without changes to `k3s_node/tasks/server.yml`.
|