Switch files.ginnoir.com from ownCloud to Nextcloud (internal-only). Fix Authentik scope PKs in CLAUDE.md — the last UUID segments were wrong and would have caused provider creation failures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 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 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_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 /config/portainer/compose/14/stacks/owncloud/docker-compose.yml ps" # root-owned; needs sudo or a helper container
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). Env convention: every service getsenv_file: stack.env, and var names instack.envare exactly what the container reads — no${VAR}interpolation, no Portainer UI env vars. Composite values (e.g.DATABASE_URL) are written out in full instack.env; keep embedded components in sync on rotation. - 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.
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.):
-
Create an Authentik OAuth2 provider via
mcp__authentik__authentik_write→POST /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-...", "19491dd8-...", "ca45a8da-..."], "sub_mode": "hashed_user_id", "grant_types": ["authorization_code", "hybrid", "implicit", "client_credentials", "password", "urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], "signing_key": "dc7cbd9e-c6d2-4168-b402-e7570bcb59d1" }IMPORTANT:
grant_typesandsigning_keymust be set explicitly — the API defaultsgrant_typesto[](empty = all auth requests rejected asinvalid_request) and leavessigning_keynull (tokens can't be signed). Note the returnedpk,client_id, andclient_secret. -
Create an Authentik application →
POST /api/v3/core/applications/:{"name": "<Service Name>", "slug": "<slug>", "provider": <pk>} -
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
- Discovery URL:
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 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.
External services (not in compose)
These run on valhalla but are managed outside of Portainer/Docker.
Plex Media Server
- Install: deb package (
plexmediaserver), managed by systemd. Not containerised. - Version: 1.43.2 (Watchtower doesn't touch it — update via
apt upgrade plexmediaserveror 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 (
tautullicontainer in the media stack) reads PMS logs from/storage1/Mediaand 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.comblock exists.
Known quirks / gotchas
- Portainer stack #6
valhalla-labis registration-only (visibility in the Portainer UI). It has no auto-update and no git credentials on purpose — the management plane deploys viaapply-compose.ps1 -Portainer, never via Portainer git. Don't "fix" it by re-enabling polling, and don't click pull and redeploy on it (itsdocker-compose.ymlpath no longer exists at repo root). - 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. Infra images (Postgres, MariaDB, Redis, MinIO, CouchDB, Caddy, Vault) are version-pinned in compose withcom.centurylabs.watchtower.enable=false; bump tags manually every few months.- Line endings:
.gitattributesforces LF so files stay Unix-clean. Pushing CRLF (especiallystack.env) to the Linux host appends stray\rto 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 toapply-compose.ps1 -Caddy. The GitHub workflow (.github/workflows/deploy.yml) is disabled. - famapp images are built by Gitea Actions on
v*tags and pushed toregistry.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.envviaenv_file. Runscripts/check-portainer-stack-env.ps1to verify before pushing.
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.