7.8 KiB
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/*/.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/<domain>/* |
git push → Portainer polls every 5 min and redeploys that one stack | ≤ 5 min |
Caddyfile |
git push → runner workflow scps 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_defaultis 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:
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"
For per-stack compose ops, pick the file from the container's label:
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"
Adding a service
- Pick the stack it belongs to under
stacks/<domain>/(or create a new stack and a new Portainer git stack for it). - Add the service to
stacks/<domain>/docker-compose.yml. Configs →/config/<svc>bind. Blobs →/storage1/labdata/<svc>bind. Addedgeto itsnetworks:if Caddy must reach it; otherwise just the per-stack private net. - Add secrets to
stacks/<domain>/.env. - Add a site block to
Caddyfile(reverse_proxy <container_name>:<port>);import internal_onlyfor LAN-only. - Run
./scripts/gen-bookmarks.ps1to regeneratebookmarks-domains.html+bookmarks-ports.htmlfrom the new Caddy block / published ports. git push. Portainer redeploys the stack within 5 min; the runner reloads Caddy on the same push ifCaddyfilechanged.
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 outside192.168.1.0/24.import internal_onlyis 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 everyWrite/Edit, until you state the required facts (the user request + what the operation does/affects). State them, then retry the same call. Caddyfileproxiesmatrix.ginnoir.com → localhost:8008, but there is no Matrix/Synapse service in compose — it's external/legacy. Likewisedev.ginnoir.com → 192.168.1.74:3000points at a different LAN host.watchtowerauto-updates:latestimages, so a running image can drift ahead of what the lastapplypulled.- Line endings:
.gitattributesforces LF so files stay Unix-clean. Pushing CRLF (especially.env) to the Linux host appends stray\rto values and breaks things. - The runner workflow (
.github/workflows/deploy.yml) may be disabled at the GitHub repo level — checkgh workflow listif 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.ps1for 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.