docs+ops: rewrite CLAUDE.md, skills, runner for post-split topology
Post-monolith documentation and ops cleanup: - CLAUDE.md rewritten end-to-end. Documents the 11 Portainer-managed stacks + raw-compose management plane, the single shared `edge` network, the SSD-vs-ZFS bind-mount tiering, the four deployment channels (git push for app stacks, runner for Caddyfile, apply-compose.ps1 for mgmt plane / Caddyfile / vault), and the fact that the repo is now canonical. - homelab-apply skill rewritten for the new channels — no more `dc up -d`, no more monolith. - homelab-ssh skill rewritten — no `dc` alias, plain `docker` against container names; per-stack compose ops via /data/compose/<id>/... - homelab-sync skill + sync-prod.ps1 retired. The repo is canonical now; pulling from prod is the wrong direction. - .github/workflows/deploy.yml: drop the dc up -d steps, gate on paths:[Caddyfile, .github/workflows/deploy.yml], reload caddy via `docker exec` (no longer through compose). - apply-compose.ps1: drop -Compose and -DevStack flags; -Caddy now reloads via `docker exec caddy` (Caddy is in its own Portainer stack now). No live container is touched by this commit. The runner workflow is currently disabled at the repo level; re-enabling it makes Caddyfile pushes auto-deploy again.
This commit is contained in:
@@ -4,92 +4,116 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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 a version-controlled mirror of three files that live on the server. Editing here changes nothing until the files are pushed to valhalla and the stack is re-applied.
|
||||
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.
|
||||
|
||||
Tracked files ↔ where they live on valhalla:
|
||||
**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`).
|
||||
|
||||
| Repo file | On valhalla | Consumed by |
|
||||
|-----------|-------------|-------------|
|
||||
| `docker-compose.yml` | `~/htpc-download-box/docker-compose.yml` | the whole stack |
|
||||
| `.env` | `~/htpc-download-box/.env` | compose variable substitution |
|
||||
| `Caddyfile` | `/config/caddy/Caddyfile` | the `caddy` container, mounted at `/etc/caddy/Caddyfile` |
|
||||
`.env` and `stacks/*/.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.
|
||||
|
||||
`.env` is committed **intentionally** — the GitHub repo `ginnoir/homelabstack` is private and the stack's secrets are versioned with it. Do not scrub or gitignore it.
|
||||
## Layout
|
||||
|
||||
The server's `~/htpc-download-box/` contains only `docker-compose.yml` and `.env` — the legacy cruft from the original *htpc-download-box* project has been cleaned out.
|
||||
```
|
||||
.
|
||||
├── 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
|
||||
```
|
||||
|
||||
## The core workflow
|
||||
## Deployment channels — what triggers what
|
||||
|
||||
Production is the source of truth. **Sync before editing, apply after editing:**
|
||||
| Edit | Deploys via | Latency |
|
||||
|---|---|---|
|
||||
| `stacks/<domain>/*` | **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`) |
|
||||
| `/data/compose/<id>/stacks/<domain>/` | Portainer's working copy of each app stack (auto-pulled from git) |
|
||||
| `/config/<service>/` | SSD-tier persistence: app configs, Postgres data, Redis data |
|
||||
| `/storage1/labdata/<service>/` | 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.
|
||||
- `<domain>` (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
|
||||
powershell -File sync-prod.ps1 # pull prod -> repo (OVERWRITES local copies)
|
||||
# ...edit docker-compose.yml / Caddyfile / .env...
|
||||
powershell -File apply-compose.ps1 # push repo -> prod and apply (mutates the LIVE stack)
|
||||
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 <container>"
|
||||
ssh -o BatchMode=yes ginnoir@valhalla "docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
```
|
||||
|
||||
`apply-compose.ps1` flags (combine freely; no flags = all three):
|
||||
|
||||
- `-EnvFile` — push `.env` only
|
||||
- `-Caddy` — push `Caddyfile`, then hot-reload Caddy (no downtime)
|
||||
- `-Compose` — push `docker-compose.yml`, then `dc pull` + `dc up -d`
|
||||
|
||||
Caddy changes hot-reload; on `dc up -d` only containers whose resolved config changed are recreated. Cosmetic/whitespace-only compose edits are therefore no-ops to the running containers.
|
||||
|
||||
## Running commands on valhalla
|
||||
|
||||
SSH is key-based and works from this Windows host directly. In automation, pass `-o BatchMode=yes` so it fails fast instead of hanging on a prompt.
|
||||
|
||||
All compose operations go through the **`dc` alias** in the server's `~/.bashrc`:
|
||||
|
||||
```
|
||||
dc = docker compose -f ~/htpc-download-box/docker-compose.yml --env-file ~/htpc-download-box/.env
|
||||
```
|
||||
|
||||
It's a shell alias, so it only exists in an **interactive** shell — invoke it over SSH with `bash -ic`:
|
||||
For per-stack compose ops, pick the file from the container's label:
|
||||
|
||||
```powershell
|
||||
ssh ginnoir@valhalla "bash -ic 'dc ps'"
|
||||
ssh ginnoir@valhalla "bash -ic 'dc logs -f --tail=100 sonarr'"
|
||||
ssh ginnoir@valhalla "bash -ic 'dc restart caddy'"
|
||||
ssh ginnoir@valhalla "bash -ic 'dc exec caddy caddy reload --config /etc/caddy/Caddyfile'"
|
||||
ssh ginnoir@valhalla "docker inspect <container> --format '{{ index .Config.Labels \"com.docker.compose.project.config_files\"}}'"
|
||||
ssh ginnoir@valhalla "docker compose -f /data/compose/14/stacks/owncloud/docker-compose.yml ps"
|
||||
```
|
||||
|
||||
Address services by their **compose service name** (`sonarr`, `caddy`, `app`, …) — this works regardless of how the container is named.
|
||||
## Adding a service
|
||||
|
||||
## docker-compose.yml — read before editing
|
||||
|
||||
- **Compose v2** (`docker compose`, space), installed as the `docker-compose-plugin` apt package and kept current via apt. Project name is `htpc-download-box` (from the directory — inconsequential since every service now has an explicit `container_name:`).
|
||||
- **Container names:** every service has an explicit `container_name:`. Disambiguated duplicates use `type_context` naming: `postgres_resume`, `postgres_famapp`, `postgres_authentik`, `minio_resume`, `minio_famapp`, `redis_authentik`. Always use the compose **service name** with `dc` commands — container names are just for `docker ps` readability.
|
||||
- **`${ROOT}`** (`.env`, default `/`) prefixes some media-stack volume mounts; other services hardcode `/config` and `/storage1`. Both forms resolve to the same real paths.
|
||||
- **Networks segment the stack:** `foundry`, `media`, `media_external`, `owncloud`, `resume`, `famapp`, `authentik`. `caddy` is the only container on all of them — a new service that Caddy must proxy has to share a network with `caddy`.
|
||||
- **Persistence lives on the server, not in this repo:** container state is in `/config/<service>` bind mounts, under `/storage1`, or in named volumes (`postgres_data`, `minio_data`, `authentik_db_data`, `garden_uploads`, …).
|
||||
1. Pick the stack it belongs to under `stacks/<domain>/` (or create a new stack and a new Portainer git stack for it).
|
||||
2. Add the service to `stacks/<domain>/docker-compose.yml`. Configs → `/config/<svc>` bind. Blobs → `/storage1/labdata/<svc>` bind. Add `edge` to its `networks:` if Caddy must reach it; otherwise just the per-stack private net.
|
||||
3. Add secrets to `stacks/<domain>/.env`.
|
||||
4. Add a site block to `Caddyfile` (`reverse_proxy <container_name>:<port>`); `import internal_only` for LAN-only.
|
||||
5. `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. Most sites are `<name>.ginnoir.com → reverse_proxy <service>:<port>`.
|
||||
- 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.
|
||||
- Reverse-proxy upstreams use the compose **service name + container-internal port** (e.g. `qbittorrent:3232`, `stash:6970`), not the host-published port.
|
||||
|
||||
## Adding a service (the common change)
|
||||
|
||||
1. Add the service to `docker-compose.yml`; put it on a network `caddy` is also on if it needs proxying; bind config to `/config/<service>`.
|
||||
2. Add any secrets/vars to `.env`.
|
||||
3. Add a site block to `Caddyfile` (`reverse_proxy <service>:<port>`); add `import internal_only` for LAN-only access.
|
||||
4. Run `apply-compose.ps1` (or `-Compose -Caddy`), then verify with `dc ps` / `dc logs <service>`.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Reactive Resume's `app` reads `${RESUME_AUTH_SECRET}`, which is **not defined in `.env`** (resolves empty). `.env` also defines `AUTH_SECRET` three times — last definition wins (famapp's).
|
||||
- `watchtower` auto-updates `:latest` images, so a running image can drift ahead of what the last `apply` pulled.
|
||||
- Line endings: a `.gitattributes` forces **LF** so files stay Unix-clean. Pushing CRLF (especially in `.env`) to the Linux host would append stray `\r` to values and break things.
|
||||
- Line endings: `.gitattributes` forces **LF** so files stay Unix-clean. Pushing CRLF (especially `.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 operations:
|
||||
Project skills in `.claude/skills/` wrap the recurring ops:
|
||||
|
||||
- `homelab-sync` — pull prod configs into the repo
|
||||
- `homelab-apply` — push config + apply to the live stack
|
||||
- `homelab-ssh` — run commands / inspect state on valhalla
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user