diff --git a/plans/homelab-improvements/PLAN-BRIEF.md b/plans/homelab-improvements/PLAN-BRIEF.md
new file mode 100644
index 0000000..654a33e
--- /dev/null
+++ b/plans/homelab-improvements/PLAN-BRIEF.md
@@ -0,0 +1,98 @@
+# Homelab improvements — plan brief
+
+**How to use this:** Don't read everything. Pick something that sounds interesting and say it in chat — e.g. *"let's talk about backups"* or *"TB-003"*. We'll have a short conversation first. You can do, defer, drop, or change any item.
+
+**Status:** Draft · **Host:** valhalla
+
+---
+
+## Talk first — plan level
+
+Before any phase or task, we should answer:
+
+1. **What feels urgent?** Backup? Security? Finishing romhacks? Something else entirely?
+2. **What can wait or die?** Throwing items out is success — tell me what doesn't interest you.
+3. **How do you want to work?** One item per session, or a theme (e.g. "media day")?
+
+### What we decided (plan level)
+
+| | |
+|---|---|
+| **Priority theme** | Backups (TB-001 in progress) |
+| **Explicitly dropped** | _(none yet)_ |
+| **Notes** | _(conversation notes go here)_ |
+
+---
+
+## Menu — pick one to talk about
+
+Each link opens an **interview-first** task brief. The conversation is the task.
+
+### Safety & quick wins
+
+| Say this… | About |
+|-----------|--------|
+| [TB-001](tasks/TB-001-backup-stack.md) | Automated backups — you don't have any yet |
+| [TB-033](tasks/TB-033-public-surface-audit.md) | What's actually public on the internet? |
+| [TB-003](tasks/TB-003-recyclarr.md) | Sync *arr quality settings automatically |
+| [TB-004](tasks/TB-004-pin-infra-tags.md) | Stop `:latest` from breaking databases |
+| [TB-002](tasks/TB-002-jd-caddy-block.md) | JDownloader web UI on jd.ginnoir.com |
+
+### Security & cleanup
+
+| Say this… | About |
+|-----------|--------|
+| [TB-005](tasks/TB-005-obsidian-hardening.md) | Your Obsidian vault is public-facing |
+| [TB-006](tasks/TB-006-authentik-forward-auth.md) | Login on admin UIs via Authentik |
+| [TB-013](tasks/TB-013-code-server-mount.md) | code-server sees your whole home dir |
+| [TB-014](tasks/TB-014-registry-auth.md) | Docker registry has no password |
+| [TB-008](tasks/TB-008-retire-deluge.md) | Maybe remove Deluge (you have qBit too) |
+| [TB-009](tasks/TB-009-bookstack-decision.md) | BookStack vs Obsidian — pick one? |
+| [TB-010](tasks/TB-010-dev-stack-env-migration.md) | dev stack env file cleanup |
+| [TB-011](tasks/TB-011-reap-orphan-volumes.md) | Delete old Docker volumes |
+
+### Finish what's started
+
+| Say this… | About |
+|-----------|--------|
+| [TB-007](tasks/TB-007-romhacks-handoff.md) | ROM hacks pipeline → RomM (incomplete) |
+| [TB-012](tasks/TB-012-maintainerr.md) | Clean stale Overseerr requests |
+| [TB-016](tasks/TB-016-document-plex.md) | Write down how Plex fits in |
+
+### New services (only if you want them)
+
+| Say this… | About |
+|-----------|--------|
+| [TB-019](tasks/TB-019-immich.md) | Family photos |
+| [TB-020](tasks/TB-020-mealie.md) | Recipes |
+| [TB-021](tasks/TB-021-paperless-ngx.md) | Scan/OCR documents |
+| [TB-022](tasks/TB-022-readarr.md) | Automate ebooks |
+| [TB-023](tasks/TB-023-autobrr.md) | Torrent announce automation |
+| [TB-024](tasks/TB-024-syncthing.md) | Device file sync |
+| [TB-025](tasks/TB-025-metrics-beszel.md) | CPU/disk metrics dashboard |
+| [TB-026](tasks/TB-026-igir-container.md) | Automate ROM library cleanup |
+
+### Maybe later (low pressure)
+
+| Say this… | About |
+|-----------|--------|
+| [TB-015](tasks/TB-015-vault-secrets-integration.md) | Actually use Vault |
+| [TB-017](tasks/TB-017-minio-consolidation.md) | One MinIO instead of three |
+| [TB-018](tasks/TB-018-plane-evaluation.md) | Is Plane worth 7 containers? |
+| [TB-027](tasks/TB-027-jellyfin.md) | Replace Plex with Jellyfin? |
+| [TB-028](tasks/TB-028-home-assistant.md) | Home automation |
+| [TB-029](tasks/TB-029-firefly-iii.md) | Personal finance |
+| [TB-030](tasks/TB-030-adguard-home.md) | Network ad blocking / DNS |
+| [TB-031](tasks/TB-031-pingvin-share.md) | Temporary file share links |
+| [TB-032](tasks/TB-032-wikijs.md) | Wiki.js (only if keeping a wiki) |
+
+---
+
+
+Background (optional — skip if overwhelming)
+
+Homelab is ~50 containers, 15 stacks, Caddy ingress, git-push deploys. Main gaps from analysis: no backups, Obsidian public, Authentik barely used, romhacks pipeline half-done.
+
+Repo conventions: `stack.env` secrets, `edge` network, `internal_only` for LAN admin UIs, `./scripts/gen-bookmarks.ps1` after Caddy changes.
+
+
diff --git a/plans/homelab-improvements/_generate_tasks.py b/plans/homelab-improvements/_generate_tasks.py
new file mode 100644
index 0000000..015317d
--- /dev/null
+++ b/plans/homelab-improvements/_generate_tasks.py
@@ -0,0 +1,627 @@
+#!/usr/bin/env python3
+"""Regenerate interview-first task briefs from structured data."""
+
+from pathlib import Path
+
+TASKS = [
+ {
+ "id": "TB-001",
+ "slug": "backup-stack",
+ "title": "Backup stack",
+ "sentence": "Set up automatic nightly backups of configs and databases, with a copy off the server.",
+ "why": "Right now nothing backs up /config or your databases — ZFS snapshots don't help if the whole machine dies.",
+ "questions": [
+ "Does losing valhalla overnight scare you, or is 'I'll deal with it' okay for now?",
+ "Do you already have offsite storage (Backblaze, Wasabi, another machine)?",
+ "Kopia (has a UI) vs Restic (simpler CLI) — want a recommendation or don't care?",
+ ],
+ "stack": "`backup` new stack",
+ "steps": [
+ "Pick Kopia or Restic",
+ "New stacks/backup/ with nightly schedule + ntfy on failure",
+ "Script dumps for all Postgres/MariaDB/CouchDB",
+ "One restore test so we know it works",
+ ],
+ "done": [
+ "Nightly backup runs without you",
+ "At least one restore tested",
+ "Failures ping ntfy",
+ ],
+ },
+ {
+ "id": "TB-002",
+ "slug": "jd-caddy-block",
+ "title": "jd.ginnoir.com",
+ "sentence": "Add a LAN-only web address for JDownloader's UI.",
+ "why": "It's documented but never added to the Caddyfile — you use port 8998 instead.",
+ "questions": [
+ "Are you actually using the romhacks/JDownloader pipeline, or is this premature?",
+ "Is LAN-only enough, or do you need it over Tailscale too (already covered by internal_only)?",
+ ],
+ "stack": "Caddyfile",
+ "steps": [
+ "Add jd.ginnoir.com block with internal_only",
+ "gen-bookmarks.ps1 + push",
+ ],
+ "done": ["jd.ginnoir.com works on LAN", "403 from outside"],
+ },
+ {
+ "id": "TB-003",
+ "slug": "recyclarr",
+ "title": "Recyclarr",
+ "sentence": "One container that keeps Sonarr/Radarr/Bazarr quality settings in sync with TRaSH guides.",
+ "why": "Your *arr configs probably drift over time; this automates the boring sync.",
+ "questions": [
+ "Have you hand-tuned quality profiles you'd hate to have overwritten?",
+ "Want this, or are your *arr settings 'set and forget' already?",
+ ],
+ "stack": "stacks/media/",
+ "steps": [
+ "Add recyclarr container on media network",
+ "Point at sonarr/radarr/bazarr/prowlarr APIs",
+ "Run first sync, spot-check Sonarr",
+ ],
+ "done": ["Recyclarr syncs without errors", "Profiles visible in Sonarr or Radarr"],
+ },
+ {
+ "id": "TB-004",
+ "slug": "pin-infra-tags",
+ "title": "Pin infra image tags",
+ "sentence": "Stop Postgres, MariaDB, Caddy, etc. from silently updating to a broken :latest.",
+ "why": "Watchtower auto-updates containers; one bad DB image pull is painful.",
+ "questions": [
+ "Have you been burned by a surprise update before?",
+ "Okay to manually bump pinned versions every few months?",
+ ],
+ "stack": "All compose files",
+ "steps": [
+ "Audit :latest on infra images",
+ "Pin versions + watchtower.enable=false on those",
+ "Document policy in CLAUDE.md",
+ ],
+ "done": ["No :latest on DB/Caddy/Vault/CouchDB/MinIO", "Stacks still deploy clean"],
+ },
+ {
+ "id": "TB-033",
+ "slug": "public-surface-audit",
+ "title": "Public surface audit",
+ "sentence": "A short conversation + checklist: what's reachable from the internet, and should it be?",
+ "why": "Some things are public on purpose (Foundry, famapp); some might surprise you (Obsidian).",
+ "questions": [
+ "Want to walk through the list together in chat instead of reading a table?",
+ "Any service you *know* should be public that we'd flag as wrong?",
+ ],
+ "stack": "Caddyfile review only",
+ "steps": [
+ "Walk each public domain in conversation",
+ "Mark: keep public / lock down / drop",
+ "Save decisions here and in TB-005/TB-006 as needed",
+ ],
+ "done": ["Every public site has your yes/no", "Action items linked to other TBs"],
+ },
+ {
+ "id": "TB-005",
+ "slug": "obsidian-hardening",
+ "title": "Obsidian / CouchDB hardening",
+ "sentence": "Your whole PKM vault syncs over a public URL — let's decide how exposed you're okay with.",
+ "why": "obsidian.ginnoir.com has no LAN-only or SSO gate; LiveSync needs remote access from phones.",
+ "questions": [
+ "Do you sync Obsidian from your phone without Tailscale always on?",
+ "Would 'Tailscale only' work, or do you need public access?",
+ "Authentik login in front — okay if we test LiveSync still works?",
+ ],
+ "stack": "Caddyfile, stacks/notes/",
+ "steps": [
+ "Pick model: Tailscale-only, Authentik, or hybrid",
+ "Implement + test sync from each device",
+ "Rotate CouchDB password",
+ ],
+ "done": ["You can still sync where you need to", "Admin/_utils not wide open"],
+ },
+ {
+ "id": "TB-006",
+ "slug": "authentik-forward-auth",
+ "title": "Authentik on admin UIs",
+ "sentence": "Require Authentik login before Portainer, code-server, registry, etc.",
+ "why": "LAN-only IP checks aren't the same as identity — Authentik is already running for famapp.",
+ "questions": [
+ "Which admin sites do you actually use? (No point gating ones you never open)",
+ "Portainer git webhooks must keep working — okay to test carefully?",
+ "Do this for everything internal, or just the scary ones (Portainer, code, vault)?",
+ ],
+ "stack": "Caddyfile + Authentik UI",
+ "steps": [
+ "Authentik proxy provider + outpost",
+ "Caddy forward_auth snippet",
+ "Tier 1: portainer, code, registry-ui, vault, minio console",
+ ],
+ "done": ["Tier 1 sites require login", "Portainer webhooks still work"],
+ },
+ {
+ "id": "TB-007",
+ "slug": "romhacks-handoff",
+ "title": "Romhacks → RomM handoff",
+ "sentence": "Finish the pipeline so downloaded ROM hacks land in RomM with cover art.",
+ "why": "Orchestrator + JDownloader exist; moving files into the library isn't built yet.",
+ "questions": [
+ "Is the Discord/JDownloader pipeline armed, or still theoretical?",
+ "Worth finishing before arming, or drop the whole romhacks stack?",
+ "How much automation vs 'I'll move files manually'?",
+ ],
+ "stack": "stacks/romhacks/orchestrator/",
+ "steps": [
+ "Complete handoff.py — incoming → Emulation/roms/",
+ "RomM custom cover API",
+ "Test with one staged folder",
+ ],
+ "done": ["Test ROM in RomM after handoff", "DRY_RUN still safe"],
+ },
+ {
+ "id": "TB-008",
+ "slug": "retire-deluge",
+ "title": "Retire Deluge",
+ "sentence": "Remove Deluge if qBittorrent is doing all the torrent work.",
+ "why": "You run Deluge, qBit, and NZBGet — two torrent clients may be leftover cruft.",
+ "questions": [
+ "When did you last use Deluge?",
+ "Are Sonarr/Radarr pointed at qBit only? (We can check together)",
+ ],
+ "stack": "stacks/media/, Caddyfile",
+ "steps": [
+ "Confirm zero Deluge torrents + no *arr dependency",
+ "Remove service + Caddy block",
+ ],
+ "done": ["Deluge gone", "qBit/NZBGet still fine"],
+ },
+ {
+ "id": "TB-009",
+ "slug": "bookstack-decision",
+ "title": "BookStack vs Obsidian",
+ "sentence": "Decide if docs.ginnoir.com (BookStack) stays or goes — Obsidian is already your main PKM.",
+ "why": "Two wiki systems = drift and an extra MariaDB to feed.",
+ "questions": [
+ "Do you use BookStack at all? When did you last open it?",
+ "If we export and remove it, anything lost?",
+ "Or keep it for a specific use case — what?",
+ ],
+ "stack": "stacks/dev/",
+ "steps": [
+ "Your call: retire, keep with boundary, or migrate to Wiki.js (TB-032)",
+ "If retire: export → remove containers",
+ ],
+ "done": ["Decision recorded", "If retired: BookStack containers gone"],
+ },
+ {
+ "id": "TB-010",
+ "slug": "dev-stack-env-migration",
+ "title": "dev stack.env migration",
+ "sentence": "Make the dev stack use stack.env like everything else — no Portainer UI env vars.",
+ "why": "Every other stack commits secrets to stack.env; dev still needs UI mirroring (footgun).",
+ "questions": [
+ "Have you been bitten by dev env drift before?",
+ "Worth an afternoon, or low priority housekeeping?",
+ ],
+ "stack": "stacks/dev/",
+ "steps": [
+ "Move all ${VAR} into stack.env",
+ "Deploy, verify Gitea/Plane/BookStack/code-server",
+ "Clear Portainer UI env vars",
+ ],
+ "done": ["Dev stack runs with env_file only"],
+ },
+ {
+ "id": "TB-011",
+ "slug": "reap-orphan-volumes",
+ "title": "Reap orphan volumes",
+ "sentence": "Delete old pre-split Docker named volumes that bind mounts replaced.",
+ "why": "htpc-download-box_* and valhalla-lab_* volumes waste disk and confuse what's canonical.",
+ "questions": [
+ "Want me to SSH and list them with you before deleting anything?",
+ "Backups done first? (TB-001)",
+ ],
+ "stack": "SSH on valhalla only",
+ "steps": [
+ "docker volume ls — confirm orphans",
+ "Spot-check bind mounts healthy",
+ "docker volume rm",
+ ],
+ "done": ["Orphan volumes gone", "All containers still healthy"],
+ },
+ {
+ "id": "TB-012",
+ "slug": "maintainerr",
+ "title": "Maintainerr",
+ "sentence": "Auto-clean stale Overseerr requests so the queue doesn't fill with junk.",
+ "why": "requests.ginnoir.com is public; old unfulfilled requests pile up.",
+ "questions": [
+ "Is your Overseerr queue messy now, or is this preventive?",
+ "How aggressive on age — 30 days? 90?",
+ ],
+ "stack": "stacks/media/",
+ "steps": [
+ "Add maintainerr on media network",
+ "Connect Overseerr + Sonarr/Radarr APIs",
+ "Conservative cleanup rule first",
+ ],
+ "done": ["Maintainerr connected", "One rule configured"],
+ },
+ {
+ "id": "TB-013",
+ "slug": "code-server-mount",
+ "title": "Narrow code-server mount",
+ "sentence": "Stop code-server from mounting your entire home directory.",
+ "why": "code.ginnoir.com currently exposes /home/ginnoir — SSH keys, everything.",
+ "questions": [
+ "What folders do you actually edit in code-server?",
+ "homelabstack + a projects dir enough?",
+ ],
+ "stack": "stacks/dev/docker-compose.yml",
+ "steps": [
+ "Change mount to scoped path",
+ "Symlink homelabstack if needed",
+ ],
+ "done": [".ssh and unrelated home dirs not visible in IDE"],
+ },
+ {
+ "id": "TB-014",
+ "slug": "registry-auth",
+ "title": "Registry authentication",
+ "sentence": "Put a password on your private Docker registry.",
+ "why": "registry.ginnoir.com is LAN-only but anyone on LAN/tailnet can push/pull.",
+ "questions": [
+ "Do you push images to it regularly, or is it barely used?",
+ "Will famapp CI or github-runner need login updated?",
+ ],
+ "stack": "stacks/dev/",
+ "steps": [
+ "htpasswd + registry auth env",
+ "Test docker login/pull/push",
+ ],
+ "done": ["Unauthenticated pull fails", "You can pull with creds"],
+ },
+ {
+ "id": "TB-016",
+ "slug": "document-plex",
+ "title": "Document Plex",
+ "sentence": "Write down where Plex lives and how it connects to Tautulli — it's not in this repo.",
+ "why": "Tautulli reads /storage1/PMS logs but Plex isn't in compose; easy to forget how it's managed.",
+ "questions": [
+ "Is Plex on valhalla itself, or another box?",
+ "Do you want it in compose eventually, or happy as host-native?",
+ ],
+ "stack": "Docs only",
+ "steps": [
+ "You tell me: install path, systemd?, library paths",
+ "Add short section to CLAUDE.md or EXTERNAL-SERVICES.md",
+ ],
+ "done": ["Plex documented with paths"],
+ },
+ {
+ "id": "TB-019",
+ "slug": "immich",
+ "title": "Immich (photos)",
+ "sentence": "Self-hosted Google Photos — family photo backup with a phone app.",
+ "why": "Natural fit next to famapp; you don't have photo backup today.",
+ "questions": [
+ "Is phone photo backup something you actively want?",
+ "Public with Authentik, or LAN/tailnet only?",
+ "Storage growth on ZFS — okay?",
+ ],
+ "stack": "New photos stack",
+ "steps": [
+ "New stacks/photos/",
+ "photos.ginnoir.com + optional Authentik",
+ "Test upload from phone",
+ ],
+ "done": ["Mobile upload works", "In backup scope"],
+ },
+ {
+ "id": "TB-020",
+ "slug": "mealie",
+ "title": "Mealie (recipes)",
+ "sentence": "Recipe manager with meal plans and shopping lists.",
+ "why": "Pairs with famapp/family theme; lightweight stack.",
+ "questions": [
+ "Would you actually use this, or is it 'nice in theory'?",
+ "Shared with family or just you?",
+ ],
+ "stack": "New kitchen stack or famapp",
+ "steps": ["New stack", "recipes.ginnoir.com", "Import one recipe as test"],
+ "done": ["You'd use it — or we drop it"],
+ },
+ {
+ "id": "TB-021",
+ "slug": "paperless-ngx",
+ "title": "Paperless-ngx",
+ "sentence": "Drop PDFs in a folder → OCR → searchable archive.",
+ "why": "ownCloud stores files; this adds document search and tagging.",
+ "questions": [
+ "Do you scan receipts/documents often enough to matter?",
+ "Or is ownCloud + Obsidian enough?",
+ ],
+ "stack": "New docs stack",
+ "steps": ["New stack", "consume folder on ZFS", "Test one PDF OCR"],
+ "done": ["Search finds text in test doc — or we skip"],
+ },
+ {
+ "id": "TB-022",
+ "slug": "readarr",
+ "title": "Readarr (ebooks)",
+ "sentence": "Automate ebook downloads like Sonarr does for TV.",
+ "why": "You have /storage1/Books in ownCloud; no *arr for books yet.",
+ "questions": [
+ "Do you acquire ebooks automatically, or buy/download manually?",
+ "Audiobooks too? (Different tool — Audiobookshelf)",
+ ],
+ "stack": "stacks/media/",
+ "steps": [
+ "Add readarr + Caddy internal block",
+ "Hook Prowlarr + download client",
+ ],
+ "done": ["Test grab lands in Books — or drop"],
+ },
+ {
+ "id": "TB-023",
+ "slug": "autobrr",
+ "title": "Autobrr",
+ "sentence": "Monitor private tracker IRC/RSS and auto-push to qBittorrent.",
+ "why": "For active private tracker use — overkill if you're mostly public indexers via Prowlarr.",
+ "questions": [
+ "Are you on private trackers that need announce monitoring?",
+ "Or skip — Prowlarr enough?",
+ ],
+ "stack": "stacks/media/",
+ "steps": ["Add autobrr", "Connect qBit", "One filter if proceeding"],
+ "done": ["Worth it for your tracker habits — or dropped"],
+ },
+ {
+ "id": "TB-024",
+ "slug": "syncthing",
+ "title": "Syncthing",
+ "sentence": "Continuous folder sync between devices — ROM saves, dotfiles, etc.",
+ "why": "Different from ownCloud's model; good for device-to-device without 'upload to server'.",
+ "questions": [
+ "What folders would you sync? (Be specific — not all of /storage1)",
+ "Conflict risk with RomM/ownCloud on same paths?",
+ ],
+ "stack": "New sync stack",
+ "steps": ["New stack", "Pair two devices", "One test folder"],
+ "done": ["Sync works on chosen folder — or drop"],
+ },
+ {
+ "id": "TB-025",
+ "slug": "metrics-beszel",
+ "title": "Metrics (Beszel/Netdata)",
+ "sentence": "See CPU/RAM/disk graphs — Uptime Kuma only tells you up/down.",
+ "why": "Homelab has no metrics dashboard today.",
+ "questions": [
+ "Do you ever wonder 'why is the server slow' without an answer?",
+ "Beszel (minimal) vs Netdata (detailed) — or skip?",
+ ],
+ "stack": "stacks/monitoring/",
+ "steps": ["Add chosen tool", "metrics.ginnoir.com internal"],
+ "done": ["Dashboard shows host + container usage"],
+ },
+ {
+ "id": "TB-026",
+ "slug": "igir-container",
+ "title": "IGIR container",
+ "sentence": "Schedule your existing ROM cleanup scripts instead of SSH ad-hoc.",
+ "why": "igir-*.sh scripts exist; romhacks handoff will add more ROMs to maintain.",
+ "questions": [
+ "Do you run IGIR often enough to automate?",
+ "Or manual is fine?",
+ ],
+ "stack": "stacks/roms/",
+ "steps": [
+ "Container wrapping existing scripts",
+ "Weekly dry-run report → ntfy",
+ ],
+ "done": ["Scheduled dry-run runs — or drop"],
+ },
+ {
+ "id": "TB-015",
+ "slug": "vault-secrets-integration",
+ "title": "Use Vault for real",
+ "sentence": "Vault runs but secrets live in stack.env — integrate Vault for CI/rotation without changing git-canonical deploy.",
+ "why": "Optional complexity; only worth it if you want rotation or CI secret centralization.",
+ "questions": [
+ "What problem would Vault solve that stack.env doesn't?",
+ "If 'nothing really' — drop this.",
+ ],
+ "stack": "portainer-compose.yml, scripts",
+ "steps": [
+ "Define Vault vs stack.env boundary",
+ "Migrate one token (e.g. github-runner)",
+ ],
+ "done": ["Clear use case — or dropped"],
+ },
+ {
+ "id": "TB-017",
+ "slug": "minio-consolidation",
+ "title": "One MinIO instead of three",
+ "sentence": "resume, famapp, and Plane each run their own MinIO — could be one server, three buckets.",
+ "why": "Less ops; slightly higher blast radius if MinIO dies.",
+ "questions": [
+ "Do the three MinIO instances cause you pain today?",
+ "If no — drop; isolation is fine.",
+ ],
+ "stack": "resume, famapp, dev",
+ "steps": ["Decision", "If yes: migrate buckets, update env vars"],
+ "done": ["Consolidated or explicitly kept separate"],
+ },
+ {
+ "id": "TB-018",
+ "slug": "plane-evaluation",
+ "title": "Is Plane worth it?",
+ "sentence": "Plane is 7 containers for project management — do you use it?",
+ "why": "Gitea has issues; Homarr is your dashboard; Plane may be shelfware.",
+ "questions": [
+ "When did you last open plane.ginnoir.com?",
+ "Gitea issues enough, or need a kanban?",
+ ],
+ "stack": "stacks/dev/",
+ "steps": ["Your usage answer", "Keep, or remove and reclaim resources"],
+ "done": ["Decision recorded"],
+ },
+ {
+ "id": "TB-027",
+ "slug": "jellyfin",
+ "title": "Jellyfin instead of Plex",
+ "sentence": "Self-host streaming in Docker instead of host-native Plex.",
+ "why": "Only if you want to move off Plex licensing / centralize in compose.",
+ "questions": [
+ "Happy with Plex today?",
+ "Family/clients okay switching apps?",
+ "Hardware transcoding needed?",
+ ],
+ "stack": "media or new streaming",
+ "steps": ["Decision", "If yes: jellyfin + GPU passthrough eval"],
+ "done": ["Stay on Plex or migrate plan documented"],
+ },
+ {
+ "id": "TB-028",
+ "slug": "home-assistant",
+ "title": "Home Assistant",
+ "sentence": "Home automation hub — only makes sense if you have smart devices to control.",
+ "why": "Zero home automation in the stack today.",
+ "questions": [
+ "How many smart devices do you have?",
+ "Zigbee/Z-Wave USB stick on valhalla?",
+ "If <5 devices — probably skip.",
+ ],
+ "stack": "New home stack",
+ "steps": ["Go/no-go", "If go: HA + USB passthrough"],
+ "done": ["Go/no-go with reason"],
+ },
+ {
+ "id": "TB-029",
+ "slug": "firefly-iii",
+ "title": "Firefly III (finance)",
+ "sentence": "Self-hosted budgeting and transaction tracking.",
+ "why": "Niche personal finance; sensitive data.",
+ "questions": [
+ "Do you want to self-host finances, or use a SaaS/bank app?",
+ "If yes — internal-only + Authentik mandatory.",
+ ],
+ "stack": "New finance stack",
+ "steps": ["New stack", "internal-only Caddy"],
+ "done": ["Using it — or dropped"],
+ },
+ {
+ "id": "TB-030",
+ "slug": "adguard-home",
+ "title": "AdGuard Home",
+ "sentence": "Network-wide ad blocking and local DNS for *.ginnoir.com.",
+ "why": "Stops LAN hairpin through Cloudflare; blocks ads if router DNS points here.",
+ "questions": [
+ "Does your router already do ad blocking / local DNS?",
+ "Willing to point DHCP DNS at valhalla?",
+ ],
+ "stack": "New network stack or mgmt",
+ "steps": ["Check router first", "If gap: AdGuard + DNS rewrites"],
+ "done": ["Router sufficient — or AdGuard deployed"],
+ },
+ {
+ "id": "TB-031",
+ "slug": "pingvin-share",
+ "title": "Pingvin Share",
+ "sentence": "Temporary password-protected file links — lighter than ownCloud shares.",
+ "why": "One-off 'here's a file' drops; ownCloud is heavier.",
+ "questions": [
+ "Do you send ad-hoc files to people often?",
+ "Or rare enough to skip?",
+ ],
+ "stack": "stacks/share/",
+ "steps": ["Add to share stack", "drop.ginnoir.com or similar"],
+ "done": ["Test share link — or drop"],
+ },
+ {
+ "id": "TB-032",
+ "slug": "wikijs",
+ "title": "Wiki.js",
+ "sentence": "Modern wiki — only relevant if TB-009 says 'keep a structured wiki' but not BookStack.",
+ "why": "Depends on TB-009 decision; don't start here first.",
+ "questions": [
+ "Did TB-009 land on 'keep wiki'?",
+ "If BookStack retired or Obsidian-only — skip this entirely.",
+ ],
+ "stack": "stacks/dev/",
+ "steps": ["Only if TB-009 = Option C", "Import + replace BookStack"],
+ "done": ["TB-009 dependency satisfied — or N/A"],
+ },
+]
+
+
+def render(task: dict) -> str:
+ qs = "\n".join(f"{i}. {q}" for i, q in enumerate(task["questions"], 1))
+ steps = "\n".join(f"{i}. {s}" for i, s in enumerate(task["steps"], 1))
+ done = "\n".join(f"- [ ] {d}" for d in task["done"])
+ return f"""# {task["id"]} — {task["title"]}
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do {task["id"]}"** or **"{task["title"].lower()}"**.
+
+**In one sentence:** {task["sentence"]}
+
+**Why it came up:** {task["why"]}
+
+### Questions
+
+{qs}
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+{task["stack"]}
+
+### If we do it — rough steps
+
+{steps}
+
+### Done when
+
+{done}
+
+
+"""
+
+
+def main() -> None:
+ out_dir = Path(__file__).parent / "tasks"
+ for task in TASKS:
+ path = out_dir / f"{task['id']}-{task['slug']}.md"
+ path.write_text(render(task), encoding="utf-8", newline="\n")
+ print(f"wrote {path.name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plans/homelab-improvements/tasks/README.md b/plans/homelab-improvements/tasks/README.md
new file mode 100644
index 0000000..ce93462
--- /dev/null
+++ b/plans/homelab-improvements/tasks/README.md
@@ -0,0 +1,69 @@
+# Task menu
+
+**Don't read a table and pick.** Say something in chat — e.g. *"backups"*, *"TB-005"*, *"what's public?"* — and we'll talk through that one item.
+
+Parent: [PLAN-BRIEF.md](../PLAN-BRIEF.md) · Template: [_TEMPLATE.md](_TEMPLATE.md)
+
+---
+
+## How this works
+
+1. You name a topic (or ask "what should I care about?")
+2. We go through that task's **Talk first** questions together
+3. You pick: **Do it / Later / Drop / Tweak**
+4. I fill in **What we decided** in the brief
+5. Reference section only matters if we're implementing
+
+---
+
+## Quick pick by vibe
+
+| If you're thinking… | Open |
+|---------------------|------|
+| "I could lose everything" | [TB-001](TB-001-backup-stack.md) |
+| "What's exposed?" | [TB-033](TB-033-public-surface-audit.md) |
+| "Obsidian / vault security" | [TB-005](TB-005-obsidian-hardening.md) |
+| "ROM hacks pipeline" | [TB-007](TB-007-romhacks-handoff.md) |
+| "Family / photos / recipes" | [TB-019](TB-019-immich.md) · [TB-020](TB-020-mealie.md) |
+| "Media stack tuning" | [TB-003](TB-003-recyclarr.md) · [TB-008](TB-008-retire-deluge.md) |
+| "Cleanup / cruft" | [TB-009](TB-009-bookstack-decision.md) · [TB-011](TB-011-reap-orphan-volumes.md) · [TB-018](TB-018-plane-evaluation.md) |
+
+---
+
+## All tasks
+
+| ID | One line | Brief |
+|----|----------|-------|
+| TB-001 | Automated backups | [→](TB-001-backup-stack.md) |
+| TB-002 | jd.ginnoir.com | [→](TB-002-jd-caddy-block.md) |
+| TB-003 | Recyclarr | [→](TB-003-recyclarr.md) |
+| TB-004 | Pin infra tags | [→](TB-004-pin-infra-tags.md) |
+| TB-005 | Obsidian hardening | [→](TB-005-obsidian-hardening.md) |
+| TB-006 | Authentik admin login | [→](TB-006-authentik-forward-auth.md) |
+| TB-007 | Romhacks handoff | [→](TB-007-romhacks-handoff.md) |
+| TB-008 | Retire Deluge | [→](TB-008-retire-deluge.md) |
+| TB-009 | BookStack decision | [→](TB-009-bookstack-decision.md) |
+| TB-010 | dev stack.env | [→](TB-010-dev-stack-env-migration.md) |
+| TB-011 | Orphan volumes | [→](TB-011-reap-orphan-volumes.md) |
+| TB-012 | Maintainerr | [→](TB-012-maintainerr.md) |
+| TB-013 | code-server mount | [→](TB-013-code-server-mount.md) |
+| TB-014 | Registry auth | [→](TB-014-registry-auth.md) |
+| TB-015 | Vault integration | [→](TB-015-vault-secrets-integration.md) |
+| TB-016 | Document Plex | [→](TB-016-document-plex.md) |
+| TB-017 | MinIO consolidation | [→](TB-017-minio-consolidation.md) |
+| TB-018 | Plane evaluation | [→](TB-018-plane-evaluation.md) |
+| TB-019 | Immich | [→](TB-019-immich.md) |
+| TB-020 | Mealie | [→](TB-020-mealie.md) |
+| TB-021 | Paperless-ngx | [→](TB-021-paperless-ngx.md) |
+| TB-022 | Readarr | [→](TB-022-readarr.md) |
+| TB-023 | Autobrr | [→](TB-023-autobrr.md) |
+| TB-024 | Syncthing | [→](TB-024-syncthing.md) |
+| TB-025 | Metrics | [→](TB-025-metrics-beszel.md) |
+| TB-026 | IGIR container | [→](TB-026-igir-container.md) |
+| TB-027 | Jellyfin | [→](TB-027-jellyfin.md) |
+| TB-028 | Home Assistant | [→](TB-028-home-assistant.md) |
+| TB-029 | Firefly III | [→](TB-029-firefly-iii.md) |
+| TB-030 | AdGuard Home | [→](TB-030-adguard-home.md) |
+| TB-031 | Pingvin Share | [→](TB-031-pingvin-share.md) |
+| TB-032 | Wiki.js | [→](TB-032-wikijs.md) |
+| TB-033 | Public audit | [→](TB-033-public-surface-audit.md) |
diff --git a/plans/homelab-improvements/tasks/TB-001-backup-stack.md b/plans/homelab-improvements/tasks/TB-001-backup-stack.md
new file mode 100644
index 0000000..7cd23db
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-001-backup-stack.md
@@ -0,0 +1,62 @@
+# TB-001 — Backup stack
+
+**Status:** in progress — stack drafted, needs B2 + Portainer deploy
+**Your call:** **Do it**
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-001"** or **"backup stack"**.
+
+**In one sentence:** Set up automatic nightly backups of configs and databases, with a copy off the server.
+
+**Why it came up:** Right now nothing backs up /config or your databases — ZFS snapshots don't help if the whole machine dies.
+
+### Questions
+
+1. Does losing valhalla overnight scare you, or is 'I'll deal with it' okay for now?
+2. Do you already have offsite storage (Backblaze, Wasabi, another machine)?
+3. Kopia (has a UI) vs Restic (simpler CLI) — want a recommendation or don't care?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | Do it |
+| **Notes** | Kopia + B2 TBD. `/config` + dumps (RomM DB). labdata: gitea, minio, romhacks, romm/assets (saves). **Skip:** entire `/storage1/Emulation` (legacy), romm/resources (art), registry, incoming. See `SCOPE.md`. |
+| **Date** | 2026-06-10 |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+`backup` new stack
+
+### If we do it — rough steps
+
+1. Pick Kopia or Restic
+2. New stacks/backup/ with nightly schedule + ntfy on failure
+3. Script dumps for all Postgres/MariaDB/CouchDB
+4. One restore test so we know it works
+
+### Done when
+
+- [ ] Nightly backup runs without you
+- [ ] At least one restore tested
+- [ ] Failures ping ntfy
+
+
diff --git a/plans/homelab-improvements/tasks/TB-002-jd-caddy-block.md b/plans/homelab-improvements/tasks/TB-002-jd-caddy-block.md
new file mode 100644
index 0000000..9e0c5e6
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-002-jd-caddy-block.md
@@ -0,0 +1,58 @@
+# TB-002 — jd.ginnoir.com
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-002"** or **"jd.ginnoir.com"**.
+
+**In one sentence:** Add a LAN-only web address for JDownloader's UI.
+
+**Why it came up:** It's documented but never added to the Caddyfile — you use port 8998 instead.
+
+### Questions
+
+1. Are you actually using the romhacks/JDownloader pipeline, or is this premature?
+2. Is LAN-only enough, or do you need it over Tailscale too (already covered by internal_only)?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+Caddyfile
+
+### If we do it — rough steps
+
+1. Add jd.ginnoir.com block with internal_only
+2. gen-bookmarks.ps1 + push
+
+### Done when
+
+- [ ] jd.ginnoir.com works on LAN
+- [ ] 403 from outside
+
+
diff --git a/plans/homelab-improvements/tasks/TB-003-recyclarr.md b/plans/homelab-improvements/tasks/TB-003-recyclarr.md
new file mode 100644
index 0000000..29fef02
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-003-recyclarr.md
@@ -0,0 +1,59 @@
+# TB-003 — Recyclarr
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-003"** or **"recyclarr"**.
+
+**In one sentence:** One container that keeps Sonarr/Radarr/Bazarr quality settings in sync with TRaSH guides.
+
+**Why it came up:** Your *arr configs probably drift over time; this automates the boring sync.
+
+### Questions
+
+1. Have you hand-tuned quality profiles you'd hate to have overwritten?
+2. Want this, or are your *arr settings 'set and forget' already?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/media/
+
+### If we do it — rough steps
+
+1. Add recyclarr container on media network
+2. Point at sonarr/radarr/bazarr/prowlarr APIs
+3. Run first sync, spot-check Sonarr
+
+### Done when
+
+- [ ] Recyclarr syncs without errors
+- [ ] Profiles visible in Sonarr or Radarr
+
+
diff --git a/plans/homelab-improvements/tasks/TB-004-pin-infra-tags.md b/plans/homelab-improvements/tasks/TB-004-pin-infra-tags.md
new file mode 100644
index 0000000..142a189
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-004-pin-infra-tags.md
@@ -0,0 +1,59 @@
+# TB-004 — Pin infra image tags
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-004"** or **"pin infra image tags"**.
+
+**In one sentence:** Stop Postgres, MariaDB, Caddy, etc. from silently updating to a broken :latest.
+
+**Why it came up:** Watchtower auto-updates containers; one bad DB image pull is painful.
+
+### Questions
+
+1. Have you been burned by a surprise update before?
+2. Okay to manually bump pinned versions every few months?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+All compose files
+
+### If we do it — rough steps
+
+1. Audit :latest on infra images
+2. Pin versions + watchtower.enable=false on those
+3. Document policy in CLAUDE.md
+
+### Done when
+
+- [ ] No :latest on DB/Caddy/Vault/CouchDB/MinIO
+- [ ] Stacks still deploy clean
+
+
diff --git a/plans/homelab-improvements/tasks/TB-005-obsidian-hardening.md b/plans/homelab-improvements/tasks/TB-005-obsidian-hardening.md
new file mode 100644
index 0000000..be69a42
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-005-obsidian-hardening.md
@@ -0,0 +1,60 @@
+# TB-005 — Obsidian / CouchDB hardening
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-005"** or **"obsidian / couchdb hardening"**.
+
+**In one sentence:** Your whole PKM vault syncs over a public URL — let's decide how exposed you're okay with.
+
+**Why it came up:** obsidian.ginnoir.com has no LAN-only or SSO gate; LiveSync needs remote access from phones.
+
+### Questions
+
+1. Do you sync Obsidian from your phone without Tailscale always on?
+2. Would 'Tailscale only' work, or do you need public access?
+3. Authentik login in front — okay if we test LiveSync still works?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+Caddyfile, stacks/notes/
+
+### If we do it — rough steps
+
+1. Pick model: Tailscale-only, Authentik, or hybrid
+2. Implement + test sync from each device
+3. Rotate CouchDB password
+
+### Done when
+
+- [ ] You can still sync where you need to
+- [ ] Admin/_utils not wide open
+
+
diff --git a/plans/homelab-improvements/tasks/TB-006-authentik-forward-auth.md b/plans/homelab-improvements/tasks/TB-006-authentik-forward-auth.md
new file mode 100644
index 0000000..dd57e08
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-006-authentik-forward-auth.md
@@ -0,0 +1,60 @@
+# TB-006 — Authentik on admin UIs
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-006"** or **"authentik on admin uis"**.
+
+**In one sentence:** Require Authentik login before Portainer, code-server, registry, etc.
+
+**Why it came up:** LAN-only IP checks aren't the same as identity — Authentik is already running for famapp.
+
+### Questions
+
+1. Which admin sites do you actually use? (No point gating ones you never open)
+2. Portainer git webhooks must keep working — okay to test carefully?
+3. Do this for everything internal, or just the scary ones (Portainer, code, vault)?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+Caddyfile + Authentik UI
+
+### If we do it — rough steps
+
+1. Authentik proxy provider + outpost
+2. Caddy forward_auth snippet
+3. Tier 1: portainer, code, registry-ui, vault, minio console
+
+### Done when
+
+- [ ] Tier 1 sites require login
+- [ ] Portainer webhooks still work
+
+
diff --git a/plans/homelab-improvements/tasks/TB-007-romhacks-handoff.md b/plans/homelab-improvements/tasks/TB-007-romhacks-handoff.md
new file mode 100644
index 0000000..acc1a8b
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-007-romhacks-handoff.md
@@ -0,0 +1,60 @@
+# TB-007 — Romhacks → RomM handoff
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-007"** or **"romhacks → romm handoff"**.
+
+**In one sentence:** Finish the pipeline so downloaded ROM hacks land in RomM with cover art.
+
+**Why it came up:** Orchestrator + JDownloader exist; moving files into the library isn't built yet.
+
+### Questions
+
+1. Is the Discord/JDownloader pipeline armed, or still theoretical?
+2. Worth finishing before arming, or drop the whole romhacks stack?
+3. How much automation vs 'I'll move files manually'?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/romhacks/orchestrator/
+
+### If we do it — rough steps
+
+1. Complete handoff.py — incoming → Emulation/roms/
+2. RomM custom cover API
+3. Test with one staged folder
+
+### Done when
+
+- [ ] Test ROM in RomM after handoff
+- [ ] DRY_RUN still safe
+
+
diff --git a/plans/homelab-improvements/tasks/TB-008-retire-deluge.md b/plans/homelab-improvements/tasks/TB-008-retire-deluge.md
new file mode 100644
index 0000000..0a49e03
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-008-retire-deluge.md
@@ -0,0 +1,58 @@
+# TB-008 — Retire Deluge
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-008"** or **"retire deluge"**.
+
+**In one sentence:** Remove Deluge if qBittorrent is doing all the torrent work.
+
+**Why it came up:** You run Deluge, qBit, and NZBGet — two torrent clients may be leftover cruft.
+
+### Questions
+
+1. When did you last use Deluge?
+2. Are Sonarr/Radarr pointed at qBit only? (We can check together)
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/media/, Caddyfile
+
+### If we do it — rough steps
+
+1. Confirm zero Deluge torrents + no *arr dependency
+2. Remove service + Caddy block
+
+### Done when
+
+- [ ] Deluge gone
+- [ ] qBit/NZBGet still fine
+
+
diff --git a/plans/homelab-improvements/tasks/TB-009-bookstack-decision.md b/plans/homelab-improvements/tasks/TB-009-bookstack-decision.md
new file mode 100644
index 0000000..01fdab5
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-009-bookstack-decision.md
@@ -0,0 +1,59 @@
+# TB-009 — BookStack vs Obsidian
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-009"** or **"bookstack vs obsidian"**.
+
+**In one sentence:** Decide if docs.ginnoir.com (BookStack) stays or goes — Obsidian is already your main PKM.
+
+**Why it came up:** Two wiki systems = drift and an extra MariaDB to feed.
+
+### Questions
+
+1. Do you use BookStack at all? When did you last open it?
+2. If we export and remove it, anything lost?
+3. Or keep it for a specific use case — what?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/
+
+### If we do it — rough steps
+
+1. Your call: retire, keep with boundary, or migrate to Wiki.js (TB-032)
+2. If retire: export → remove containers
+
+### Done when
+
+- [ ] Decision recorded
+- [ ] If retired: BookStack containers gone
+
+
diff --git a/plans/homelab-improvements/tasks/TB-010-dev-stack-env-migration.md b/plans/homelab-improvements/tasks/TB-010-dev-stack-env-migration.md
new file mode 100644
index 0000000..525ecc6
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-010-dev-stack-env-migration.md
@@ -0,0 +1,58 @@
+# TB-010 — dev stack.env migration
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-010"** or **"dev stack.env migration"**.
+
+**In one sentence:** Make the dev stack use stack.env like everything else — no Portainer UI env vars.
+
+**Why it came up:** Every other stack commits secrets to stack.env; dev still needs UI mirroring (footgun).
+
+### Questions
+
+1. Have you been bitten by dev env drift before?
+2. Worth an afternoon, or low priority housekeeping?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/
+
+### If we do it — rough steps
+
+1. Move all ${VAR} into stack.env
+2. Deploy, verify Gitea/Plane/BookStack/code-server
+3. Clear Portainer UI env vars
+
+### Done when
+
+- [ ] Dev stack runs with env_file only
+
+
diff --git a/plans/homelab-improvements/tasks/TB-011-reap-orphan-volumes.md b/plans/homelab-improvements/tasks/TB-011-reap-orphan-volumes.md
new file mode 100644
index 0000000..5359e77
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-011-reap-orphan-volumes.md
@@ -0,0 +1,59 @@
+# TB-011 — Reap orphan volumes
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-011"** or **"reap orphan volumes"**.
+
+**In one sentence:** Delete old pre-split Docker named volumes that bind mounts replaced.
+
+**Why it came up:** htpc-download-box_* and valhalla-lab_* volumes waste disk and confuse what's canonical.
+
+### Questions
+
+1. Want me to SSH and list them with you before deleting anything?
+2. Backups done first? (TB-001)
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+SSH on valhalla only
+
+### If we do it — rough steps
+
+1. docker volume ls — confirm orphans
+2. Spot-check bind mounts healthy
+3. docker volume rm
+
+### Done when
+
+- [ ] Orphan volumes gone
+- [ ] All containers still healthy
+
+
diff --git a/plans/homelab-improvements/tasks/TB-012-maintainerr.md b/plans/homelab-improvements/tasks/TB-012-maintainerr.md
new file mode 100644
index 0000000..4e0e070
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-012-maintainerr.md
@@ -0,0 +1,59 @@
+# TB-012 — Maintainerr
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-012"** or **"maintainerr"**.
+
+**In one sentence:** Auto-clean stale Overseerr requests so the queue doesn't fill with junk.
+
+**Why it came up:** requests.ginnoir.com is public; old unfulfilled requests pile up.
+
+### Questions
+
+1. Is your Overseerr queue messy now, or is this preventive?
+2. How aggressive on age — 30 days? 90?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/media/
+
+### If we do it — rough steps
+
+1. Add maintainerr on media network
+2. Connect Overseerr + Sonarr/Radarr APIs
+3. Conservative cleanup rule first
+
+### Done when
+
+- [ ] Maintainerr connected
+- [ ] One rule configured
+
+
diff --git a/plans/homelab-improvements/tasks/TB-013-code-server-mount.md b/plans/homelab-improvements/tasks/TB-013-code-server-mount.md
new file mode 100644
index 0000000..97dd848
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-013-code-server-mount.md
@@ -0,0 +1,57 @@
+# TB-013 — Narrow code-server mount
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-013"** or **"narrow code-server mount"**.
+
+**In one sentence:** Stop code-server from mounting your entire home directory.
+
+**Why it came up:** code.ginnoir.com currently exposes /home/ginnoir — SSH keys, everything.
+
+### Questions
+
+1. What folders do you actually edit in code-server?
+2. homelabstack + a projects dir enough?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/docker-compose.yml
+
+### If we do it — rough steps
+
+1. Change mount to scoped path
+2. Symlink homelabstack if needed
+
+### Done when
+
+- [ ] .ssh and unrelated home dirs not visible in IDE
+
+
diff --git a/plans/homelab-improvements/tasks/TB-014-registry-auth.md b/plans/homelab-improvements/tasks/TB-014-registry-auth.md
new file mode 100644
index 0000000..599599c
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-014-registry-auth.md
@@ -0,0 +1,58 @@
+# TB-014 — Registry authentication
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-014"** or **"registry authentication"**.
+
+**In one sentence:** Put a password on your private Docker registry.
+
+**Why it came up:** registry.ginnoir.com is LAN-only but anyone on LAN/tailnet can push/pull.
+
+### Questions
+
+1. Do you push images to it regularly, or is it barely used?
+2. Will famapp CI or github-runner need login updated?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/
+
+### If we do it — rough steps
+
+1. htpasswd + registry auth env
+2. Test docker login/pull/push
+
+### Done when
+
+- [ ] Unauthenticated pull fails
+- [ ] You can pull with creds
+
+
diff --git a/plans/homelab-improvements/tasks/TB-015-vault-secrets-integration.md b/plans/homelab-improvements/tasks/TB-015-vault-secrets-integration.md
new file mode 100644
index 0000000..5c87f14
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-015-vault-secrets-integration.md
@@ -0,0 +1,57 @@
+# TB-015 — Use Vault for real
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-015"** or **"use vault for real"**.
+
+**In one sentence:** Vault runs but secrets live in stack.env — integrate Vault for CI/rotation without changing git-canonical deploy.
+
+**Why it came up:** Optional complexity; only worth it if you want rotation or CI secret centralization.
+
+### Questions
+
+1. What problem would Vault solve that stack.env doesn't?
+2. If 'nothing really' — drop this.
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+portainer-compose.yml, scripts
+
+### If we do it — rough steps
+
+1. Define Vault vs stack.env boundary
+2. Migrate one token (e.g. github-runner)
+
+### Done when
+
+- [ ] Clear use case — or dropped
+
+
diff --git a/plans/homelab-improvements/tasks/TB-016-document-plex.md b/plans/homelab-improvements/tasks/TB-016-document-plex.md
new file mode 100644
index 0000000..c6fcba9
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-016-document-plex.md
@@ -0,0 +1,57 @@
+# TB-016 — Document Plex
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-016"** or **"document plex"**.
+
+**In one sentence:** Write down where Plex lives and how it connects to Tautulli — it's not in this repo.
+
+**Why it came up:** Tautulli reads /storage1/PMS logs but Plex isn't in compose; easy to forget how it's managed.
+
+### Questions
+
+1. Is Plex on valhalla itself, or another box?
+2. Do you want it in compose eventually, or happy as host-native?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+Docs only
+
+### If we do it — rough steps
+
+1. You tell me: install path, systemd?, library paths
+2. Add short section to CLAUDE.md or EXTERNAL-SERVICES.md
+
+### Done when
+
+- [ ] Plex documented with paths
+
+
diff --git a/plans/homelab-improvements/tasks/TB-017-minio-consolidation.md b/plans/homelab-improvements/tasks/TB-017-minio-consolidation.md
new file mode 100644
index 0000000..f45f8ee
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-017-minio-consolidation.md
@@ -0,0 +1,57 @@
+# TB-017 — One MinIO instead of three
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-017"** or **"one minio instead of three"**.
+
+**In one sentence:** resume, famapp, and Plane each run their own MinIO — could be one server, three buckets.
+
+**Why it came up:** Less ops; slightly higher blast radius if MinIO dies.
+
+### Questions
+
+1. Do the three MinIO instances cause you pain today?
+2. If no — drop; isolation is fine.
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+resume, famapp, dev
+
+### If we do it — rough steps
+
+1. Decision
+2. If yes: migrate buckets, update env vars
+
+### Done when
+
+- [ ] Consolidated or explicitly kept separate
+
+
diff --git a/plans/homelab-improvements/tasks/TB-018-plane-evaluation.md b/plans/homelab-improvements/tasks/TB-018-plane-evaluation.md
new file mode 100644
index 0000000..7210fca
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-018-plane-evaluation.md
@@ -0,0 +1,57 @@
+# TB-018 — Is Plane worth it?
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-018"** or **"is plane worth it?"**.
+
+**In one sentence:** Plane is 7 containers for project management — do you use it?
+
+**Why it came up:** Gitea has issues; Homarr is your dashboard; Plane may be shelfware.
+
+### Questions
+
+1. When did you last open plane.ginnoir.com?
+2. Gitea issues enough, or need a kanban?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/
+
+### If we do it — rough steps
+
+1. Your usage answer
+2. Keep, or remove and reclaim resources
+
+### Done when
+
+- [ ] Decision recorded
+
+
diff --git a/plans/homelab-improvements/tasks/TB-019-immich.md b/plans/homelab-improvements/tasks/TB-019-immich.md
new file mode 100644
index 0000000..aef4994
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-019-immich.md
@@ -0,0 +1,60 @@
+# TB-019 — Immich (photos)
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-019"** or **"immich (photos)"**.
+
+**In one sentence:** Self-hosted Google Photos — family photo backup with a phone app.
+
+**Why it came up:** Natural fit next to famapp; you don't have photo backup today.
+
+### Questions
+
+1. Is phone photo backup something you actively want?
+2. Public with Authentik, or LAN/tailnet only?
+3. Storage growth on ZFS — okay?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New photos stack
+
+### If we do it — rough steps
+
+1. New stacks/photos/
+2. photos.ginnoir.com + optional Authentik
+3. Test upload from phone
+
+### Done when
+
+- [ ] Mobile upload works
+- [ ] In backup scope
+
+
diff --git a/plans/homelab-improvements/tasks/TB-020-mealie.md b/plans/homelab-improvements/tasks/TB-020-mealie.md
new file mode 100644
index 0000000..f65fb34
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-020-mealie.md
@@ -0,0 +1,58 @@
+# TB-020 — Mealie (recipes)
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-020"** or **"mealie (recipes)"**.
+
+**In one sentence:** Recipe manager with meal plans and shopping lists.
+
+**Why it came up:** Pairs with famapp/family theme; lightweight stack.
+
+### Questions
+
+1. Would you actually use this, or is it 'nice in theory'?
+2. Shared with family or just you?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New kitchen stack or famapp
+
+### If we do it — rough steps
+
+1. New stack
+2. recipes.ginnoir.com
+3. Import one recipe as test
+
+### Done when
+
+- [ ] You'd use it — or we drop it
+
+
diff --git a/plans/homelab-improvements/tasks/TB-021-paperless-ngx.md b/plans/homelab-improvements/tasks/TB-021-paperless-ngx.md
new file mode 100644
index 0000000..fd2759b
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-021-paperless-ngx.md
@@ -0,0 +1,58 @@
+# TB-021 — Paperless-ngx
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-021"** or **"paperless-ngx"**.
+
+**In one sentence:** Drop PDFs in a folder → OCR → searchable archive.
+
+**Why it came up:** ownCloud stores files; this adds document search and tagging.
+
+### Questions
+
+1. Do you scan receipts/documents often enough to matter?
+2. Or is ownCloud + Obsidian enough?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New docs stack
+
+### If we do it — rough steps
+
+1. New stack
+2. consume folder on ZFS
+3. Test one PDF OCR
+
+### Done when
+
+- [ ] Search finds text in test doc — or we skip
+
+
diff --git a/plans/homelab-improvements/tasks/TB-022-readarr.md b/plans/homelab-improvements/tasks/TB-022-readarr.md
new file mode 100644
index 0000000..d045398
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-022-readarr.md
@@ -0,0 +1,57 @@
+# TB-022 — Readarr (ebooks)
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-022"** or **"readarr (ebooks)"**.
+
+**In one sentence:** Automate ebook downloads like Sonarr does for TV.
+
+**Why it came up:** You have /storage1/Books in ownCloud; no *arr for books yet.
+
+### Questions
+
+1. Do you acquire ebooks automatically, or buy/download manually?
+2. Audiobooks too? (Different tool — Audiobookshelf)
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/media/
+
+### If we do it — rough steps
+
+1. Add readarr + Caddy internal block
+2. Hook Prowlarr + download client
+
+### Done when
+
+- [ ] Test grab lands in Books — or drop
+
+
diff --git a/plans/homelab-improvements/tasks/TB-023-autobrr.md b/plans/homelab-improvements/tasks/TB-023-autobrr.md
new file mode 100644
index 0000000..63cdc59
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-023-autobrr.md
@@ -0,0 +1,58 @@
+# TB-023 — Autobrr
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-023"** or **"autobrr"**.
+
+**In one sentence:** Monitor private tracker IRC/RSS and auto-push to qBittorrent.
+
+**Why it came up:** For active private tracker use — overkill if you're mostly public indexers via Prowlarr.
+
+### Questions
+
+1. Are you on private trackers that need announce monitoring?
+2. Or skip — Prowlarr enough?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/media/
+
+### If we do it — rough steps
+
+1. Add autobrr
+2. Connect qBit
+3. One filter if proceeding
+
+### Done when
+
+- [ ] Worth it for your tracker habits — or dropped
+
+
diff --git a/plans/homelab-improvements/tasks/TB-024-syncthing.md b/plans/homelab-improvements/tasks/TB-024-syncthing.md
new file mode 100644
index 0000000..3241abe
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-024-syncthing.md
@@ -0,0 +1,58 @@
+# TB-024 — Syncthing
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-024"** or **"syncthing"**.
+
+**In one sentence:** Continuous folder sync between devices — ROM saves, dotfiles, etc.
+
+**Why it came up:** Different from ownCloud's model; good for device-to-device without 'upload to server'.
+
+### Questions
+
+1. What folders would you sync? (Be specific — not all of /storage1)
+2. Conflict risk with RomM/ownCloud on same paths?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New sync stack
+
+### If we do it — rough steps
+
+1. New stack
+2. Pair two devices
+3. One test folder
+
+### Done when
+
+- [ ] Sync works on chosen folder — or drop
+
+
diff --git a/plans/homelab-improvements/tasks/TB-025-metrics-beszel.md b/plans/homelab-improvements/tasks/TB-025-metrics-beszel.md
new file mode 100644
index 0000000..1714e63
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-025-metrics-beszel.md
@@ -0,0 +1,57 @@
+# TB-025 — Metrics (Beszel/Netdata)
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-025"** or **"metrics (beszel/netdata)"**.
+
+**In one sentence:** See CPU/RAM/disk graphs — Uptime Kuma only tells you up/down.
+
+**Why it came up:** Homelab has no metrics dashboard today.
+
+### Questions
+
+1. Do you ever wonder 'why is the server slow' without an answer?
+2. Beszel (minimal) vs Netdata (detailed) — or skip?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/monitoring/
+
+### If we do it — rough steps
+
+1. Add chosen tool
+2. metrics.ginnoir.com internal
+
+### Done when
+
+- [ ] Dashboard shows host + container usage
+
+
diff --git a/plans/homelab-improvements/tasks/TB-026-igir-container.md b/plans/homelab-improvements/tasks/TB-026-igir-container.md
new file mode 100644
index 0000000..f8d807c
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-026-igir-container.md
@@ -0,0 +1,57 @@
+# TB-026 — IGIR container
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-026"** or **"igir container"**.
+
+**In one sentence:** Schedule your existing ROM cleanup scripts instead of SSH ad-hoc.
+
+**Why it came up:** igir-*.sh scripts exist; romhacks handoff will add more ROMs to maintain.
+
+### Questions
+
+1. Do you run IGIR often enough to automate?
+2. Or manual is fine?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/roms/
+
+### If we do it — rough steps
+
+1. Container wrapping existing scripts
+2. Weekly dry-run report → ntfy
+
+### Done when
+
+- [ ] Scheduled dry-run runs — or drop
+
+
diff --git a/plans/homelab-improvements/tasks/TB-027-jellyfin.md b/plans/homelab-improvements/tasks/TB-027-jellyfin.md
new file mode 100644
index 0000000..2cc4ba3
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-027-jellyfin.md
@@ -0,0 +1,58 @@
+# TB-027 — Jellyfin instead of Plex
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-027"** or **"jellyfin instead of plex"**.
+
+**In one sentence:** Self-host streaming in Docker instead of host-native Plex.
+
+**Why it came up:** Only if you want to move off Plex licensing / centralize in compose.
+
+### Questions
+
+1. Happy with Plex today?
+2. Family/clients okay switching apps?
+3. Hardware transcoding needed?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+media or new streaming
+
+### If we do it — rough steps
+
+1. Decision
+2. If yes: jellyfin + GPU passthrough eval
+
+### Done when
+
+- [ ] Stay on Plex or migrate plan documented
+
+
diff --git a/plans/homelab-improvements/tasks/TB-028-home-assistant.md b/plans/homelab-improvements/tasks/TB-028-home-assistant.md
new file mode 100644
index 0000000..8a65bc3
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-028-home-assistant.md
@@ -0,0 +1,58 @@
+# TB-028 — Home Assistant
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-028"** or **"home assistant"**.
+
+**In one sentence:** Home automation hub — only makes sense if you have smart devices to control.
+
+**Why it came up:** Zero home automation in the stack today.
+
+### Questions
+
+1. How many smart devices do you have?
+2. Zigbee/Z-Wave USB stick on valhalla?
+3. If <5 devices — probably skip.
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New home stack
+
+### If we do it — rough steps
+
+1. Go/no-go
+2. If go: HA + USB passthrough
+
+### Done when
+
+- [ ] Go/no-go with reason
+
+
diff --git a/plans/homelab-improvements/tasks/TB-029-firefly-iii.md b/plans/homelab-improvements/tasks/TB-029-firefly-iii.md
new file mode 100644
index 0000000..b1d14db
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-029-firefly-iii.md
@@ -0,0 +1,57 @@
+# TB-029 — Firefly III (finance)
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-029"** or **"firefly iii (finance)"**.
+
+**In one sentence:** Self-hosted budgeting and transaction tracking.
+
+**Why it came up:** Niche personal finance; sensitive data.
+
+### Questions
+
+1. Do you want to self-host finances, or use a SaaS/bank app?
+2. If yes — internal-only + Authentik mandatory.
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New finance stack
+
+### If we do it — rough steps
+
+1. New stack
+2. internal-only Caddy
+
+### Done when
+
+- [ ] Using it — or dropped
+
+
diff --git a/plans/homelab-improvements/tasks/TB-030-adguard-home.md b/plans/homelab-improvements/tasks/TB-030-adguard-home.md
new file mode 100644
index 0000000..8aa8c98
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-030-adguard-home.md
@@ -0,0 +1,57 @@
+# TB-030 — AdGuard Home
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-030"** or **"adguard home"**.
+
+**In one sentence:** Network-wide ad blocking and local DNS for *.ginnoir.com.
+
+**Why it came up:** Stops LAN hairpin through Cloudflare; blocks ads if router DNS points here.
+
+### Questions
+
+1. Does your router already do ad blocking / local DNS?
+2. Willing to point DHCP DNS at valhalla?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+New network stack or mgmt
+
+### If we do it — rough steps
+
+1. Check router first
+2. If gap: AdGuard + DNS rewrites
+
+### Done when
+
+- [ ] Router sufficient — or AdGuard deployed
+
+
diff --git a/plans/homelab-improvements/tasks/TB-031-pingvin-share.md b/plans/homelab-improvements/tasks/TB-031-pingvin-share.md
new file mode 100644
index 0000000..f722dce
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-031-pingvin-share.md
@@ -0,0 +1,57 @@
+# TB-031 — Pingvin Share
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-031"** or **"pingvin share"**.
+
+**In one sentence:** Temporary password-protected file links — lighter than ownCloud shares.
+
+**Why it came up:** One-off 'here's a file' drops; ownCloud is heavier.
+
+### Questions
+
+1. Do you send ad-hoc files to people often?
+2. Or rare enough to skip?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/share/
+
+### If we do it — rough steps
+
+1. Add to share stack
+2. drop.ginnoir.com or similar
+
+### Done when
+
+- [ ] Test share link — or drop
+
+
diff --git a/plans/homelab-improvements/tasks/TB-032-wikijs.md b/plans/homelab-improvements/tasks/TB-032-wikijs.md
new file mode 100644
index 0000000..7c4bf6f
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-032-wikijs.md
@@ -0,0 +1,57 @@
+# TB-032 — Wiki.js
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-032"** or **"wiki.js"**.
+
+**In one sentence:** Modern wiki — only relevant if TB-009 says 'keep a structured wiki' but not BookStack.
+
+**Why it came up:** Depends on TB-009 decision; don't start here first.
+
+### Questions
+
+1. Did TB-009 land on 'keep wiki'?
+2. If BookStack retired or Obsidian-only — skip this entirely.
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+stacks/dev/
+
+### If we do it — rough steps
+
+1. Only if TB-009 = Option C
+2. Import + replace BookStack
+
+### Done when
+
+- [ ] TB-009 dependency satisfied — or N/A
+
+
diff --git a/plans/homelab-improvements/tasks/TB-033-public-surface-audit.md b/plans/homelab-improvements/tasks/TB-033-public-surface-audit.md
new file mode 100644
index 0000000..1227b62
--- /dev/null
+++ b/plans/homelab-improvements/tasks/TB-033-public-surface-audit.md
@@ -0,0 +1,59 @@
+# TB-033 — Public surface audit
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-033"** or **"public surface audit"**.
+
+**In one sentence:** A short conversation + checklist: what's reachable from the internet, and should it be?
+
+**Why it came up:** Some things are public on purpose (Foundry, famapp); some might surprise you (Obsidian).
+
+### Questions
+
+1. Want to walk through the list together in chat instead of reading a table?
+2. Any service you *know* should be public that we'd flag as wrong?
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+Caddyfile review only
+
+### If we do it — rough steps
+
+1. Walk each public domain in conversation
+2. Mark: keep public / lock down / drop
+3. Save decisions here and in TB-005/TB-006 as needed
+
+### Done when
+
+- [ ] Every public site has your yes/no
+- [ ] Action items linked to other TBs
+
+
diff --git a/plans/homelab-improvements/tasks/_TEMPLATE.md b/plans/homelab-improvements/tasks/_TEMPLATE.md
new file mode 100644
index 0000000..15fe601
--- /dev/null
+++ b/plans/homelab-improvements/tasks/_TEMPLATE.md
@@ -0,0 +1,58 @@
+# TB-XXX — Title
+
+**Status:** not started
+**Your call:** _(unset — talk first)_
+
+---
+
+## Talk first
+
+> **Stop.** Don't read Reference until we've talked.
+> In chat, say **"let's do TB-XXX"** and we'll go through this together.
+
+**In one sentence:** _(what this is)_
+
+**Why it came up:** _(one line)_
+
+### Questions
+
+1. _(question)_
+2. _(question)_
+3. _(question)_
+
+### Your options
+
+| Option | Meaning |
+|--------|---------|
+| **Do it** | We're doing this — I'll implement or walk you through it |
+| **Later** | Keep on the list, not now |
+| **Drop** | Remove from plan — totally fine |
+| **Tweak** | Change scope; tell me how |
+
+### What we decided
+
+| | |
+|---|---|
+| **Decision** | |
+| **Notes** | |
+| **Date** | |
+
+---
+
+
+Reference (only open if we're doing it)
+
+### Stack / files
+
+_(where changes live)_
+
+### If we do it — rough steps
+
+1. ...
+2. ...
+
+### Done when
+
+- [ ] ...
+
+
diff --git a/stacks/backup/README.md b/stacks/backup/README.md
new file mode 100644
index 0000000..d554f2d
--- /dev/null
+++ b/stacks/backup/README.md
@@ -0,0 +1,39 @@
+# backup stack
+
+Kopia → Backblaze B2. Backs up `/config`, **selected** `labdata` paths (see `SCOPE.md`), and nightly SQL dumps.
+
+## Before first deploy
+
+1. **Backblaze B2** (recommended offsite — ~$6/TB/mo)
+ - Create a bucket (e.g. `valhalla-homelab-backup`)
+ - Create an **Application Key** scoped to that bucket
+ - Note the S3 endpoint for your region
+
+2. **Fill `stack.env`**
+ - `KOPIA_REPOSITORY_PASSWORD` — `openssl rand -hex 32`
+ - `B2_KEY_ID`, `B2_APPLICATION_KEY`, `B2_BUCKET`, `B2_ENDPOINT`
+
+3. **Host paths** (auto-created on first start if missing)
+ - `/config/kopia/{config,cache,logs}`
+ - `/config/backup/dumps`
+
+4. **Register** new Portainer git stack → `stacks/backup` (one-time)
+
+5. **Push** → Portainer redeploys within 5 min
+
+## After deploy
+
+- UI: `http://valhalla:51515` (or add `backup.ginnoir.com` internal-only in Caddy)
+- Subscribe phone to ntfy topic `backup` (or path in `NTFY_URL`)
+- **Restore test** (do once): pick a small dump from `/config/backup/dumps/`, restore to a throwaway container
+
+## Schedule
+
+| Time | What |
+|------|------|
+| 02:50 | `backup-dump-databases.sh` — SQL dumps |
+| 03:00 | Kopia snapshots (paths in `snapshot-paths.txt`) |
+
+## If B2 creds are empty
+
+Kopia still starts with UI — configure repository manually, then set policies for each path in `snapshot-paths.txt`.
diff --git a/stacks/backup/SCOPE.md b/stacks/backup/SCOPE.md
new file mode 100644
index 0000000..c421466
--- /dev/null
+++ b/stacks/backup/SCOPE.md
@@ -0,0 +1,49 @@
+# What we back up (and skip)
+
+**Rule:** everything irreplaceable or slow to rebuild — **not** ROM files, **not** provider cover art.
+
+## Always
+
+| Path | Why |
+|------|-----|
+| `/config` | App state, DBs, CouchDB vault — **includes RomM MariaDB** (`/config/romm/mariadb`) |
+| `/config/backup/dumps` | Nightly SQL dumps — **includes `romm-db`** |
+
+## RomM
+
+Per [RomM docs](https://docs.romm.app/): **`assets` = saves/states**; **`resources` = fetched covers**.
+
+| Included | Path | What |
+|----------|------|------|
+| Database | `/config/romm/mariadb` + nightly dump | Scans, hashes, matches (weeks of work) |
+| Saves & states | `labdata/romm/assets` | RomM-managed save files |
+| Config/cache | `/config/romm/config`, `/config/romm/redis` | Via `/config` snapshot |
+
+| Skipped | Path | What |
+|---------|------|------|
+| Cover art | `labdata/romm/resources` | IGDB/SteamGridDB — minutes to re-fetch |
+
+## Emulation (`/storage1/Emulation`) — excluded
+
+Legacy pre-RomM tree (EmuDeck, ES-DE, 13T `roms/`, symlinks). **Not backed up offsite.** RomM is the source of truth for saves via `labdata/romm/assets` + DB.
+
+## labdata — included
+
+| Path | Why |
+|------|-----|
+| `gitea/repositories`, `gitea/lfs` | Git + LFS |
+| `famapp/minio`, `resume/minio`, `plane/minio` | User uploads |
+| `romhacks/metadata`, `romhacks/library` | Catalog work + guides/spreadsheets |
+| `romm/assets` | Saves (see RomM above) |
+
+## labdata — skipped
+
+| Path | Why |
+|------|-----|
+| `romm/resources` | Provider cover art |
+| `registry` | Re-pull images |
+| `romhacks/incoming` | Transient download queue |
+
+## Change scope
+
+Edit `snapshot-paths.txt`, bind mounts in `docker-compose.yml`, and `SCOPE.md`.
diff --git a/stacks/backup/crontab b/stacks/backup/crontab
new file mode 100644
index 0000000..b39aee2
--- /dev/null
+++ b/stacks/backup/crontab
@@ -0,0 +1,3 @@
+# min hour day month weekday command
+50 2 * * * /bin/sh /scripts/backup-dump-databases.sh
+0 3 * * * /bin/sh /scripts/backup-run-snapshots.sh
diff --git a/stacks/backup/docker-compose.yml b/stacks/backup/docker-compose.yml
new file mode 100644
index 0000000..5f34c9d
--- /dev/null
+++ b/stacks/backup/docker-compose.yml
@@ -0,0 +1,85 @@
+# backup stack — Kopia snapshots of /config, selected /storage1/labdata paths, and DB dumps.
+# Scope: stacks/backup/SCOPE.md + snapshot-paths.txt (excludes RomM art, registry, transient dirs).
+#
+# Offsite target: Backblaze B2 (S3-compatible). One-time repo setup — see README.md.
+# Schedule: backup-scheduler dumps databases ~02:50, Kopia snapshots ~03:00 (policy).
+#
+# Env convention: stack.env only (no Portainer UI vars). Kopia UI is internal-only
+# via Caddy after you add backup.ginnoir.com (optional, README).
+
+services:
+ kopia:
+ container_name: kopia
+ image: kopia/kopia:0.17
+ hostname: kopia
+ restart: unless-stopped
+ networks: [backup, edge]
+ env_file:
+ - stack.env
+ environment:
+ - KOPIA_PASSWORD=${KOPIA_REPOSITORY_PASSWORD}
+ - TZ=${TZ}
+ - B2_BUCKET=${B2_BUCKET}
+ - B2_KEY_ID=${B2_KEY_ID}
+ - B2_APPLICATION_KEY=${B2_APPLICATION_KEY}
+ - B2_ENDPOINT=${B2_ENDPOINT}
+ cap_add:
+ - SYS_ADMIN
+ security_opt:
+ - apparmor:unconfined
+ devices:
+ - /dev/fuse:/dev/fuse
+ volumes:
+ - /config/kopia/config:/app/config
+ - /config/kopia/cache:/app/cache
+ - /config/kopia/logs:/app/logs
+ - /config:/backup/config:ro
+ - /config/backup/dumps:/backup/dumps:ro
+ - /storage1/labdata/gitea/repositories:/backup/labdata/gitea/repositories:ro
+ - /storage1/labdata/gitea/lfs:/backup/labdata/gitea/lfs:ro
+ - /storage1/labdata/famapp/minio:/backup/labdata/famapp/minio:ro
+ - /storage1/labdata/resume/minio:/backup/labdata/resume/minio:ro
+ - /storage1/labdata/plane/minio:/backup/labdata/plane/minio:ro
+ - /storage1/labdata/romhacks/metadata:/backup/labdata/romhacks/metadata:ro
+ - /storage1/labdata/romhacks/library:/backup/labdata/romhacks/library:ro
+ - /storage1/labdata/romm/assets:/backup/labdata/romm/assets:ro
+ - ./init-kopia.sh:/init-kopia.sh:ro
+ - ./snapshot-paths.txt:/snapshot-paths.txt:ro
+ ports:
+ - "51515:51515"
+ entrypoint: ["/bin/sh", "/init-kopia.sh"]
+ labels:
+ - "com.centurylabs.watchtower.enable=false"
+
+ backup-scheduler:
+ container_name: backup_scheduler
+ image: alpine:3.20
+ restart: unless-stopped
+ networks: [backup]
+ env_file:
+ - stack.env
+ environment:
+ - TZ=${TZ}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - /config/backup/dumps:/dumps
+ - ./scripts/backup-dump-databases.sh:/scripts/backup-dump-databases.sh:ro
+ - ./scripts/backup-run-snapshots.sh:/scripts/backup-run-snapshots.sh:ro
+ - ./snapshot-paths.txt:/snapshot-paths.txt:ro
+ - ./crontab:/etc/crontab:ro
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - |
+ apk add --no-cache docker-cli curl tzdata >/dev/null
+ printenv | grep -E '^(NTFY_|TZ=)' > /etc/backup.env || true
+ crond -f -l 8 -c /etc
+ depends_on:
+ - kopia
+
+networks:
+ backup:
+ name: backup
+ driver: bridge
+ edge:
+ name: edge
+ external: true
diff --git a/stacks/backup/init-kopia.sh b/stacks/backup/init-kopia.sh
new file mode 100644
index 0000000..6f1e323
--- /dev/null
+++ b/stacks/backup/init-kopia.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+# One-time (or idempotent) Kopia repo + policy setup, then start server UI.
+set -e
+
+CONFIG_DIR=/app/config
+export KOPIA_CONFIG_PATH="${CONFIG_DIR}/repository.config"
+
+mkdir -p "${CONFIG_DIR}" /app/cache /app/logs
+
+# Connect or create B2 repository when credentials are present.
+if [ -n "${B2_KEY_ID}" ] && [ -n "${B2_APPLICATION_KEY}" ] && [ -n "${B2_BUCKET}" ]; then
+ if [ ! -f "${KOPIA_CONFIG_PATH}" ]; then
+ echo "init-kopia: creating S3 repository on bucket ${B2_BUCKET}"
+ kopia repository create s3 \
+ --bucket="${B2_BUCKET}" \
+ --access-key="${B2_KEY_ID}" \
+ --secret-access-key="${B2_APPLICATION_KEY}" \
+ --endpoint="${B2_ENDPOINT}" \
+ --password="${KOPIA_PASSWORD}"
+ else
+ echo "init-kopia: repository config exists, skipping create"
+ fi
+
+ while IFS= read -r path || [ -n "${path}" ]; do
+ case "${path}" in ''|\#*) continue ;; esac
+ kopia policy set "${path}" \
+ --keep-latest 14 \
+ --keep-daily 30 \
+ --keep-weekly 12 \
+ --keep-monthly 24 \
+ --compression=zstd \
+ --snapshot-time=03:00 \
+ --enable-actions=false \
+ 2>/dev/null || kopia policy set "${path}" \
+ --keep-latest 14 \
+ --keep-daily 30 \
+ --keep-weekly 12 \
+ --keep-monthly 24 \
+ --compression=zstd \
+ --snapshot-time=03:00
+ done < /snapshot-paths.txt
+else
+ echo "init-kopia: B2 credentials empty — start UI only; configure repo manually (see README)"
+fi
+
+exec kopia server start --ui --insecure --address=0.0.0.0:51515
diff --git a/stacks/backup/scripts/backup-dump-databases.sh b/stacks/backup/scripts/backup-dump-databases.sh
new file mode 100644
index 0000000..98c4282
--- /dev/null
+++ b/stacks/backup/scripts/backup-dump-databases.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+# Dump all homelab databases to /config/backup/dumps (gzip SQL).
+# Runs inside backup_scheduler; needs docker.sock.
+set -eu
+
+DUMP_ROOT=/dumps
+STAMP=$(date +%Y%m%d-%H%M%S)
+DIR="${DUMP_ROOT}/${STAMP}"
+mkdir -p "${DIR}"
+
+log() { echo "[backup-dump ${STAMP}] $*"; }
+
+dump_postgres() {
+ name="$1"
+ user="$2"
+ db="$3"
+ if ! docker ps --format '{{.Names}}' | grep -qx "${name}"; then
+ log "skip ${name} (not running)"
+ return 0
+ fi
+ log "dump ${name} → ${db}"
+ docker exec "${name}" pg_dump -U "${user}" "${db}" | gzip -c > "${DIR}/${name}.sql.gz"
+}
+
+dump_mariadb() {
+ name="$1"
+ db="$2"
+ if ! docker ps --format '{{.Names}}' | grep -qx "${name}"; then
+ log "skip ${name} (not running)"
+ return 0
+ fi
+ log "dump ${name} → ${db}"
+ docker exec "${name}" sh -c "mariadb-dump -u root -p\"\$MYSQL_ROOT_PASSWORD\" \"${db}\"" \
+ | gzip -c > "${DIR}/${name}.sql.gz"
+}
+
+log "starting database dumps → ${DIR}"
+
+dump_postgres postgres_gitea gitea gitea
+dump_postgres postgres_plane plane plane
+dump_postgres postgres_authentik authentik authentik
+dump_postgres postgres_famapp famapp famapp
+dump_postgres postgres_resume postgres postgres
+
+dump_mariadb mariadb_bookstack bookstack
+dump_mariadb owncloud_mariadb owncloud
+dump_mariadb romm-db romm
+
+find "${DUMP_ROOT}" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
+
+log "done"
diff --git a/stacks/backup/scripts/backup-run-snapshots.sh b/stacks/backup/scripts/backup-run-snapshots.sh
new file mode 100644
index 0000000..33c9d73
--- /dev/null
+++ b/stacks/backup/scripts/backup-run-snapshots.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+set -eu
+
+[ -f /etc/backup.env ] && . /etc/backup.env
+
+NTFY_URL="${NTFY_URL:-https://ntfy.ginnoir.com/backup}"
+NTFY_TITLE="${NTFY_TITLE:-ValhallaBackup}"
+
+notify() {
+ priority="$1"
+ message="$2"
+ curl -sf -H "Title: ${NTFY_TITLE}" -H "Priority: ${priority}" -d "${message}" "${NTFY_URL}" >/dev/null 2>&1 || true
+}
+
+if ! docker ps --format '{{.Names}}' | grep -qx kopia; then
+ notify high "Kopia container not running — snapshots skipped"
+ exit 1
+fi
+
+FAILED=0
+while IFS= read -r path || [ -n "${path}" ]; do
+ case "${path}" in ''|\#*) continue ;; esac
+ if ! docker exec kopia kopia snapshot create "${path}"; then
+ FAILED=1
+ fi
+done < /snapshot-paths.txt
+
+if [ "${FAILED}" -eq 0 ]; then
+ notify default "Snapshots OK ($(date +%Y-%m-%d\ %H:%M))"
+else
+ notify high "One or more snapshots FAILED — check docker logs kopia"
+ exit 1
+fi
diff --git a/stacks/backup/snapshot-paths.txt b/stacks/backup/snapshot-paths.txt
new file mode 100644
index 0000000..2fe1db1
--- /dev/null
+++ b/stacks/backup/snapshot-paths.txt
@@ -0,0 +1,11 @@
+# Kopia snapshot roots (one path per line). Edit when scope changes.
+/backup/config
+/backup/dumps
+/backup/labdata/gitea/repositories
+/backup/labdata/gitea/lfs
+/backup/labdata/famapp/minio
+/backup/labdata/resume/minio
+/backup/labdata/plane/minio
+/backup/labdata/romhacks/metadata
+/backup/labdata/romhacks/library
+/backup/labdata/romm/assets
diff --git a/stacks/backup/stack.env b/stacks/backup/stack.env
new file mode 100644
index 0000000..000681c
--- /dev/null
+++ b/stacks/backup/stack.env
@@ -0,0 +1,15 @@
+# backup stack — Kopia repository + Backblaze B2 offsite.
+TZ=America/Chicago
+
+# Encrypts the Kopia repository (NOT your B2 login).
+KOPIA_REPOSITORY_PASSWORD=8f4c2a9e1b7d6035f8e2c4a6910d3b7e5f9a8c2d6b4e1f0a3c7d9e2b5f8a1c4
+
+# Backblaze B2 (S3-compatible API)
+B2_BUCKET=ginnoir-backup
+B2_KEY_ID=00452ab80ae44e10000000001
+B2_APPLICATION_KEY=K004P2lGTtRiCe430O5dVZJiTn7Kwr8
+B2_ENDPOINT=https://s3.us-west-004.backblazeb2.com
+
+# ntfy alerts (same pattern as watchtower)
+NTFY_URL=https://ntfy.ginnoir.com/backup
+NTFY_TITLE=ValhallaBackup
diff --git a/stacks/roms/docker-compose.yml b/stacks/roms/docker-compose.yml
index ea17a91..de1a8ae 100644
--- a/stacks/roms/docker-compose.yml
+++ b/stacks/roms/docker-compose.yml
@@ -11,7 +11,7 @@
# UI env vars for ${VAR} substitution. See memory portainer-env-interpolation.
#
# Tiered binds: DB + RomM config + bundled-redis -> /config/romm (SSD);
-# RomM blobs (downloaded art + user saves/states) -> /storage1/labdata/romm (ZFS).
+# RomM labdata (ZFS): assets = saves/states; resources = fetched cover art (IGDB etc.).
# The labdata/romm/* targets are auto-created by Docker as root on first start.
#
# Only romm joins `edge` (Caddy proxies romm:8080); romm-db stays on `roms` only.