Portainer git-stack deploys require stack.env (not .env) in the repo. Renames all stacks/*/. env → stacks/*/stack.env so Portainer reads secrets directly from git on each redeploy, making the repo the single source of truth instead of requiring manual UI sync. Also fixes the dev stack BOOKSTACK_APP_KEY gap — the key was already present in the file but missing from Portainer's stored envVars; it will now be picked up automatically from stack.env on next redeploy. Updates CLAUDE.md to reflect the new filename convention.
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/*/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/<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>/stack.env(Portainer reads this file directly from the git repo on each redeploy). - 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 (especiallystack.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.