README.md
Rama master · réplica de lectura del repositorio original.
# Server Homelab
A **k3s** homelab managed as infrastructure-as-code with **Ansible**, with a small residual
**Docker** footprint on the Pi. Services are fronted by **Traefik** with **Authelia** SSO,
secrets encrypted with **SOPS/age**, storage on **Longhorn**, and reverse-proxied behind
**Cloudflare**.
<!-- The exact count is whatever `containers_list` says in inventory/host_vars/*.yml — don't
restate a precise number here or in CLAUDE.md; two hand-maintained copies drift apart
(they read 44 and 49 while the real total was 52). -->
```bash
grep -c '^ - name:' ansible/inventory/host_vars/*.yml # the actual per-host tally
```
> Day-to-day conventions and the agent contract live in [`CLAUDE.md`](CLAUDE.md). Most
> directories and many roles have their own `CLAUDE.md` with role-specific notes.
The migration from Docker Compose to k3s completed on **2026-08-14**, when Docker was
uninstalled from `daniel-server`. The slice-by-slice record is in
[`docs/archive/k3s-migration/`](docs/archive/k3s-migration/) — those documents are historical and describe
work already executed.
## Hosts
| Host | Role | Notes |
|------|------|-------|
| `daniel-box` | k3s server (control plane) | Runs almost every workload. Traefik edge, Authelia SSO + OIDC issuer, Pi-hole DNS, Longhorn storage, CrowdSec. Internet-exposed via Cloudflare, and the **public WireGuard endpoint** (wg-easy, 51820/udp — the router forward moved here). Ansible runs *on* this host. |
| `daniel-server` | k3s agent node | Intel XE iGPU (Jellyfin/Tdarr transcode), LVM storage, UPS hardware + the NUT shutdown chain. **Docker uninstalled 2026-08-14** — it hosts no Compose services. |
| `daniel-pi` | Raspberry Pi — Docker | **LAN-only**, never internet-exposed or on the tunnel. A second, LAN-only wg-easy (51822/udp) + a small utility stack (Glances, autoheal, docker-proxy). The only remaining Docker host. |
## Repository layout
```
ansible/ # Playbooks, roles, inventory, templates ← EDIT HERE
deploy.yml # A Docker play with no entries, so it runs no role
k3s-bringup.yml # Cluster foundation (k3s, Longhorn, Traefik CRDs, …)
initial_setup.yml # Host hardening / bootstrap
roles/k8s/ # One role per k8s workload (manifests rendered from templates)
roles/setup/ # One role per host-setup concern (k3s, Docker engine, crons, …)
hosts/ # Host packages; the Pi's Docker services are hosts/components/pi-compose/
filter_plugins/ # Custom dependency-resolution filters (toposort.py)
inventory/ # hosts.ini, group_vars/all.yml, host_vars/<host>.yml
vars/secrets.yml # SOPS-encrypted secrets
scripts/ # Helper scripts (template validation, …)
docs/ # Runbooks, design specs, security notes
archive/ # Superseded planning docs (incl. the Docker → k3s migration)
```
**The Pi's rendered Compose project is not in this repo.** The `homelab-daniel-pi` package
installs it at `/usr/lib/homelab/compose/` on `daniel-pi`, the only Docker host; neither
cluster node runs Docker. Edits there are overwritten by the next package version: always
change `hosts/components/pi-compose/services/<svc>/` instead. The cluster's equivalent is the
manifests rendered from `ansible/roles/k8s/`.
## Ingress and segmentation
Traefik is the single ingress on `daniel-box`, routing to workloads via Traefik
`IngressRoute` CRDs; Authelia gates protected routes via forward-auth middleware and issues
OIDC tokens for apps that speak it. Cloudflare proxies the public hostnames; `*.local.<domain>`
names are resolved by Pi-hole on the LAN and are not exposed.
```mermaid
flowchart TD
net[Internet] --> cf[Cloudflare DNS + proxy]
cf --> traefik[Traefik ingress — daniel-box]
traefik -. forward-auth .-> authelia[Authelia SSO + 2FA + OIDC]
subgraph cluster["k3s cluster (daniel-box server, daniel-server agent)"]
traefik
authelia
crowdsec[CrowdSec engine + node agents]
apps["apps · pihole · n8n · code-server · karakeep · freshrss · livesync · wg-easy"]
media["media · jellyfin · arr-stack · qbittorrent · tdarr · bazarr"]
monitoring["monitoring · prometheus · grafana · scrutiny · loki"]
longhorn[(Longhorn PVs)]
end
traefik --> apps
traefik --> media
traefik --> monitoring
apps --- longhorn
media --- longhorn
monitoring --- longhorn
subgraph pi["daniel-pi — Docker, LAN-only"]
wg[wg-easy] --- alloy[Alloy · autoheal]
end
```
Network segmentation is **not** blanket-enforced: only a few roles define NetworkPolicies
(`n8n`, `prowlarr`, `registry`) — everything else is reachable pod-to-pod within the
namespace. Where policies do exist, **ingress** rules are enforced; **egress** rules are
**not** enforced by this cluster's CNI, so never read an egress policy as a control.
## How deploys work
A k3s workload deploys through `promote` and Flux once its PR merges. `./scripts/deploy.sh`
exits 5 for a k8s tag.
- **The render** — `homelab_render.py` orders `roles/k8s/<name>` with `build_k8s_dep_map` /
`toposort_containers` (`ansible/filter_plugins/toposort.py`, unit-tested in
`ansible/tests/deploy/test_toposort.py`). A role rendering a Traefik CRD gets an edge onto
`traefik`, which installs the CRDs every later `IngressRoute` depends on; `use_authelia: true` gets one onto
`authelia`, which creates the middleware other routes reference. Both are derived from the
role's own templates/entry, not hand-listed; an edge no template carries (crowdsec before
traefik, for the LAPI machine credential) is declared as `depends_on:` on the entry instead.
List order in `cluster/services.yml` is only the sort's tiebreak.
- **`deploy.yml`** — runs a Docker play that deploys nothing, because no host lists a Docker
entry: `scripts/validate/compose_templates.py:undeployed_docker_entries` refuses one. The Pi's
containers are `compose_services` entries that its `homelab-daniel-pi` package deploys, so
`deploy.yml` never starts them. The Pi's ordering, docker-proxy before autoheal and alloy, is
each service file's `depends_on`, and
`ansible/tests/deploy/test_platform_filter_real_inventory.py` pins it.
Ansible plays run against the host named by `-e target=` — but `daniel-server` and `daniel-box`
are `ansible_connection=local` in `hosts.ini`, so `-e target=` only selects whose *variables*
to use while tasks still execute locally. A pre-task refuses that case outright. Only
`daniel-pi` is genuinely remote, so the setup plays' `-e target=daniel-pi` works as it reads.
```bash
uv run ansible-playbook ansible/k3s-bringup.yml # cluster foundation
uv run ansible-playbook ansible/initial_setup.yml # host bootstrap/hardening
```
First-host bring-up (uv → SOPS onboarding → `initial_setup.yml`) is ordered in
[`ansible/README.md`](ansible/README.md); [`ansible/bring-up.sh`](ansible/bring-up.sh) drives
those steps (`--scaffold` for inventory, no flag for uv + SOPS, `--continue` for the
playbooks). The manual post-deploy setup Ansible can't do is verified by
`uv run python scripts/diagnostics/postflight.py`.
## Cross-cutting systems
- **Secrets** — `ansible/vars/secrets.yml`, encrypted with SOPS + age (`.sops.yaml`
auto-encrypts anything under `vars/`/`secrets/`). Decrypted at runtime via the
`community.sops` lookup. Edit with `sops ansible/vars/secrets.yml`. **Never commit
plaintext secrets** — gitleaks runs pre-commit. The age private key is backed up
out-of-band (single point of recovery).
- **Observability** — the Prometheus / Grafana / Loki / Tempo stack runs in-cluster from
`ansible/roles/k8s/observability/`, alongside `roles/k8s/loki-homelab` (Loki + an Alloy
DaemonSet) for homelab logs. Prometheus scrapes node-exporter / cAdvisor / Traefik /
CrowdSec. Grafana dashboards stay provisioned as code from
[`roles/k8s/observability/files/dashboards/`](ansible/roles/k8s/observability/files/dashboards/) — that
tree is the single source of truth and is mounted into the cluster Grafana, which is why
the role survives the migration despite not being in any `containers_list`.
`monitor-bridge` exports Prometheus/Longhorn signals (backup freshness, disk, memory) as
gauges that page through Alertmanager.
- **Backups** — Longhorn takes scheduled volume backups to **Backblaze B2**; the per-volume
tier (daily / weekly / no-backup) and its rationale are in
[`docs/longhorn-backup-tiering.md`](docs/longhorn-backup-tiering.md). Recovery procedure:
[`docs/longhorn-disaster-recovery.md`](docs/longhorn-disaster-recovery.md). The Pi's own
data is covered by `pi-peer-backup`. **Kopia is retired** (2026-08-13; repo deleted
2026-08-14) — [`docs/archive/kopia-disaster-recovery.md`](docs/archive/kopia-disaster-recovery.md) is kept
only as history.
- **Updates** — **Renovate** opens PRs for version-pinned images and the pinned `prek.toml`
hook revisions (see [`renovate.json`](renovate.json)); it requires installing the Renovate
GitHub App on the repo once. Watchtower was retired with the migration — image updates are
PR-driven, not automatic.
- **Security** — Authelia SSO + TOTP + OIDC, CrowdSec (cluster engine + per-node agents),
fail2ban, UFW (default-deny inbound), source-route rejection. See
[`docs/security-tools.md`](docs/security-tools.md).
## Quality gates (pre-commit)
The repo uses [`prek`](https://prek.j178.dev) (`prek.toml`): YAML/JSON lint, `ansible-lint`,
`gitleaks`, rendered-template validation (compose + config + Home Assistant + Grafana),
secret-rotation-registry sync, `ruff` (lint + format), and the `pytest` suite.
```bash
prek run --all-files
```
## Adding a service
Almost every new service is a **k8s** workload: add a role under `ansible/roles/k8s/<name>/`
rendering its manifests (Deployment/Service/IngressRoute/PVC), then an entry in
`cluster/services.yml` `containers_list` with `platform: k8s`. Position in the list
doesn't matter — the renderer toposorts on derived Traefik-CRD and authelia edges, plus any
`depends_on:` the entry declares — so add it wherever reads best. Deploy tags derive from
the name.
For the Pi's Docker services, the `new-container` skill adds a `pi-compose` service: a
`compose_services` entry in `host_vars/daniel-pi.yml` and the service's compose file. See
`CLAUDE.md` → "Adding a New Service".