Files
homelabstack/CLAUDE.md
T
ginnoirandClaude Opus 5 317a424a77 docs: correct valhalla OS and Plex packaging in CLAUDE.md
valhalla is EndeavourOS (Arch), not Ubuntu — there is no apt, only pacman.
Verified via /etc/os-release. This bit during a live session: `apt-get`
was not found when trying to install a package on the host.

Plex is likewise not a deb — it is the AUR/pacman package
plex-media-server 1.43.2.10687-1, confirmed with `pacman -Q`. Version,
systemd unit and binary path in that section were already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:09:06 -05:00

13 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 EndeavourOS (Arch) server reachable at ssh ginnoir@valhalla — package manager is pacman, there is no apt. 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 the Gitea repo https://gitea.ginnoir.com/ginnoir/homelabstack on main every 5 min and redeploys any application stack whose source files have changed. Editing here changes nothing until you push (or, for the management plane, run apply-compose.ps1 -Portainer). GitHub is a temporary push mirror only.

.env and stacks/*/stack.env are committed intentionally — secrets are versioned in the private repos. 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/               # code-server, 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 Gitea Actions or -Caddy)
├── .env                   # mgmt-plane env + vault unseal keys + global vars
├── apply-compose.ps1      # manual ops helper (mgmt plane + Caddyfile + vault unseal)
├── .gitea/workflows/deploy-caddy.yml  # Gitea Actions: pushes Caddyfile + reloads caddy on push
└── .github/workflows/deploy.yml  # disabled — Gitea Actions is primary

Deployment channels — what triggers what

Edit Deploys via Latency
stacks/<domain>/* git push to Gitea → Portainer polls every 5 min and redeploys that one stack ≤ 5 min
Caddyfile git push → Gitea Actions copies to /config/caddy/Caddyfile + docker exec caddy caddy reload seconds
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

Gitea is primary; GitHub is a temporary push mirror. Portainer app stacks must have no Portainer UI env vars — all config comes from stack.env via env_file: stack.env.

On-host topology

Path on valhalla Purpose
~/valhalla-lab/portainer-compose.yml + .env management plane (raw compose; project name valhalla-lab)
/config/portainer/compose/<id>/stacks/<domain>/ Portainer's working copy of each app stack (auto-pulled from git; /data/compose/<id>/... is the same path inside the portainer container, and root-owned on the host)
/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 /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/<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). 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.
  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.

Authentication standard

Every new service gets SSO. Pick the right pattern based on what the app supports:

Pattern A — Native OAuth2/OIDC (preferred)

If the service has built-in OAuth2/OIDC login (Immich, Paperless-ngx, Gitea, etc.):

  1. Create an Authentik OAuth2 provider via mcp__authentik__authentik_writePOST /api/v3/providers/oauth2/:

    {
      "name": "homelab-<slug>",
      "client_type": "confidential",
      "redirect_uris": [{"matching_mode": "strict", "url": "https://<slug>.ginnoir.com/auth/callback"}],
      "authorization_flow": "b88daebc-c54d-4e85-ae27-8b47384d5d74",
      "invalidation_flow": "3259f9c5-7eab-40bf-81cb-2b0ede6eb7b3",
      "property_mappings": ["718abef2-504c-452c-9ff0-329eda8031c7", "19491dd8-d239-41a4-aa48-030c1471779d", "ca45a8da-db62-41ba-b1c4-e669bf3c9a82"],
      "sub_mode": "hashed_user_id",
      "grant_types": ["authorization_code", "refresh_token"],
      "signing_key": "dc7cbd9e-c6d2-4168-b402-e7570bcb59d1"
    }
    

    IMPORTANT: grant_types and signing_key must be set explicitly — the API defaults grant_types to [] (empty = all auth requests rejected as invalid_request) and leaves signing_key null (tokens can't be signed). Use UpdateOauth2Provider to fix an existing broken provider. Note the returned pk, client_id, and client_secret.

  2. Create an Authentik applicationPOST /api/v3/core/applications/:

    {"name": "<Service Name>", "slug": "<slug>", "provider": <pk>}
    
  3. Configure the service with:

    • Discovery URL: https://auth.ginnoir.com/application/o/<slug>/
    • Client ID / Client secret from step 1
    • No Caddy forward_auth needed — the app handles login itself

Reusable Authentik IDs (do not re-derive these):

Item Value
Implicit consent flow b88daebc-c54d-4e85-ae27-8b47384d5d74
Invalidation flow 3259f9c5-7eab-40bf-81cb-2b0ede6eb7b3
Scope — openid 718abef2-504c-452c-9ff0-329eda8031c7
Scope — email 19491dd8-d239-41a4-aa48-030c1471779d
Scope — profile ca45a8da-db62-41ba-b1c4-e669bf3c9a82

To get the exact scope PKs if needed: GET /api/v3/propertymappings/scope/?search=openid.

Pattern B — Caddy forward_auth (fallback)

If the service has no native auth (sonarr, qbittorrent, etc.):

service.ginnoir.com {
  import internal_only
  route {
    import authentik_outpost
    handle /api/* { reverse_proxy service:port }   # skip auth for API keys
    handle {
      import authentik_forward_auth
      reverse_proxy service:port
    }
  }
}

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.

External services (not in compose)

These run on valhalla but are managed outside of Portainer/Docker.

Plex Media Server

  • Install: AUR/pacman package (plex-media-server), managed by systemd. Not containerised.
  • Version: 1.43.2 (Watchtower doesn't touch it — update via pacman -Syu plex-media-server or the Plex UI).
  • Service: systemctl {start,stop,restart,status} plexmediaserver
  • Binary: /usr/lib/plexmediaserver/
  • App data / metadata / DB: /var/lib/plexmediaserver/Library/Application Support/Plex Media Server/
  • Media libraries: /storage1/Media/{Movies,TV,Anime} — same ZFS pool bind-mounted into the *arr containers.
  • Tautulli (tautulli container in the media stack) reads PMS logs from /storage1/Media and connects to Plex over the LAN. It is not behind Authentik — Plex login is its own auth.
  • Caddy: Plex is not reverse-proxied through Caddy (Plex handles its own relay/direct connections). No plex.ginnoir.com block exists.

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.
  • Caddyfile reloads are handled by .gitea/workflows/deploy-caddy.yml (Gitea Actions, valhalla-management-runner). If Gitea Actions doesn't trigger, fall back to apply-compose.ps1 -Caddy. The GitHub workflow (.github/workflows/deploy.yml) is disabled.
  • famapp images are built by Gitea Actions on v* tags and pushed to registry.ginnoir.com/ginnoir/famapp. Portainer pulls from the self-hosted registry (registered in Portainer as "Valhalla Registry").
  • Portainer app stacks must have empty UI environment variables. All stack config comes from stack.env via env_file. Run scripts/check-portainer-stack-env.ps1 to verify before pushing.

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.