Files
homelabstack/CLAUDE.md
T
ginnoir 441e1ee272 chore: rename stack .env files to stack.env for Portainer git stack compatibility
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.
2026-06-04 21:41:56 -05:00

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_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:

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

  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>/stack.env (Portainer reads this file directly from the git repo on each redeploy).
  4. Add a site block to Caddyfile (reverse_proxy <container_name>:<port>); 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

  • 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.
  • 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.