# 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 this repo's `main` branch every 5 min and redeploys any application stack whose source files have changed. Editing here changes nothing until you push to GitHub (or, for the management plane, run `apply-compose.ps1 -Portainer`). `.env` and `stacks/*/stack.env` are committed **intentionally** — the GitHub repo `ginnoir/homelabstack` is private and the stack's secrets are versioned with it. 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/ # gitea, code-server, registry, 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 runner workflow or -Caddy) ├── .env # mgmt-plane env + vault unseal keys + global vars ├── apply-compose.ps1 # manual ops helper (mgmt plane + Caddyfile + vault unseal) └── .github/workflows/deploy.yml # runner: pushes Caddyfile + reloads caddy on push ``` ## Deployment channels — what triggers what | Edit | Deploys via | Latency | |---|---|---| | `stacks//*` | **git push** → Portainer polls every 5 min and redeploys that one stack | ≤ 5 min | | `Caddyfile` | git push → runner workflow `scp`s to `/config/caddy/Caddyfile` + `docker exec caddy caddy reload` | seconds (when workflow is enabled) | | `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 | The runner workflow gate-filters on `paths: [Caddyfile, .github/workflows/deploy.yml]` so non-Caddyfile pushes don't trigger it. ## 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. Exception: `stacks/dev` still uses `${VAR}` interpolation backed by Portainer UI env vars — when editing its `stack.env`, mirror the change in the stack's UI env vars too. 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. ## 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. ## 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. - The runner workflow (`.github/workflows/deploy.yml`) may be **disabled** at the GitHub repo level — check `gh workflow list` if Caddyfile pushes don't trigger a reload. Manual fallback: `apply-compose.ps1 -Caddy`. ## 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.