# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this repo is Deployment configuration for a **live, single-host Docker homelab** running on a headless Ubuntu server reachable at `ssh ginnoir@valhalla`. This repo is **not application code** — it is the canonical source for ~50 containers organized into per-domain Portainer-managed stacks. **The repo is canonical.** Portainer polls the **Gitea** repo `https://gitea.ginnoir.com/ginnoir/homelabstack` on `main` every 5 min and redeploys any application stack whose source files have changed. Editing here changes nothing until you push (or, for the management plane, run `apply-compose.ps1 -Portainer`). GitHub is a temporary push mirror only. `.env` and `stacks/*/stack.env` are committed **intentionally** — secrets are versioned in the private repos. Do not scrub or gitignore them. ## Layout ``` . ├── stacks/ # one directory per Portainer-managed application stack │ ├── proxy/ # caddy │ ├── media/ # *arr stack, deluge, qbittorrent, stash, ... │ ├── foundry/ # foundry, foundry2, 5etools │ ├── owncloud/ # owncloud + its mariadb + redis │ ├── resume/ # reactive-resume + its postgres + minio + chrome │ ├── famapp/ # famapp + its postgres + minio │ ├── authentik/ # authentik server/worker + its postgres + redis │ ├── notify/ # ntfy, freshrss, vigilant │ ├── monitoring/ # uptime-kuma, homarr │ ├── remote/ # rustdesk hbbr + hbbs │ └── dev/ # code-server, bookstack, dbx, plane ├── portainer-compose.yml # management plane (raw compose, not a Portainer stack) ├── vault.hcl # vault config (deployed alongside portainer-compose.yml) ├── Caddyfile # caddy config (deployed via Gitea Actions or -Caddy) ├── .env # mgmt-plane env + vault unseal keys + global vars ├── apply-compose.ps1 # manual ops helper (mgmt plane + Caddyfile + vault unseal) ├── .gitea/workflows/deploy-caddy.yml # Gitea Actions: pushes Caddyfile + reloads caddy on push └── .github/workflows/deploy.yml # disabled — Gitea Actions is primary ``` ## Deployment channels — what triggers what | Edit | Deploys via | Latency | |---|---|---| | `stacks//*` | **git push to Gitea** → Portainer polls every 5 min and redeploys that one stack | ≤ 5 min | | `Caddyfile` | git push → Gitea Actions copies to `/config/caddy/Caddyfile` + `docker exec caddy caddy reload` | seconds | | `portainer-compose.yml` / `vault.hcl` | `apply-compose.ps1 -Portainer` (Portainer can't manage itself) | seconds | | `~/valhalla-lab/.env` (mgmt-plane env, vault unseal keys) | `apply-compose.ps1 -EnvFile` | seconds | Gitea is primary; GitHub is a temporary push mirror. Portainer app stacks must have **no Portainer UI env vars** — all config comes from `stack.env` via `env_file: stack.env`. ## On-host topology | Path on valhalla | Purpose | |---|---| | `~/valhalla-lab/portainer-compose.yml` + `.env` | management plane (raw compose; project name `valhalla-lab`) | | `/config/portainer/compose//stacks//` | Portainer's working copy of each app stack (auto-pulled from git; `/data/compose//...` is the same path *inside* the portainer container, and root-owned on the host) | | `/config//` | SSD-tier persistence: app configs, Postgres data, Redis data | | `/storage1/labdata//` | ZFS-tier persistence: MinIO blob buckets, registry image layers, gitea repos/LFS | | `/storage1/` | media library (Books, Tabletop, plex-style media) | | `/config/caddy/Caddyfile` | live Caddy config (deployed from this repo) | | `/config/vault/` | Vault config + sealed data + audit logs | The pre-split monolith's named volumes (`htpc-download-box_*` and `valhalla-lab_*`) are still on the host as a safety net — every app now uses bind mounts. Reap the orphans once you trust the new persistence. ## Networks Single shared edge network, not Caddy-on-every-network: - `edge` — the **only** reverse-proxy network. Caddy + everything Caddy proxies. A new public service must join this. - `` (e.g. `media`, `authentik`, `dev`) — per-stack private network for intra-stack traffic (app ↔ its db/cache/minio). - `portainer_proxy`, `valhalla-lab_default` — mgmt plane (`valhalla-lab_default` is auto-named after the project dir; it's mis-named but legitimate). - `bridge`, `host`, `none` — Docker defaults. `reverse_proxy` upstreams use container name + container-internal port (e.g. `qbittorrent:3232`, `stash:6970`), resolving over `edge`. ## Common ops on valhalla There is **no `dc` alias** anymore. Each stack has its own compose dir. Use plain `docker` against container names: ```powershell ssh -o BatchMode=yes ginnoir@valhalla "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}'" ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 200 -f " ssh -o BatchMode=yes ginnoir@valhalla "docker exec caddy caddy reload --config /etc/caddy/Caddyfile" ``` For per-stack compose ops, pick the file from the container's label: ```powershell ssh ginnoir@valhalla "docker inspect --format '{{ index .Config.Labels \"com.docker.compose.project.config_files\"}}'" ssh ginnoir@valhalla "docker compose -f /config/portainer/compose/14/stacks/owncloud/docker-compose.yml ps" # root-owned; needs sudo or a helper container ``` ## Adding a service 1. Pick the stack it belongs to under `stacks//` (or create a new stack and a new Portainer git stack for it). 2. Add the service to `stacks//docker-compose.yml`. Configs → `/config/` bind. Blobs → `/storage1/labdata/` bind. Add `edge` to its `networks:` if Caddy must reach it; otherwise just the per-stack private net. 3. Add secrets to `stacks//stack.env` (Portainer reads this file directly from the git repo on each redeploy). **Env convention:** every service gets `env_file: stack.env`, and var names in `stack.env` are exactly what the container reads — no `${VAR}` interpolation, no Portainer UI env vars. Composite values (e.g. `DATABASE_URL`) are written out in full in `stack.env`; keep embedded components in sync on rotation. 4. Add a site block to `Caddyfile` (`reverse_proxy :`); `import internal_only` for LAN-only. 5. Run `./scripts/gen-bookmarks.ps1` to regenerate `bookmarks-domains.html` + `bookmarks-ports.html` from the new Caddy block / published ports. 6. `git push`. Portainer redeploys the stack within 5 min; the runner reloads Caddy on the same push if `Caddyfile` changed. ## Authentication standard Every new service gets SSO. Pick the right pattern based on what the app supports: ### Pattern A — Native OAuth2/OIDC (preferred) If the service has built-in OAuth2/OIDC login (Immich, Paperless-ngx, Gitea, etc.): 1. **Create an Authentik OAuth2 provider** via `mcp__authentik__authentik_write` → `POST /api/v3/providers/oauth2/`: ```json { "name": "homelab-", "client_type": "confidential", "redirect_uris": [{"matching_mode": "strict", "url": "https://.ginnoir.com/auth/callback"}], "authorization_flow": "b88daebc-c54d-4e85-ae27-8b47384d5d74", "invalidation_flow": "3259f9c5-7eab-40bf-81cb-2b0ede6eb7b3", "property_mappings": ["718abef2-504c-452c-9ff0-329eda8031c7", "19491dd8-d239-41a4-aa48-030c1471779d", "ca45a8da-db62-41ba-b1c4-e669bf3c9a82"], "sub_mode": "hashed_user_id", "grant_types": ["authorization_code", "refresh_token"], "signing_key": "dc7cbd9e-c6d2-4168-b402-e7570bcb59d1" } ``` **IMPORTANT:** `grant_types` and `signing_key` must be set explicitly — the API defaults `grant_types` to `[]` (empty = all auth requests rejected as `invalid_request`) and leaves `signing_key` null (tokens can't be signed). Use `UpdateOauth2Provider` to fix an existing broken provider. Note the returned `pk`, `client_id`, and `client_secret`. 2. **Create an Authentik application** → `POST /api/v3/core/applications/`: ```json {"name": "", "slug": "", "provider": } ``` 3. **Configure the service** with: - Discovery URL: `https://auth.ginnoir.com/application/o//` - Client ID / Client secret from step 1 - No Caddy forward_auth needed — the app handles login itself **Reusable Authentik IDs** (do not re-derive these): | Item | Value | |---|---| | Implicit consent flow | `b88daebc-c54d-4e85-ae27-8b47384d5d74` | | Invalidation flow | `3259f9c5-7eab-40bf-81cb-2b0ede6eb7b3` | | Scope — openid | `718abef2-504c-452c-9ff0-329eda8031c7` | | Scope — email | `19491dd8-d239-41a4-aa48-030c1471779d` | | Scope — profile | `ca45a8da-db62-41ba-b1c4-e669bf3c9a82` | To get the exact scope PKs if needed: `GET /api/v3/propertymappings/scope/?search=openid`. ### Pattern B — Caddy forward_auth (fallback) If the service has no native auth (sonarr, qbittorrent, etc.): ```caddy service.ginnoir.com { import internal_only route { import authentik_outpost handle /api/* { reverse_proxy service:port } # skip auth for API keys handle { import authentik_forward_auth reverse_proxy service:port } } } ``` ## Caddyfile - TLS uses the Cloudflare DNS-01 challenge (`acme_dns cloudflare {env.CF_API_TOKEN}`), so certs need no inbound ports. - The `(internal_only)` snippet 403s any client outside `192.168.1.0/24`. `import internal_only` is how admin UIs (sonarr, qbittorrent, ...) stay LAN-only while public sites omit it. - Upstreams use container name + container-internal port (e.g. `qbittorrent:3232`), not host-published ports. ## External services (not in compose) These run on valhalla but are managed outside of Portainer/Docker. ### Plex Media Server - **Install:** deb package (`plexmediaserver`), managed by systemd. Not containerised. - **Version:** 1.43.2 (Watchtower doesn't touch it — update via `apt upgrade plexmediaserver` or the Plex UI). - **Service:** `systemctl {start,stop,restart,status} plexmediaserver` - **Binary:** `/usr/lib/plexmediaserver/` - **App data / metadata / DB:** `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/` - **Media libraries:** `/storage1/Media/{Movies,TV,Anime}` — same ZFS pool bind-mounted into the *arr containers. - **Tautulli** (`tautulli` container in the media stack) reads PMS logs from `/storage1/Media` and connects to Plex over the LAN. It is **not** behind Authentik — Plex login is its own auth. - **Caddy:** Plex is not reverse-proxied through Caddy (Plex handles its own relay/direct connections). No `plex.ginnoir.com` block exists. ## Known quirks / gotchas - Portainer stack #6 `valhalla-lab` is **registration-only** (visibility in the Portainer UI). It has no auto-update and no git credentials on purpose — the management plane deploys via `apply-compose.ps1 -Portainer`, never via Portainer git. Don't "fix" it by re-enabling polling, and don't click *pull and redeploy* on it (its `docker-compose.yml` path no longer exists at repo root). - A **GateGuard hook** blocks the first use of `Bash`, and every `Write`/`Edit`, until you state the required facts (the user request + what the operation does/affects). State them, then retry the same call. - `Caddyfile` proxies `matrix.ginnoir.com → localhost:8008`, but there is **no Matrix/Synapse service in compose** — it's external/legacy. Likewise `dev.ginnoir.com → 192.168.1.74:3000` points at a different LAN host. - `watchtower` auto-updates `:latest` images, so a running image can drift ahead of what the last `apply` pulled. **Infra images** (Postgres, MariaDB, Redis, MinIO, CouchDB, Caddy, Vault) are **version-pinned** in compose with `com.centurylabs.watchtower.enable=false`; bump tags manually every few months. - Line endings: `.gitattributes` forces **LF** so files stay Unix-clean. Pushing CRLF (especially `stack.env`) to the Linux host appends stray `\r` to values and breaks things. - **Caddyfile reloads** are handled by `.gitea/workflows/deploy-caddy.yml` (Gitea Actions, `valhalla-management-runner`). If Gitea Actions doesn't trigger, fall back to `apply-compose.ps1 -Caddy`. The GitHub workflow (`.github/workflows/deploy.yml`) is disabled. - **famapp images** are built by Gitea Actions on `v*` tags and pushed to `registry.ginnoir.com/ginnoir/famapp`. Portainer pulls from the self-hosted registry (registered in Portainer as "Valhalla Registry"). - **Portainer app stacks must have empty UI environment variables.** All stack config comes from `stack.env` via `env_file`. Run `scripts/check-portainer-stack-env.ps1` to verify before pushing. ## Skills Project skills in `.claude/skills/` wrap the recurring ops: - `homelab-apply` — deploy changes (git push for app stacks; `apply-compose.ps1` for mgmt plane / Caddyfile / vault) - `homelab-ssh` — inspect and operate the live containers from SSH The pre-split `homelab-sync` skill (pull live config into repo) has been retired — the repo is now canonical, not a mirror.