New services: all in except TB-020 Mealie (deferred to famapp).
Active interest: TB-015 Vault, TB-027 Jellyfin, TB-028 HA, TB-031 Pingvin.
Dropped: TB-017 MinIO consolidation, TB-018 Plane, TB-029 Firefly III,
TB-030 AdGuard (handled at router), TB-032 Wiki.js (using BookStack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Image moved from ghcr.io/jorenn92/maintainerr to ghcr.io/maintainerr/maintainerr
with the v3.x release series. v3 runs its own DB migrations on startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace sctx/overseerr:latest with ghcr.io/seerr-team/seerr:latest.
Same port (5055), same config volume (/config/overseerr:/app/config) —
Seerr auto-migrates the DB on first boot. Add init: true as required.
Rename container overseerr → seerr; update Caddy upstream to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Mount /config/maintainerr to /opt/data (actual DB location, not /opt/maintainerr/data)
- Add NODE_TLS_REJECT_UNAUTHORIZED=0 so Node.js accepts Plex's self-signed cert
- Mark TB-007/008/009/012/013/016 tasks as done/dropped in improvement plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch CLAUDE.md and AGENTS.md to Gitea-primary language. Remove
github-runner from the management plane now that Gitea Actions handles
Caddy reloads and famapp builds. Disable GitHub deploy workflow.
Update apply-compose.ps1 status text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch famapp container from ghcr.io/ginnoir/famapp:latest to
registry.ginnoir.com/ginnoir/famapp:latest.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Portainer go-git resolves gitea.ginnoir.com inside Docker. Alias was on
the gitea container → port 443 refused. Moved alias to Caddy so go-git
reaches Caddy:443 (TLS ok) → proxies to gitea:3000.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Intermediate step on the way to 2026.5.2 — Authentik requires passing
through the latest 2025 release before jumping to 2026.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TB-006 forward_auth blocks machine clients with HTML/302; route /api on *arr
sites and /api plus socket.io on uptime (LAN/tailnet only) before forward_auth.
Also add Cursor interview-first rule and small Authentik/FreshRSS debug helpers.
Adds a named matcher @plane_api that matches /api/* requests carrying
X-API-Key, routing them directly to plane_api:8000 without hitting the
Authentik outpost. Browser/UI sessions still go through forward_auth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Recyclarr to the media stack, pin database/object-store/Caddy/Vault images with Watchtower disabled, expose JDownloader at jd.ginnoir.com, and restrict minio/homarr/docs to internal_only. Mark TB-002–004 and TB-033 complete; bundle batch-3 OIDC env for FreshRSS, ownCloud, and Reactive Resume.
Media sites get Caddy forward_auth; Authentik scripts cover all proxy providers. BookStack OIDC and Plane admin routing fixes included, plus ops scripts for external auth on valhalla.
Native OAuth/OIDC for Homarr, BookStack, Gitea, and MinIO console; forward_auth with local auth disabled for code-server, uptime, and kopia; Caddy and Authentik scripts updated to match.
Homarr-labs stores state under /appdata; the old compose mapped empty paths and lost the dashboard on redeploy. TB-006 batch 1 adds Caddy forward_auth for 12 admin UIs with API and webhook bypasses.
Newer hashicorp/vault:latest mlocks more memory than docker's default
64KB memlock ulimit allows; vault crash-looped for ~5900 restarts with
'Failed to lock memory: cannot allocate memory' despite cap_add IPC_LOCK.
Deployed via apply-compose.ps1 -Portainer (mgmt plane, not a git stack).
Every service now reads stack.env via env_file with container-exact var
names — no ${VAR} parse-time interpolation, no Portainer UI env vars
(except stacks/dev, kept on UI-env interpolation as documented exception).
- media: drop PUID/PGID/TZ env lines (env_file covers); resolve ${ROOT}
- foundry: secrets via env_file; FOUNDRY_PATCH_URLS -> CONTAINER_PATCH_URLS
- monitoring: HOMARR_SECRET_ENCRYPTION_KEY -> SECRET_ENCRYPTION_KEY
- notify: FRESHRSS_USER composed in stack.env; TZ via env_file
- owncloud: container-exact OWNCLOUD_*/MYSQL_* keys; runtime $$ healthcheck
- resume/famapp: composites (DATABASE_URL etc.) written out in stack.env
- authentik: container-exact keys; image tag pinned in compose
- notes: couchdb creds via env_file
- remote: gains stack.env (no secrets; convention completeness)
- CLAUDE.md: env convention, valhalla-lab registration-only note, correct
on-host Portainer working-copy path (/config/portainer/compose/<id>)
Validated with docker compose config on valhalla for all 15 stacks.
handoff.json lists files captured at routing time, but the
extraction-cruft cleanup later removes files extracted out of a kept
archive. The Caddy file_server can only serve real files (not archive
interiors), so existence-filter the art + guide rel-paths against
library/<slug>/ before embedding/linking them — dropping phantom
entries that would 404.
handoff.py classifies completed downloads (console rom vs RPG-Maker fangame vs
patch, via bsdtar archive peek incl. RAR5), copies console roms into
Emulation/roms/<platform> for RomM, moves the full folder (rom + guides) into
the library archive, and renders note.md + handoff.json per hack for the
Obsidian publish step. Wired into the cycle; single /data mount keeps moves as
fast renames. Validated on 4 real games.
process_file now marks messages processed only after a real crawljob write, so
DRY_RUN no longer poisons the state and link-less posts get re-checked after the
host filter widens. State file reset alongside this so the arm cycle re-enqueues
all threads.
The tyrrrz/discordchatexporter image is Alpine, not Debian, and ships DCE as a
self-contained executable (no dotnet on PATH). Use apk for python3 and invoke
/opt/app/DiscordChatExporter.Cli directly.
Inert by default: empty token keeps the orchestrator idle and DRY_RUN=true
suppresses downloads. Orchestrator exports per-generation forum threads via
DiscordChatExporter, extracts download links + art, stages metadata, and writes
JDownloader crawljobs. Armed here with an alt account token and all 11 romhack
forums, still in DRY_RUN pending first-cycle validation.
scripts/deck-sgdb-art.py — runs on Deck; fetches hero, wide capsule,
and portrait/poster art from SGDB for all RomM-synced shortcuts.
scripts/deck-sgdb-art.ps1 — Windows wrapper; handles SSH ASKPASS
upload and execution.
.claude/skills/deck-sgdb/ — Claude skill documenting invocation,
file naming conventions, appid formula, and known gotchas.
External access for a trusted friend now goes through the share stack's
Tailscale serve node (roms-share.<tailnet>.ts.net), so roms.ginnoir.com
no longer needs public exposure. Add import internal_only (LAN + tailnet).
Portainer's git-stack checkout does not materialize sibling repo files
at the relative bind path, so ./serve-roms.json mounted as an empty
auto-created directory and tailscale serve failed ('is a directory').
Switch to an absolute /config/share/serve-roms.json bind (matching every
other stack's /config convention); the file is mirrored on the host.
Adds stacks/share/ — a dedicated stack of userspace Tailscale 'serve'
nodes that expose individual internal services to external tailnet
users (a friend on his own tailnet) over each node's 100.x identity.
No public exposure, no LAN access, immune to the friend's home-subnet
addressing. First node ts-roms serves RomM at roms-share.<tailnet>.ts.net
-> romm:8080. One reusable tag:share auth key + one ACL rule cover every
node; adding a service is a serve-<svc>.json + a copied service block.
roms stack reverted to a pointer comment.
decky-romm-sync has completed its first-run token mint, so the
Authorization: Bearer (empty token) probe that crashed RomM 4.8.x no
longer occurs. Collapse roms.ginnoir.com back to a plain reverse_proxy.
Tailnet clients that reach Caddy without subnet-route SNAT arrive from
100.64.0.0/10 and were getting 403 from LAN-only site blocks. Add the
range to the internal_only matcher so split-DNS tailnet access works
regardless of the --snat-subnet-routes setting.
RomM 4.8.x crashes with HTTP 500 on a malformed Authorization header:
hybrid_auth.py unpacks `scheme, token = header.split()` with no guard, so
any value without exactly two space-separated parts raises ValueError.
decky-romm-sync >=0.20.0 (Client API Token auth, added 2026-06-05) sends
`Authorization: Bearer ` with an empty token on its first-run heartbeat/
platforms/devices probes, before it has minted a token, which trips that
crash and deadlocks setup. The Basic-auth mint (POST /api/client-tokens)
is never reached.
Strip the empty-token header for the roms host so the probe gets a clean
200 and the mint can run; thereafter the plugin sends a real Bearer rmm_
token. Well-formed Bearer tokens and browser session-cookie auth are
unaffected. Remove once the plugin omits the empty header or RomM guards
the split.
The Pokémon catalog + pipeline are moved out of this infra repo. The catalog
notes now live in the self-hosted Obsidian vault (Pokémon ROM Hacks/); the
acquisition/patching scripts live in their own repo at Documents\pokemon.
homelabstack stays focused on the homelab.
- rm pokemon-romhack-vault/ (75 hack notes + Index/Types/README/Platforms)
- rm scripts/{build-romhack-vault,romhack-import,romhack-fetch,romhack-apply}.py
- rm pokemon-romhacks-wanted.md
- .gitignore: drop the now-dead pokemon/ drop-folder rule
- .claude/skills/vault/SKILL.md: condense the Pokemon-vault subtree to a one-line pointer
RomM stack (stacks/roms) and the general igir library scripts stay.
Adds the ROM Hacks folder tree to the vault structure reference so
future sessions know where _Claude.md, Index.md, Play Queue, Bases/,
and Platforms/ live without re-deriving it.
build-romhack-vault.py generates pokemon-romhack-vault/: one note per hack
(YAML properties + tags + summary + links), an Index MOC (Dataview + static
table), a Types taxonomy, and per-platform MOCs. Dataset is the single source
of truth — edit + re-run to extend.
stacks/notes/: new stack with CouchDB 3 for obsidian-livesync sync backend.
Vault data at /config/couchdb/data; CORS must be configured post-deploy
via Fauxton (/_utils). Claude integration via CouchDB REST API documented
in compose comments.
Caddyfile: obsidian.ginnoir.com → couchdb:5984 (public for mobile sync).
romhack-import.py: classifies a mixed drop folder (ROMs/patches/docs/archives),
clean-names ROMs to 'Pokemon - <Hack> (Hack)', auto-detects patch base via
BPS/UPS CRC, recurses zips/rar, archives patches+docs. Imported the pokemon/
drop (now gitignored) into the valhalla library + Following Renegade Platinum.
Updated wanted-list for newly acquired hacks.
igir-dryrun.sh: read-only igir report for one platform vs given DAT(s),
prints an identified%% summary + sample of unmatched files (the scoring
step of the platform sweep).
igir-dorename.sh: igir move to canonical names in place for one platform,
with before/after manifests for reversibility and --dir-game-subdir never
to keep folders flat. Cart-safe; not for disc systems with .m3u.
igir-report.ps1: read-only igir report over the EmuDeck tree on valhalla,
matching ROMs by checksum against libretro's No-Intro + Redump DAT sets
(sparse-cloned + cached). Scoped (-Platforms) or whole-library (-All);
CSV per run under /storage1/igir/reports. Uses node:lts (alpine breaks
igir on musl) and metadat/no-intro (not the curated dat/ folder).
igir-strip-snes-headers.sh: reversible backup-then-strip of 512-byte SNES
copier headers (size%1024==512) that igir can't auto-detect, renaming to
.sfc so headered ROMs match headerless No-Intro.
Portainer git-stack deployments require an explicit env_file reference
in the compose file to load variables from the repo's stack.env.
Adds env_file: [stack.env] before every service-level environment:
block across all 10 stacks.
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.
linuxserver/bookstack refuses to start without APP_KEY:
'The application key is missing, halting init!'
nginx never bound :80, so caddy -> bookstack:80 -> connection refused -> 502.
Key generated via 'docker run --rm --entrypoint /bin/bash
lscr.io/linuxserver/bookstack:latest appkey' on valhalla. Stored in
stacks/dev/.env per per-stack secrets policy; do NOT rotate without
invalidating existing sessions.
scripts/gen-bookmarks.ps1 parses Caddyfile (site blocks grouped by
banner comments) and stacks/*/docker-compose.yml (services with
published ports) into two Netscape-format HTML bookmark files importable
by any browser. Run after editing Caddyfile or stack compose; commit the
regenerated HTMLs alongside the source change.
tls_insecure_skip_verify inside transport http implicitly opts the
upstream connection into TLS. code-server (lscr.io/linuxserver) serves
plain HTTP on 8443, so Caddy's TLS handshake failed with
'tls: first record does not look like a TLS handshake' -> 502.
Post-monolith documentation and ops cleanup:
- CLAUDE.md rewritten end-to-end. Documents the 11 Portainer-managed stacks
+ raw-compose management plane, the single shared `edge` network, the
SSD-vs-ZFS bind-mount tiering, the four deployment channels (git push
for app stacks, runner for Caddyfile, apply-compose.ps1 for mgmt plane /
Caddyfile / vault), and the fact that the repo is now canonical.
- homelab-apply skill rewritten for the new channels — no more `dc up -d`,
no more monolith.
- homelab-ssh skill rewritten — no `dc` alias, plain `docker` against
container names; per-stack compose ops via /data/compose/<id>/...
- homelab-sync skill + sync-prod.ps1 retired. The repo is canonical now;
pulling from prod is the wrong direction.
- .github/workflows/deploy.yml: drop the dc up -d steps, gate on
paths:[Caddyfile, .github/workflows/deploy.yml], reload caddy via
`docker exec` (no longer through compose).
- apply-compose.ps1: drop -Compose and -DevStack flags; -Caddy now reloads
via `docker exec caddy` (Caddy is in its own Portainer stack now).
No live container is touched by this commit. The runner workflow is
currently disabled at the repo level; re-enabling it makes Caddyfile pushes
auto-deploy again.
Application stacks now live in stacks/<domain>/ and are deployed by
Portainer's git poll. The runner workflow no longer pushes or applies the
monolithic root docker-compose.yml — it just pushes the Caddyfile and reloads
Caddy (now via docker exec, since `dc exec` no longer makes sense). apply-
compose.ps1 drops its -Compose and -DevStack flags for the same reason.
Local docker-compose.yml and dev-compose.yml are deleted; their content has
been split into stacks/* and is already running in production. The server
copies at /valhalla-lab/docker-compose.yml and /valhalla-lab/dev-compose.yml
are now orphan files (no workflow or script writes to them) and will be
removed after Caddy is cut over to its own proxy Portainer stack.
No live container is touched by this commit. The runner's next invocation
runs the new strict-subset workflow (Caddyfile push + reload only).
Add stacks/<domain>/ compose + env for the 11 target stacks (proxy, media,
foundry, owncloud, resume, famapp, authentik, notify, monitoring, remote, dev).
Each app stack joins a shared external `edge` network for Caddy and keeps its
DB/cache co-located (no shared backing services). All named volumes convert to
tiered bind mounts: DBs/configs -> /config (SSD), blobs/repos/registry ->
/storage1/labdata (ZFS). Gitea repos+LFS split to ZFS.
Move Vault into the management plane (portainer-compose.yml) and add the shared
`edge` network there. apply-compose.ps1 -Portainer now also pushes vault.hcl.
Additive only: root docker-compose.yml/.env/Caddyfile untouched, so the live
monolith is unchanged. Live cutover (Phase B) is next.