Files
homelabstack/plans/homelab-improvements/_generate_tasks.py
T
ginnoir 7c7f6dfebc Add Kopia backup stack with Backblaze B2 offsite target.
Protects /config, selective labdata, and nightly DB dumps; documents homelab improvement plan briefs.
2026-06-10 21:21:34 -05:00

628 lines
24 KiB
Python

#!/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/<platform>",
"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** | |
---
<details>
<summary>Reference (only open if we're doing it)</summary>
### Stack / files
{task["stack"]}
### If we do it — rough steps
{steps}
### Done when
{done}
</details>
"""
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()