A self-hosted environment organized around authenticated remote access, containerized applications, synchronized storage, automation, observability, and agent-assisted workflows. Follow the diagrams from system context to individual application pipelines to see which clients open each dashboard, which services exchange data, and where durable state is kept.
Architecture and workflows
00-context · Foundations
System context
what operator is and what it depends on
One screen, no container detail. Establishes current access, active compose projects, the agent plane, storage, and off-box dependencies. Every later diagram zooms into one box here.
%% 00-context — system context. What this box is, who talks to it, what it depends on.
%% Source of truth: src/00-context.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 42, "rankSpacing": 48, "diagramPadding": 18}}}%%
flowchart TB
subgraph people["People and devices"]
x_user["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/user.svg' width='72' height='72' alt=''/><br/><b>User</b></div>"]
workstation["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/laptop-workstation.svg' width='72' height='72' alt=''/><br/><b>workstation — laptop</b><br/>authenticated workstation</div>"]
mobile_device["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/phone.svg' width='72' height='72' alt=''/><br/><b>Phone</b><br/>authenticated mobile client</div>"]
end
subgraph edge["Current ingress"]
x_overlay_access["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/overlay.svg' width='72' height='72' alt=''/><br/><b>Authenticated overlay access</b><br/>overlay.example.test<br/><b>application access</b></div>"]
x_lan["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/internet.svg' width='72' height='72' alt=''/><br/><b>LAN / WAN</b><br/>ssh 22 · slskd 50300 only</div>"]
end
subgraph box["operator — Generic host · 7.6 GiB RAM"]
x_stack_homelab["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/docker.svg' width='72' height='72' alt=''/><br/><b>homelab compose project</b></div>"]
x_agent_plane["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/agent.svg' width='72' height='72' alt=''/><br/><b>agent plane</b><br/>Pi · Herdr · Hermes · homelab-mcp</div>"]
x_stack_rustdesk["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/rustdesk.svg' width='72' height='72' alt=''/><br/><b>rustdesk relay · host net</b></div>"]
x_storage["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/storage.svg' width='72' height='72' alt=''/><br/><b>storage</b><br/>media · music · books · photos · workspace</div>"]
end
subgraph ext["External dependencies"]
x_vpn_egress["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/vpn.svg' width='72' height='72' alt=''/><br/><b>Cloudflare WARP</b><br/>+ ProtonVPN</div>"]
x_anthropic["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/agent.svg' width='72' height='72' alt=''/><br/><b>Models through Pi</b><br/>Codex · Grok · Antigravity<br/>Claude<br/>via Pro subscription</div>"]
x_telegram["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/telegram.svg' width='72' height='72' alt=''/><br/><b>Telegram</b><br/>bots and alerts</div>"]
x_aws["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/aws.svg' width='72' height='72' alt=''/><br/><b>AWS example-region-1</b><br/>cka-dojo · on demand only</div>"]
x_content["<div style='width:220px;min-height:160px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/00-context/search.svg' width='72' height='72' alt=''/><br/><b>Indexers · Soulseek</b><br/>Anna's Archive · job sources</div>"]
end
x_user --> workstation
x_user --> mobile_device
workstation ==> x_overlay_access
mobile_device ==> x_overlay_access
x_overlay_access ==> x_stack_homelab
x_overlay_access ==> x_agent_plane
x_lan --> x_stack_rustdesk
x_lan --> x_stack_homelab
x_stack_homelab --> x_storage
x_agent_plane --> x_storage
x_stack_homelab ---->|"download traffic"| x_vpn_egress
x_vpn_egress --> x_content
x_agent_plane ---->|"LLM calls"| x_anthropic
x_agent_plane ---->|"digests and alerts"| x_telegram
x_agent_plane -...->|"terraform, parked when idle"| x_aws
%% Keep cross-group edges attached to nodes: do not override group direction.
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_user,workstation,mobile_device host
class x_overlay_access,x_lan ingress
class x_stack_homelab,x_stack_rustdesk media
class x_agent_plane ai
class x_storage data
class x_vpn_egress vpn
class x_anthropic,x_telegram,x_content,x_aws external
Notes
I use the laptop as the main control point. In a browser I open the home dashboard, this atlas, Grafana, the investment dashboard, and the other web apps through authenticated overlay access. The phone uses the same private access path for dashboards and also sends photos, wearable exports, and synchronized files.
The homelab host runs two kinds of work. Docker Compose runs the web applications and their databases. System services run jobs that need host files, timers, or tighter operating-system controls, including the agent gateway, investment pipeline, and health-data importers.
Syncthing connects the laptop, phone, and server. I use it to keep the Obsidian workspace, music, selected files, and incoming wearable exports available where they are needed. Application databases and generated files stay on the server unless a diagram explicitly shows them being synchronized.
External traffic has a specific purpose. WARP and ProtonVPN provide egress for selected download or lookup workflows. Model providers answer agent requests. Telegram carries bot conversations, digests, and failure alerts. AWS is used only when I start a CKA practice session.
This first diagram is the map for the rest of the site. Access and networking are expanded in 01 and 02. Applications are covered in 03 through 07. Agent, job-search, training, automation, investment, and health workflows follow after that.
01-topology · Foundations
Topology
host, authenticated overlay, and active Docker networks
The network skeleton: authenticated clients reach dashboards through the overlay, while container networks and direct protocols remain separate paths.
%% 01-topology — physical host, authenticated overlay, and every docker network.
%% Source of truth: src/01-topology.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 36, "diagramPadding": 24}}}%%
flowchart TD
subgraph authenticated overlay["Authenticated overlay — overlay.example.test"]
workstation["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/laptop-workstation.svg' width='72' height='72' alt=''/><br/>workstation (schematic)<br/>authenticated overlay peer</div>"]
mobile_device["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-phone.svg' width='72' height='72' alt=''/><br/>mobile device<br/>authenticated overlay peer</div>"]
overlay_host["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-overlay.svg' width='72' height='72' alt=''/><br/>homelab host</div>"]
end
subgraph group_host["Physical host"]
host["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/hp-operator-800-g3-dm.svg' width='72' height='72' alt=''/><br/>operator — Generic host (schematic) · 7.6 GiB RAM · Linux</div>"]
subgraph group_hostnet["Host networking"]
hostnet["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-network.svg' width='72' height='72' alt=''/><br/>host network — no NAT, ports land on the host directly</div>"]
syncthing["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-syncthing.svg' width='72' height='72' alt=''/><br/>syncthing<br/>8384 UI · 22000 sync · 21027 disc</div>"]
hbbs["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-rustdesk.svg' width='72' height='72' alt=''/><br/>hbbs — rustdesk relay<br/>21115-21118</div>"]
hbbr["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-rustdesk.svg' width='72' height='72' alt=''/><br/>hbbr — rustdesk relay<br/>21119</div>"]
end
subgraph group_n_homelab["Application network"]
n_homelab["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-network.svg' width='72' height='72' alt=''/><br/>homelab_homelab — 198.18.1.0/16</div>"]
x_homelab_members["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-package.svg' width='72' height='72' alt=''/><br/>application containers<br/>media · books · photos · rss · dashboards<br/>see 03-containers</div>"]
gluetun["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-gluetun.svg' width='72' height='72' alt=''/><br/>gluetun — WARP egress<br/>qbittorrent shares this netns</div>"]
gluetun_books["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-gluetun.svg' width='72' height='72' alt=''/><br/>gluetun-books — Proton HTTP proxy<br/>internal :8888, no published port</div>"]
end
subgraph group_n_default["Default network"]
n_default["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-network.svg' width='72' height='72' alt=''/><br/>homelab_default — 198.18.2.0/16</div>"]
watchtower["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-watchtower.svg' width='72' height='72' alt=''/><br/>watchtower<br/>sole member · declares no network</div>"]
end
subgraph group_n_wger["Fitness network"]
n_wger["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-network.svg' width='72' height='72' alt=''/><br/>wger_network — 198.18.3.0/16</div>"]
x_wger_members["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-wger.svg' width='72' height='72' alt=''/><br/>wger stack<br/>web · worker · scheduler · sync · db · cache · nginx</div>"]
end
subgraph n_unused["built-in, unused by these stacks"]
x_net_bridge["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-network.svg' width='72' height='72' alt=''/><br/>bridge — 198.18.0.0/16</div>"]
x_net_none["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-none.svg' width='72' height='72' alt=''/><br/>none</div>"]
end
x_docker_gw["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-gateway.svg' width='72' height='72' alt=''/><br/>container-to-host gateway<br/>internal service seam</div>"]
x_hostproc["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-gear.svg' width='72' height='72' alt=''/><br/>host processes<br/>homelab-mcp :8830 · gateway :9995 · invest :8502<br/>claude-bridge :8787 · cloudops-chat bridge :8788<br/>Health Connect receiver 127.0.0.1:8788</div>"]
end
x_internet["<div style='width:260px;min-height:170px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/01-topology/topology-cloud.svg' width='72' height='72' alt=''/><br/>Internet</div>"]
workstation ==> overlay_host
mobile_device ==> overlay_host
overlay_host ==>|"authenticated application access"| n_homelab
overlay_host ==> n_wger
overlay_host ==> x_hostproc
overlay_host --> hostnet
x_internet -.->|"direct protocols"| host
x_internet --> hostnet
n_homelab -.->|"nginx auth_request<br/>and /api proxy"| x_docker_gw
x_docker_gw -.-> x_hostproc
gluetun ==>|"WireGuard"| x_internet
gluetun_books ==> x_internet
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class workstation,mobile_device,overlay_host host
class syncthing,hbbs,hbbr data
class x_homelab_members media
class watchtower media
class x_wger_members media
class gluetun vpn
class gluetun_books vpn
class x_net_bridge,x_net_none external
class x_docker_gw ingress
class x_hostproc ai
class x_internet external
class host host
class hostnet,n_homelab,n_default,n_wger ingress
%% Membership layout only. These invisible constraints are not traffic.
n_homelab ~~~ x_homelab_members
n_homelab ~~~ gluetun
n_homelab ~~~ gluetun_books
hostnet ~~~ syncthing
hostnet ~~~ hbbs
hostnet ~~~ hbbr
n_default ~~~ watchtower
n_wger ~~~ x_wger_members
%% Layout-only placement of dormant inventory after active host services.
x_wger_members ~~~ n_default
watchtower ~~~ x_net_bridge
Notes
The laptop and phone join the authenticated overlay as clients. I do not need to expose each dashboard to the public internet. Once a device is authenticated, it can reach the host and the application routes intended for that device.
Containers are split by workload. Most applications share the main application network so related services can call each other by container name. The fitness stack has its own network. Syncthing and the remote-desktop relay use the host network because their protocols need direct host connectivity.
qBittorrent deliberately shares Gluetun’s network namespace. It has no independent route to the internet. If Gluetun stops, download traffic stops with it. The books proxy is separate because only the book lookup workflow should use that ProtonVPN route.
Some small APIs run as host services instead of containers. The authenticated proxy forwards requests to them without making those APIs general public endpoints. This is how the CloudOps chat API, MCP gateway, investment dashboard, and health receiver fit beside Docker services.
I use the topology view when a dashboard opens but its backend does not respond. It shows whether to check device access, the host route, a Docker network, a shared network namespace, or a host-side service.
02-ingress · Foundations
Ingress
current access paths and the auth hop
Current access is through device-authenticated Authenticated overlay or a small set of deliberate direct listeners. Also documents the container-to-host seam: nginx in the cloudops container calls back to a host process on the Docker gateway IP for auth and chat.
%% 02-ingress — how a request reaches anything on this box, and what stops it.
%% Source of truth: src/02-ingress.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 28, "rankSpacing": 48, "diagramPadding": 34}}}%%
flowchart TB
x_browser["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-browser.svg' width='64' height='64' alt=''/><br/>Browser / client</div>"]
subgraph door1["Application ingress — Authenticated overlay"]
x_overlay_access["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-overlay-access.svg' width='64' height='64' alt=''/><br/>authenticated overlay overlay.example.test<br/>device-authenticated</div>"]
x_serve["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-serve.svg' width='64' height='64' alt=''/><br/>overlay serve :443<br/>operator.overlay.example.test<br/>/ homepage · /atlas/ atlas · /health Grafana<br/>/invest dashboard · /health-connect webhook<br/>authenticated overlay only · funnel off</div>"]
end
subgraph door3["Direct protocol access"]
x_sshd["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-sshd.svg' width='64' height='64' alt=''/><br/>sshd :22</div>"]
x_slskd_p2p["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-slskd-p2p.svg' width='64' height='64' alt=''/><br/>slskd :50300<br/>Soulseek P2P</div>"]
x_rustdesk_p["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-rustdesk-p.svg' width='64' height='64' alt=''/><br/>rustdesk 21115-21119</div>"]
end
subgraph bound["operator — authenticated overlay-published and loopback services"]
homepage["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-homepage.svg' width='64' height='64' alt=''/><br/>homepage :3000<br/>dashboard</div>"]
cloudops["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-cloudops.svg' width='64' height='64' alt=''/><br/>cloudops — nginx :8090<br/>auth_request gate</div>"]
x_others["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-others.svg' width='64' height='64' alt=''/><br/>other published services<br/>jellyfin 8096 · immich 2283 · wger 8765 …</div>"]
x_atlas["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-atlas.svg' width='64' height='64' alt=''/><br/>Architecture atlas<br/>static files from<br/>homelab-atlas/out/site</div>"]
x_mcp["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-x-mcp.svg' width='64' height='64' alt=''/><br/>homelab-mcp :8830<br/>bearer token · overlay0 only</div>"]
grafana["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-grafana.svg' width='64' height='64' alt=''/><br/>grafana :3030 loopback<br/>Wearable health dashboards</div>"]
end
subgraph hostside["Host-side backends — not containers"]
cloudops_chat["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-cloudops-chat.svg' width='64' height='64' alt=''/><br/>cloudops-chat<br/>internal API for the CloudOps dashboard</div>"]
claude_bridge["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-claude-bridge.svg' width='64' height='64' alt=''/><br/>claude-bridge<br/>127.0.0.1:8787 · loopback only</div>"]
health_connect["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-health-connect.svg' width='64' height='64' alt=''/><br/>Health Connect receiver<br/>127.0.0.1:8788 · token-authenticated</div>"]
invest_dashboard["<div style='width:220px;min-height:150px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/02-ingress/02-ingress-invest-dashboard.svg' width='64' height='64' alt=''/><br/>invest dashboard<br/>127.0.0.1:8502 · loopback only</div>"]
end
x_browser --> x_overlay_access
x_browser -.->|"LAN / WAN"| x_sshd
x_overlay_access ==> x_serve
x_serve ==>|"/ proxy"| homepage
x_serve ==>|"/atlas/ static"| x_atlas
x_serve ==>|"/health proxy"| grafana
x_serve ==>|"/health-connect proxy"| health_connect
x_serve ==>|"/invest proxy"| invest_dashboard
x_overlay_access ==> cloudops
x_overlay_access ==> x_others
x_overlay_access ==> x_mcp
cloudops -->|"auth_request /auth-check<br/>401 → /login.html"| cloudops_chat
cloudops -->|"/api/ proxy · 600s · buffering off"| cloudops_chat
cloudops_chat -.->|"study chat"| claude_bridge
%% The ingress fanout stays direct; portrait layout and all relationships are unchanged.
linkStyle 3,4,5,6,7,8,9,10,11 interpolate linear
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_browser host
class x_overlay_access,x_serve,cloudops ingress
class x_sshd,x_slskd_p2p,x_rustdesk_p warn
class homepage,x_others,grafana media
class x_atlas data
%% Layout-only ordering within lanes.
x_sshd ~~~ x_slskd_p2p ~~~ x_rustdesk_p
homepage ~~~ x_atlas
x_atlas ~~~ grafana
grafana ~~~ health_connect
health_connect ~~~ invest_dashboard
cloudops ~~~ cloudops_chat
Notes
From the laptop or phone, I first connect through the authenticated overlay. The HTTPS router then maps friendly paths to Homepage, the atlas, Grafana, the investment dashboard, and the Health Connect receiver. Funnel is off, so those routes are not public web entry points.
Homepage is my starting dashboard. It links to the applications I use regularly. The atlas explains how they fit together. Grafana shows wearable data, and the investment path opens the read-only Streamlit dashboard.
CloudOps has its own login check. Nginx asks the CloudOps Chat service whether the browser session is valid before serving the site or proxying an API request. The browser never calls the internal chat process directly.
The MCP endpoint is for configured AI clients rather than a browser dashboard. Clients authenticate, then call a limited set of workspace, job-search, training, and read-only operations tools.
SSH, remote-desktop relay traffic, and Soulseek are protocols rather than browser dashboards. They take the direct protocol path shown separately from the HTTPS application routes.
03-containers · Workloads
Container map
running containers grouped by purpose
Running containers grouped by function, with published ports. RustDesk and Syncthing appear under Platform because they use host networking.
%% 03-containers — every container on the box, grouped by what it is for.
%% Source of truth: src/03-containers.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 22, "rankSpacing": 36, "diagramPadding": 34}}}%%
flowchart TB
subgraph entry["Entry and dashboards"]
homepage["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-homepage.svg' width='52' height='52' alt=''/><br/>homepage<br/>:3000 · the front page</div>"]
cloudops["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-cloudops.svg' width='52' height='52' alt=''/><br/>cloudops — nginx<br/>:8090 · auth-gated study site</div>"]
uptime_kuma["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-uptime-kuma.svg' width='52' height='52' alt=''/><br/>uptime-kuma<br/>:8091 · monitoring</div>"]
homelab_ui["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-homelab-ui.svg' width='52' height='52' alt=''/><br/>homelab-ui — nginx<br/>:8400 · shared hl.css + fonts, also /ui over HTTPS</div>"]
grafana["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-grafana.svg' width='52' height='52' alt=''/><br/>grafana<br/>127.0.0.1:3030 · health dashboards</div>"]
end
subgraph video["Video"]
direction LR
jellyseerr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-jellyseerr.svg' width='52' height='52' alt=''/><br/>jellyseerr<br/>:5055 · request front-door</div>"]
radarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-radarr.svg' width='52' height='52' alt=''/><br/>radarr :7878 · movies</div>"]
sonarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-sonarr.svg' width='52' height='52' alt=''/><br/>sonarr :8989 · TV</div>"]
prowlarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-prowlarr.svg' width='52' height='52' alt=''/><br/>prowlarr :9696 · indexers</div>"]
flaresolverr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-flaresolverr.svg' width='52' height='52' alt=''/><br/>flaresolverr<br/>internal :8191 · CF challenge solver</div>"]
bazarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-bazarr.svg' width='52' height='52' alt=''/><br/>bazarr :6767 · subtitles</div>"]
tdarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-tdarr.svg' width='52' height='52' alt=''/><br/>tdarr :8265-8266<br/>transcode · server-only node</div>"]
jellyfin["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-jellyfin.svg' width='52' height='52' alt=''/><br/>jellyfin :8096 · playback</div>"]
end
subgraph dl["Downloads"]
direction LR
gluetun["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-gluetun.svg' width='52' height='52' alt=''/><br/>gluetun :8085<br/>Cloudflare WARP · kill-switch</div>"]
qbittorrent["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-qbittorrent.svg' width='52' height='52' alt=''/><br/>qbittorrent<br/>no netns of its own</div>"]
unpackerr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-unpackerr.svg' width='52' height='52' alt=''/><br/>unpackerr · extracts</div>"]
decluttarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-decluttarr.svg' width='52' height='52' alt=''/><br/>decluttarr · cleans stalled</div>"]
end
subgraph music["Music"]
direction LR
lidarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-lidarr.svg' width='52' height='52' alt=''/><br/>lidarr :8686</div>"]
slskd["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-slskd.svg' width='52' height='52' alt=''/><br/>slskd :5030 · Soulseek</div>"]
soularr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-soularr.svg' width='52' height='52' alt=''/><br/>soularr · lidarr↔slskd bridge</div>"]
sonobarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-sonobarr.svg' width='52' height='52' alt=''/><br/>sonobarr :5000 · Last.fm discovery</div>"]
navidrome["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-navidrome.svg' width='52' height='52' alt=''/><br/>navidrome :4533 · playback</div>"]
musicportal["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-musicportal.svg' width='52' height='52' alt=''/><br/>musicportal :8100<br/>custom · JioSaavn</div>"]
end
subgraph del["Deletion"]
librarian["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-librarian.svg' width='52' height='52' alt=''/><br/>librarian :8300<br/>custom · the only delete path<br/>basic auth + Authenticated overlay</div>"]
end
subgraph books["Books"]
bookportal["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-bookportal.svg' width='52' height='52' alt=''/><br/>bookportal :8200<br/>custom · Libgen/Anna's</div>"]
gluetun_books["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-gluetun-books.svg' width='52' height='52' alt=''/><br/>gluetun-books<br/>Proton HTTP proxy :8888</div>"]
kavita["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-kavita.svg' width='52' height='52' alt=''/><br/>kavita :5001 · reader</div>"]
end
subgraph photos["Photos"]
immich_server["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-immich-server.svg' width='52' height='52' alt=''/><br/>immich-server :2283</div>"]
immich_machine_learning["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-immich-machine-learning.svg' width='52' height='52' alt=''/><br/>immich-machine-learning</div>"]
immich_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-immich-db.svg' width='52' height='52' alt=''/><br/>immich-db · pgvector</div>"]
immich_redis["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-immich-redis.svg' width='52' height='52' alt=''/><br/>immich-redis</div>"]
end
subgraph rss["Reading"]
miniflux["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-miniflux.svg' width='52' height='52' alt=''/><br/>miniflux :8080</div>"]
miniflux_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-miniflux-db.svg' width='52' height='52' alt=''/><br/>miniflux-db · postgres 16</div>"]
end
subgraph fitness["Fitness"]
direction LR
wger_nginx["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-nginx.svg' width='52' height='52' alt=''/><br/>wger-nginx :8765</div>"]
wger_web["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-web.svg' width='52' height='52' alt=''/><br/>wger-web</div>"]
wger_worker["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-worker.svg' width='52' height='52' alt=''/><br/>wger-celery-worker</div>"]
wger_beat["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-beat.svg' width='52' height='52' alt=''/><br/>wger-celery-beat</div>"]
wger_powersync["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-powersync.svg' width='52' height='52' alt=''/><br/>wger-powersync</div>"]
wger_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-db.svg' width='52' height='52' alt=''/><br/>wger-db</div>"]
wger_cache["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-wger-cache.svg' width='52' height='52' alt=''/><br/>wger-cache</div>"]
end
subgraph syncplat["Sync platform"]
syncthing["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-syncthing.svg' width='52' height='52' alt=''/><br/>syncthing · host net<br/>:8384 UI · :22000 sync</div>"]
end
subgraph opsplat["Container operations"]
watchtower["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-watchtower.svg' width='52' height='52' alt=''/><br/>watchtower<br/>alone on homelab_default</div>"]
end
subgraph remoteplat["Remote support"]
hbbs["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-hbbs.svg' width='52' height='52' alt=''/><br/>hbbs · rustdesk</div>"]
hbbr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/03-containers/03-containers-hbbr.svg' width='52' height='52' alt=''/><br/>hbbr · rustdesk</div>"]
end
jellyseerr --> radarr
jellyseerr --> sonarr
radarr --> prowlarr
sonarr --> prowlarr
prowlarr -.-> flaresolverr
radarr ==> qbittorrent
sonarr ==> qbittorrent
qbittorrent -.->|"netns"| gluetun
qbittorrent ==> unpackerr
decluttarr -.->|"prunes"| qbittorrent
unpackerr ==> jellyfin
bazarr --> jellyfin
tdarr --> jellyfin
sonobarr --> lidarr
lidarr ==> soularr
soularr <--> slskd
slskd ==> navidrome
musicportal ==> navidrome
librarian ---->|"deletes + unmonitors video"| video
librarian ---->|"deletes + retires artists"| lidarr
librarian -...->|"clears transfers"| slskd
librarian -...->|"deletes files"| kavita
bookportal -.->|"HTTP proxy"| gluetun_books
bookportal ==> kavita
immich_server --> immich_db
immich_server --> immich_redis
immich_server --> immich_machine_learning
miniflux --> miniflux_db
wger_nginx --> wger_web
wger_web --> wger_db
wger_web --> wger_cache
wger_worker --> wger_db
wger_worker --> wger_cache
wger_beat --> wger_worker
wger_powersync --> wger_web
syncthing -...->|"serves synced music"| navidrome
syncthing -...->|"serves synced media"| jellyfin
syncthing -...->|"serves synced site content"| cloudops
syncthing -...->|"serves synced workspace content"| homepage
watchtower -...->|"weekly image pulls"| homepage
uptime_kuma -.->|"probes"| homepage
%% Non-traffic layout constraints between existing cards only.
librarian ~~~ lidarr
librarian ~~~ kavita
miniflux ~~~ immich_server
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class homepage,cloudops,uptime_kuma,homelab_ui,grafana ingress
class jellyseerr,radarr,sonarr,prowlarr,flaresolverr,bazarr,tdarr,jellyfin media
class qbittorrent,unpackerr,decluttarr media
class gluetun vpn
class lidarr,slskd,soularr,sonobarr,navidrome,musicportal media
class librarian media
class bookportal,kavita media
class gluetun_books vpn
class immich_server,immich_machine_learning media
class immich_db,immich_redis,miniflux_db data
class miniflux media
class wger_nginx,wger_web,wger_worker,wger_beat,wger_powersync media
class wger_db,wger_cache data
class syncthing data
class watchtower,hbbs,hbbr host
%% Layout-only support column. These links do not represent traffic.
homepage ~~~ immich_server
immich_server ~~~ miniflux
miniflux ~~~ wger_nginx
wger_nginx ~~~ syncthing
Notes
I use Homepage as the front page and Uptime Kuma to see whether important endpoints respond. CloudOps is a study and operations interface. Grafana is the dashboard for wearable data. Each front end connects to a smaller set of backend services shown by the arrows.
The video workflow starts in Jellyseerr. Movie requests go to Radarr and series requests go to Sonarr. Both use Prowlarr for search and send downloads to qBittorrent through Gluetun. Unpackerr extracts completed downloads. Jellyfin plays the resulting library, while Bazarr manages subtitles and Tdarr handles planned transcode work.
The music workflow has two inputs. Lidarr, Soularr, and slskd cooperate on wanted music. Musicportal is a custom service for JioSaavn searches and downloads. Navidrome reads the shared music library for playback. Librarian is my controlled cleanup tool: it previews a deletion plan, requires typed confirmation, removes files, and updates the relevant library manager.
Bookportal is a custom FastAPI search and download interface. It writes normalized files into the book library, and Kavita is the reader. Immich handles phone photo backup. Its server uses PostgreSQL for metadata, Redis for jobs, and a machine-learning worker for search and face features.
Miniflux is the RSS reader and stores subscriptions in PostgreSQL. Wger is the fitness application. Its web process, worker, scheduler, sync process, database, cache, and Nginx entry point form one application even though they run as separate containers.
Watchtower checks container images on its weekly schedule. Syncthing handles device replication. The RustDesk relay supports remote access. These platform containers are separate from the application request chains.
04-media · Workloads
Media pipeline
request to playback
The full path a movie or episode takes: jellyseerr request, radarr/sonarr management, prowlarr search, qbittorrent fetch through the WARP tunnel, unpackerr extraction, import to ./data/media, then jellyfin playback with bazarr subtitles and tdarr transcode.
%% 04-media — how a movie or episode actually arrives and reaches playback.
%% Source of truth: src/04-media.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 34, "rankSpacing": 46, "diagramPadding": 20}}}%%
flowchart LR
x_user["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-x-user.svg' width='52' height='52' alt=''/><br/>Request</div>"]
jellyseerr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-jellyseerr.svg' width='52' height='52' alt=''/><br/>jellyseerr :5055<br/>request front-door</div>"]
subgraph manage["Management"]
radarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-radarr.svg' width='52' height='52' alt=''/><br/>radarr :7878<br/>movies</div>"]
sonarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-sonarr.svg' width='52' height='52' alt=''/><br/>sonarr :8989<br/>TV</div>"]
end
subgraph find["Finding it"]
prowlarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-prowlarr.svg' width='52' height='52' alt=''/><br/>prowlarr :9696<br/>indexer aggregator</div>"]
flaresolverr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-flaresolverr.svg' width='52' height='52' alt=''/><br/>flaresolverr :8191<br/>Cloudflare challenges</div>"]
x_indexers["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-x-indexers.svg' width='52' height='52' alt=''/><br/>Knaben · YTS<br/>ISP blocks force these</div>"]
end
subgraph fetch["Fetching it — all traffic leaves via VPN"]
gluetun["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-gluetun.svg' width='52' height='52' alt=''/><br/>gluetun :8085<br/>Cloudflare WARP · kill-switch</div>"]
end
subgraph pipeline["Download and extraction"]
qbittorrent["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-qbittorrent.svg' width='52' height='52' alt=''/><br/>qbittorrent<br/>network_mode: service:gluetun</div>"]
v_downloads["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-v-downloads.svg' width='52' height='52' alt=''/><br/>downloads</div>"]
unpackerr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-unpackerr.svg' width='52' height='52' alt=''/><br/>unpackerr<br/>extracts archives</div>"]
decluttarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-decluttarr.svg' width='52' height='52' alt=''/><br/>decluttarr<br/>prunes stalled/orphaned</div>"]
end
subgraph library["Libraries and serving"]
v_syncmedia["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-v-syncmedia.svg' width='52' height='52' alt=''/><br/>data/syncthing/Media<br/>second library, synced from laptop</div>"]
jellyfin["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-jellyfin.svg' width='52' height='52' alt=''/><br/>jellyfin :8096</div>"]
v_movies["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-v-movies.svg' width='52' height='52' alt=''/><br/>movies</div>"]
bazarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-bazarr.svg' width='52' height='52' alt=''/><br/>bazarr :6767<br/>subtitles</div>"]
v_tv["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-v-tv.svg' width='52' height='52' alt=''/><br/>tv</div>"]
tdarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/04-media/04-media-tdarr.svg' width='52' height='52' alt=''/><br/>tdarr :8265<br/>transcode · server-only</div>"]
end
x_user ==> jellyseerr
jellyseerr ==> radarr
jellyseerr ==> sonarr
radarr ==> prowlarr
sonarr ==> prowlarr
prowlarr -.->|"solves challenges via"| flaresolverr
prowlarr ==> x_indexers
radarr ==>|"send .torrent"| qbittorrent
sonarr ==> qbittorrent
qbittorrent ==>|"shares netns —<br/>no VPN, no traffic"| gluetun
gluetun ==>|"WireGuard 162.159.192.1:2408"| x_indexers
qbittorrent ==> v_downloads
v_downloads ==> unpackerr
decluttarr -.->|"removes stalled"| qbittorrent
unpackerr ==>|"radarr/sonarr import"| v_movies
unpackerr ==> v_tv
v_movies ==> jellyfin
v_tv ==> jellyfin
v_syncmedia ==> jellyfin
v_movies --> bazarr
v_tv --> bazarr
bazarr -.->|"subtitle files"| v_movies
v_movies --> tdarr
v_tv --> tdarr
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_user host
class jellyseerr,radarr,sonarr,prowlarr,flaresolverr media
class qbittorrent,unpackerr,decluttarr,bazarr,tdarr,jellyfin media
class gluetun vpn
class v_downloads,v_movies,v_tv,v_syncmedia data
class x_indexers external
Notes
I open Jellyseerr in a browser when I want to request a film or series. Jellyseerr sends movie requests to Radarr and television requests to Sonarr, so I do not have to operate each manager for a normal request.
Radarr and Sonarr ask Prowlarr to search the configured indexers. Prowlarr can use FlareSolverr when a source requires a browser-style challenge. A selected result is passed to qBittorrent.
qBittorrent shares Gluetun’s network and therefore uses the WARP egress path. Completed files land in the download area. Unpackerr extracts archives, then Radarr or Sonarr imports and names the result in the movie or television library.
Jellyfin reads the finished libraries and is the dashboard and app I use for playback. Bazarr adds subtitles. Tdarr is available for server-side media processing. A second Jellyfin library comes from files synchronized from the laptop.
Decluttarr cleans stalled download-manager entries. It is housekeeping, not part of a successful request. The normal result of the workflow is a named file in the correct library that appears in Jellyfin.
05-music · Workloads
Music
two acquisition paths into one synced library
Path A is Lidarr driving Soulseek through soularr; path B is the custom musicportal pulling from JioSaavn. Both write into ./data/syncthing/music, which Navidrome mounts read-only and Syncthing replicates to the laptop and mobile device.
%% 05-music — two independent paths into one library.
%% Source of truth: src/05-music.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 38, "diagramPadding": 34}}}%%
flowchart LR
x_user["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-x-user.svg' width='52' height='52' alt=''/><br/>Request</div>"]
subgraph disco["Discovery"]
sonobarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-sonobarr.svg' width='52' height='52' alt=''/><br/>sonobarr :5000<br/>Last.fm recommendations<br/>needs LAST_FM key</div>"]
x_lastfm["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-x-lastfm.svg' width='52' height='52' alt=''/><br/>Last.fm</div>"]
end
subgraph pathA["Path A — Lidarr + Soulseek"]
lidarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-lidarr.svg' width='52' height='52' alt=''/><br/>lidarr :8686<br/>library manager</div>"]
soularr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-soularr.svg' width='52' height='52' alt=''/><br/>soularr<br/>polls lidarr wanted list</div>"]
slskd["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-slskd.svg' width='52' height='52' alt=''/><br/>slskd :5030<br/>Soulseek client</div>"]
v_slskd_dl["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-v-slskd-dl.svg' width='52' height='52' alt=''/><br/>data/media/downloads/slskd</div>"]
x_soulseek["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-x-soulseek.svg' width='52' height='52' alt=''/><br/>Soulseek P2P<br/>music discovery network</div>"]
end
subgraph pathB["Path B — direct"]
musicportal["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-musicportal.svg' width='52' height='52' alt=''/><br/>musicportal :8100<br/>custom · JioSaavn</div>"]
x_saavn["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-x-saavn.svg' width='52' height='52' alt=''/><br/>JioSaavn</div>"]
end
subgraph lib["data/syncthing/music — the one library"]
v_music["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-v-music.svg' width='52' height='52' alt=''/><br/>music/<br/>synced to laptop and mobile device</div>"]
end
navidrome["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-navidrome.svg' width='52' height='52' alt=''/><br/>navidrome :4533<br/>mounts music read-only</div>"]
librarian["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/05-music/05-music-librarian.svg' width='52' height='52' alt=''/><br/>librarian :8300<br/>the only delete path<br/>plan → typed confirm → execute</div>"]
x_user --> sonobarr
x_user --> musicportal
sonobarr --> x_lastfm
sonobarr ==>|"adds artists"| lidarr
lidarr ==>|"wanted list"| soularr
soularr <==>|"search and queue"| slskd
slskd <==> x_soulseek
slskd ==> v_slskd_dl
v_slskd_dl ==>|"soularr imports"| lidarr
lidarr ==>|"writes tagged files"| v_music
musicportal ==> x_saavn
musicportal ==>|"writes directly"| v_music
v_music ==>|"read-only mount"| navidrome
slskd -.->|"shares library read-only"| v_music
x_user --> librarian
librarian ==>|"deletes files"| v_music
librarian ==>|"unmonitor albums,<br/>retire artists"| lidarr
librarian -.->|"purges leftovers"| v_slskd_dl
%% Non-traffic lane ordering between existing cards.
v_slskd_dl ~~~ x_soulseek
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_user host
class sonobarr,lidarr,soularr,slskd,musicportal,navidrome,librarian media
class v_slskd_dl,v_music data
class x_lastfm,x_soulseek,x_saavn external
Notes
I use Sonobarr when I want recommendations based on Last.fm. It adds selected artists to Lidarr. Lidarr records what is wanted, Soularr polls that list, and slskd searches and queues the matching Soulseek downloads.
When a Soulseek download finishes, Soularr hands it back to Lidarr. Lidarr imports, names, and tags the files in the shared music folder. Syncthing then makes that library available on the laptop and phone.
Musicportal is the second path. It is a custom web service I use to search JioSaavn and write selected tracks directly into the same music folder. It exists because that workflow does not fit the Lidarr and Soulseek path.
Navidrome mounts the library read-only and gives me the playback dashboard. It cannot alter the source files. Librarian is the deliberate deletion path. I review its plan, type the confirmation, and let it remove files and retire the matching Lidarr entries.
Both acquisition paths end in one library. That keeps playback simple, but it also means a file change can synchronize to every peer. Cleanup goes through Librarian rather than an unrestricted file browser.
06-books-photos · Workloads
Books and photos
two small self-contained stacks
Books: the custom bookportal fetches from Libgen directly and from Anna's Archive through a dedicated ProtonVPN HTTP proxy, normalises filenames into ./data/books, and Kavita serves them. Photos: immich-server with its Postgres+pgvector database, Redis job queue, and a separate machine-learning container for CLIP search and face detection.
%% 06-books-photos — two small self-contained stacks.
%% Source of truth: src/06-books-photos.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 38, "diagramPadding": 34}}}%%
flowchart TB
subgraph photosflow["Photos"]
direction LR
x_user_p["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-x-user-p.svg' width='52' height='52' alt=''/><br/>Immich app<br/>mobile device auto-backup</div>"]
immich_server["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-immich-server.svg' width='52' height='52' alt=''/><br/>immich-server :2283</div>"]
immich_machine_learning["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-immich-machine-learning.svg' width='52' height='52' alt=''/><br/>immich-machine-learning<br/>CLIP search, face detection</div>"]
immich_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-immich-db.svg' width='52' height='52' alt=''/><br/>immich-db<br/>postgres 14 + pgvector/vectorchord</div>"]
immich_redis["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-immich-redis.svg' width='52' height='52' alt=''/><br/>immich-redis<br/>job queue</div>"]
v_upload["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-v-upload.svg' width='52' height='52' alt=''/><br/>data/immich/upload</div>"]
v_model["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-v-model.svg' width='52' height='52' alt=''/><br/>data/immich/model-cache</div>"]
v_immich_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-v-immich-db.svg' width='52' height='52' alt=''/><br/>data/immich/db</div>"]
end
subgraph booksflow["Books"]
direction LR
x_user_b["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-x-user-b.svg' width='52' height='52' alt=''/><br/>Search</div>"]
bookportal["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-bookportal.svg' width='52' height='52' alt=''/><br/>bookportal :8200<br/>custom FastAPI · python:3.12-slim<br/>dns 1.1.1.1</div>"]
gluetun_books["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-gluetun-books.svg' width='52' height='52' alt=''/><br/>gluetun-books<br/>ProtonVPN · HTTP proxy :8888</div>"]
x_libgen["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-x-libgen.svg' width='52' height='52' alt=''/><br/>Libgen</div>"]
x_annas["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-x-annas.svg' width='52' height='52' alt=''/><br/>Anna's Archive<br/>reached only via the proxy</div>"]
v_books["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-v-books.svg' width='52' height='52' alt=''/><br/>data/books</div>"]
kavita["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/06-books-photos/06-books-photos-kavita.svg' width='52' height='52' alt=''/><br/>kavita :5001→5000<br/>reader</div>"]
end
x_user_b ==> bookportal
bookportal ==>|"direct"| x_libgen
bookportal -.->|"HTTP_PROXY"| gluetun_books
gluetun_books ==> x_annas
bookportal ==>|"normalises filenames"| v_books
v_books ==> kavita
x_user_p ==> immich_server
immich_server ==> v_upload
immich_server --> immich_db
immich_db --> v_immich_db
immich_server -->|"queues jobs"| immich_redis
immich_server -->|"embeddings, faces"| immich_machine_learning
immich_machine_learning --> v_model
immich_machine_learning -.->|"vectors"| immich_db
%% Non-traffic ordering keeps Books below Photos on narrow and wide views.
v_model ~~~ x_user_b
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_user_b,x_user_p host
class bookportal,kavita,immich_server media
class gluetun_books vpn
class immich_machine_learning ai
class immich_db,immich_redis data
class v_books,v_upload,v_model,v_immich_db data
class x_libgen,x_annas external
Notes
For books, I open Bookportal in a browser and search from one page. Bookportal is a custom FastAPI service. It queries the configured sources, sends the source that needs a proxy through the dedicated ProtonVPN container, normalizes the downloaded filename, and saves the result in the book folder.
Kavita reads that folder and is the browser-based reader and library dashboard. Bookportal acquires and organizes files. Kavita catalogs and presents them. Keeping those jobs separate makes each service easier to replace or troubleshoot.
For photos, the Immich app on the phone uploads to Immich Server. The server stores original files in the upload area and metadata in PostgreSQL. Redis holds background jobs, and the machine-learning container produces search embeddings and face-detection results.
I use the Immich phone app and web dashboard to browse, search, and back up photos. A usable restore needs both the uploaded files and the database because the files alone do not contain albums, jobs, and application metadata.
The books and photos stacks share the host but not a workflow. They appear together because each is a small, self-contained service group with a clear user-facing app and its own durable storage.
07-storage · Workloads
Storage
Syncthing as the hub, and who reads what
Shared folders, application state, and read-only consumer relationships across the homelab.
%% 07-storage — Syncthing is the replication hub for shared content.
%% Source of truth: src/07-storage.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 24, "rankSpacing": 34, "diagramPadding": 34}}}%%
flowchart TB
subgraph peers["Syncthing peers"]
workstation["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-workstation.svg' width='52' height='52' alt=''/><br/>workstation</div>"]
mobile_device["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-phone.svg' width='52' height='52' alt=''/><br/>mobile device</div>"]
end
syncthing["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-syncthing.svg' width='52' height='52' alt=''/><br/>syncthing — host network<br/>:8384 UI · :22000 sync · :21027 discovery</div>"]
subgraph synced["./data/syncthing — Syncthing-backed, multi-device"]
v_shared-workspace["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-shared-workspace.svg' width='52' height='52' alt=''/><br/>shared-workspace/<br/>Obsidian workspace · append-only for agents</div>"]
v_resume["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-resume.svg' width='52' height='52' alt=''/><br/>resume/<br/>workstation + homelab writers · mobile device receive-only</div>"]
v_music["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-music.svg' width='52' height='52' alt=''/><br/>music/</div>"]
v_media["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-media.svg' width='52' height='52' alt=''/><br/>Media/</div>"]
v_cloudops["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-cloudops.svg' width='52' height='52' alt=''/><br/>cloudops/<br/>static study site</div>"]
v_gadgetbridge_raw["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-gadgetbridge-raw.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health/<br/>mobile device send-only · server receive-only</div>"]
end
subgraph local["./data — local only, not synced"]
v_mediaroot["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-mediaroot.svg' width='52' height='52' alt=''/><br/>media/<br/>movies · tv · downloads</div>"]
v_books["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-books.svg' width='52' height='52' alt=''/><br/>books/</div>"]
v_immich["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-immich.svg' width='52' height='52' alt=''/><br/>immich/<br/>upload · db · model-cache</div>"]
v_configs["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-configs.svg' width='52' height='52' alt=''/><br/>per-service config dirs<br/>radarr, sonarr, jellyfin, …</div>"]
v_health["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-v-health.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health/<br/>validated Gadgetbridge + Health Connect<br/>atomic Grafana snapshots · backups</div>"]
end
subgraph music_consumers["Music"]
navidrome["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-navidrome.svg' width='52' height='52' alt=''/><br/>navidrome · music :ro</div>"]
lidarr["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-lidarr.svg' width='52' height='52' alt=''/><br/>lidarr · music rw</div>"]
musicportal["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-musicportal.svg' width='52' height='52' alt=''/><br/>musicportal · music rw</div>"]
slskd["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-slskd.svg' width='52' height='52' alt=''/><br/>slskd · music :ro</div>"]
end
subgraph video_consumers["Video"]
jellyfin["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-jellyfin.svg' width='52' height='52' alt=''/><br/>jellyfin · Media + movies + tv</div>"]
end
subgraph library_consumers["Books and photos"]
kavita["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-kavita.svg' width='52' height='52' alt=''/><br/>kavita · books</div>"]
immich_server["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-immich-server.svg' width='52' height='52' alt=''/><br/>immich-server · upload</div>"]
end
subgraph agent_consumers["Agents and study"]
cloudops["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-cloudops.svg' width='52' height='52' alt=''/><br/>cloudops · cloudops :ro</div>"]
x_agents["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-x-agents.svg' width='52' height='52' alt=''/><br/>agent plane<br/>homelab-mcp · jobhunt · Hermes</div>"]
end
subgraph health_consumers["Health"]
health_promoter["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-health-promoter.svg' width='52' height='52' alt=''/><br/>Gadgetbridge validator/promoter</div>"]
health_receiver["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-health-receiver.svg' width='52' height='52' alt=''/><br/>Health Connect receiver</div>"]
grafana["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/07-storage/07-storage-grafana.svg' width='52' height='52' alt=''/><br/>Grafana · snapshots :ro</div>"]
end
workstation <==>|"replicates"| syncthing
mobile_device <==>|"replicates"| syncthing
syncthing ==>|"syncs"| v_shared-workspace
syncthing ==>|"syncs"| v_music
syncthing ==>|"syncs"| v_media
syncthing ==>|"syncs"| v_cloudops
syncthing ==>|"syncs"| v_resume
syncthing ==>|"receives"| v_gadgetbridge_raw
v_music ==>|":ro"| navidrome
v_music ==>|"rw"| lidarr
v_music ==>|"rw"| musicportal
v_music -.->|":ro"| slskd
v_mediaroot --> jellyfin
v_media ==>|"reads"| jellyfin
v_cloudops ==>|":ro"| cloudops
v_shared-workspace <==>|"guarded writes only"| x_agents
v_gadgetbridge_raw ==> health_promoter
health_promoter ==> v_health
health_receiver ==> v_health
v_health ==> grafana
v_books ==> kavita
v_immich ==> immich_server
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class workstation,mobile_device host
class syncthing data
class v_shared-workspace,v_music,v_media,v_cloudops,v_resume,v_gadgetbridge_raw data
class v_mediaroot,v_books,v_immich,v_configs,v_health data
class navidrome,jellyfin,lidarr,musicportal,slskd,kavita,immich_server media
class cloudops ingress
class health_promoter,health_receiver,grafana media
class x_agents ai
Notes
Syncthing is the bridge between the laptop, phone, and server. I use its web dashboard to check peers and folder health. The Obsidian workspace and selected libraries synchronize between devices, while wearable exports use a one-way phone-to-server pattern.
The shared music folder has several roles. Lidarr and Musicportal write it. Navidrome reads it for playback. Syncthing copies it to my devices. The arrows show permissions because write access matters more than the implementation path here.
The video libraries are different. Jellyfin reads both the server-managed movie and television folders and a separate folder synchronized from the laptop. The book folder feeds Kavita, while Immich uses its own upload, database, and model-cache storage.
The workspace is where Obsidian notes and agent-readable project context live. MCP tools expose controlled search, reading, append, and create operations. Agents do not receive unrestricted filesystem access.
The health importers validate phone exports and publish stable snapshots. Grafana mounts those snapshots read-only. This keeps a dashboard query from changing the source health records.
I use this page to answer two practical questions before changing anything: which service writes this folder, and which devices or applications will see the result.
08-ai-plane · Workloads
Agent workflow
Pi, Hermes, providers, and homelab-mcp
Pi calls its configured OpenAI Codex provider directly, while the Hermes gateway and Jobhunt use Hermes-selected provider routes. CloudOps Chat invokes its selected provider or local CLI itself and uses Hermes for catalog data. homelab-mcp remains authenticated overlay-only. Herdr is a local workflow CLI, while agent-workbench is public reference material, not a deployed service.
I normally start AI coding work from Pi on the laptop. Pi reads the repository AGENTS.md, project documents, and installed skills before it edits code. For homelab projects the project-dev skill sets the change class, required checks, review gate, and the rule that an agent cannot restart a service without permission.
The unslop skill is the final writing pass. It removes stock AI wording and rewrites explanations in plain language without changing technical meaning. Other task-specific instructions can come from the repository or the synchronized Obsidian workspace.
Pi calls its configured Codex provider directly. Hermes is a separate router used by the Telegram gateway and Jobhunt. CloudOps Chat can call a hosted provider, Claude CLI, or Codex CLI. The credential stores authorize those calls, but credentials never enter the instruction files or diagrams.
Herdr coordinates work when several agents need bounded tasks. Pi reports session state to Herdr, and the coordinator assigns non-overlapping work. I still review the combined diff and control commits, deployment, and merge.
homelab-mcp is the controlled bridge from AI clients to homelab information. Its workspace tools search and read notes, append approved content, and create notes only in allowed folders. Jobhunt and Dojo tools return bounded database views. Operations tools expose selected health and log reads.
Every MCP tool call is logged with the caller, tool, and outcome. Writes are serialized. A guard refusal is a result to report, not something the agent should work around.
agent-workbench is the public explanation of this workflow. It documents the loop I use: understand the task, decide the boundary, write the contract, implement one reviewable slice, run real checks, review the diff, and record durable decisions.
09-jobhunt · Operations
jobhunt
discovery, bounded scoring, and user actions
A persistent timer runs discovery, normalization, deduplication, deterministic filtering, bounded model scoring through Hermes, notifications, and board generation. SQLite remains the canonical job store; the workspace contains generated user-facing outputs.
%% 09-jobhunt — nightly discovery, bounded model scoring, and user-facing outputs.
%% Source of truth: src/09-jobhunt.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 18, "rankSpacing": 26, "diagramPadding": 34}}}%%
flowchart LR
subgraph sources["Current sources"]
x_ats["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-ats.svg' width='52' height='52' alt=''/><br/>ATS boards<br/>Greenhouse · Lever · Ashby</div>"]
x_apis["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-apis.svg' width='52' height='52' alt=''/><br/>Job APIs<br/>Remotive · Jobicy · Arbeitnow<br/>Remote OK · Adzuna</div>"]
x_local["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-local.svg' width='52' height='52' alt=''/><br/>Regional sources<br/>Regional board A · Regional board B</div>"]
x_serpapi["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-serpapi.svg' width='52' height='52' alt=''/><br/>SerpAPI pilot<br/>enabled · recent failures/timeouts</div>"]
x_disabled["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-disabled.svg' width='52' height='52' alt=''/><br/>configured inactive<br/>Jooble · JSearch</div>"]
end
jobhunt_timer["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-jobhunt-timer.svg' width='52' height='52' alt=''/><br/>jobhunt-discover.timer<br/>daily window · persistent</div>"]
jobhunt_discover["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-jobhunt-discover.svg' width='52' height='52' alt=''/><br/>jobhunt-discover.service<br/>jobhunt.nightly</div>"]
resume["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-resume.svg' width='52' height='52' alt=''/><br/>canonical resume<br/>Syncthing resume folder</div>"]
subgraph pipeline["Discovery and scoring"]
leads["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-leads.svg' width='52' height='52' alt=''/><br/>fetch · normalize<br/>insert · deduplicate</div>"]
prefilter["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-prefilter.svg' width='52' height='52' alt=''/><br/>deterministic filter<br/>PASS · REVIEW · HARD_REJECT</div>"]
score["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-score.svg' width='52' height='52' alt=''/><br/>bounded model scoring<br/>fast tier · failed calls remain retryable</div>"]
db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-db.svg' width='52' height='52' alt=''/><br/>jobhunt.db<br/>canonical jobs and status</div>"]
funnel["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-funnel.svg' width='52' height='52' alt=''/><br/>funnel.jsonl<br/>aggregate run diagnostics</div>"]
end
hermes["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-hermes.svg' width='52' height='52' alt=''/><br/>Hermes CLI<br/>selected provider + model</div>"]
settings["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-settings.svg' width='52' height='52' alt=''/><br/>settings.json<br/>main and scoring selections</div>"]
claude_fallback["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-claude-fallback.svg' width='52' height='52' alt=''/><br/>direct claude -p<br/>Claude-family emergency fallback</div>"]
providers["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-providers.svg' width='52' height='52' alt=''/><br/>configured model providers</div>"]
subgraph outputs["Outputs and actions"]
digest["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-digest.svg' width='52' height='52' alt=''/><br/>Telegram digest and cards</div>"]
jobhunt_bot["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-jobhunt-bot.svg' width='52' height='52' alt=''/><br/>jobhunt-bot.service<br/>show · tailor · prep · model</div>"]
board["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-board.svg' width='52' height='52' alt=''/><br/>workspace kanban<br/>Tracker/jobhunt-board.md</div>"]
prep["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-prep.svg' width='52' height='52' alt=''/><br/>tailored material<br/>Tracker/jobhunt-prep/</div>"]
end
x_telegram["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/09-jobhunt/09-jobhunt-x-telegram.svg' width='52' height='52' alt=''/><br/>Telegram</div>"]
jobhunt_timer ==> jobhunt_discover
jobhunt_discover ==> leads
x_ats ==> leads
x_apis ==> leads
x_local ==> leads
x_serpapi -.->|"degraded source state"| leads
x_disabled -.->|"off"| leads
leads ==> db
leads ==> prefilter
prefilter ==>|"PASS and REVIEW only"| score
score ==> db
jobhunt_discover ==> funnel
resume ==> score
settings ==> hermes
score <==>|"hermes -z · fast model"| hermes
hermes ==> providers
hermes -.->|"Claude-family failure only"| claude_fallback
db ==> digest
db ==> board
digest ==> x_telegram
x_telegram <==> jobhunt_bot
jobhunt_bot ==>|"tailored resume and prep"| prep
jobhunt_bot <==>|"main model"| hermes
jobhunt_bot --> db
%% Keep prep beyond Hermes so the bot's two outgoing lanes do not intersect.
hermes ~~~ prep
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_ats,x_apis,x_local,providers,x_telegram external
class x_serpapi,x_disabled,claude_fallback warn
class jobhunt_timer,jobhunt_discover,jobhunt_bot ai
class leads,prefilter,digest media
class score,hermes ai
class db,funnel,resume,settings,board,prep data
Notes
A persistent system timer starts discovery each day, even if the machine was asleep at the nominal time. The discovery service reads the configured job boards and APIs, converts their different responses into one shape, deduplicates repeated listings, and stores jobs in SQLite.
Cheap deterministic rules run before any model call. Clear mismatches are recorded and stop there. Plausible and uncertain roles continue to bounded model scoring. This keeps cost and runtime controlled and leaves failed model calls available for a later retry.
Hermes selects the configured provider and model for scoring and bot conversations. The canonical resume gives the scorer the experience it should compare against. Provider output adds a score and explanation, but it does not submit an application.
I use Telegram for the digest and quick actions. The bot can show a job, prepare tailored material, and change its model. The workspace kanban is the longer review view. SQLite remains the source of truth for status.
The human gate is deliberate. The workflow finds, sorts, and prepares work. I decide what to shortlist, what material to use, and whether to apply. No recruiter message or application is sent automatically.
10-cka-dojo · Operations
cka-dojo
the only off-box footprint
A CKA break/fix trainer. Terraform builds a kubeadm cluster in example-region-1 on demand, 17 scenarios inject faults over SSM, and dojo down parks the cluster as AMIs so nothing bills while idle. Access is SSM-only — no SSH, no public ingress.
%% 10-cka-dojo — the only off-box footprint, parked when not studying.
%% Source of truth: src/10-cka-dojo.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "linear", "nodeSpacing": 24, "rankSpacing": 38, "diagramPadding": 20}}}%%
flowchart TB
x_user["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-x-user.svg' width='52' height='52' alt=''/><br/>dojo up / dojo down</div>"]
subgraph local["operator — control side"]
dojo_cli["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-dojo-cli.svg' width='52' height='52' alt=''/><br/>bin/dojo</div>"]
terraform["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-terraform.svg' width='52' height='52' alt=''/><br/>Terraform<br/>infra/*.tf · local tfstate</div>"]
scenarios["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-scenarios.svg' width='52' height='52' alt=''/><br/>17 break/fix scenarios</div>"]
dojo_db["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-dojo-db.svg' width='52' height='52' alt=''/><br/>state/dojo.db<br/>attempt history</div>"]
end
subgraph aws["AWS example-region-1 — created on demand"]
vpc["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-vpc.svg' width='52' height='52' alt=''/><br/>VPC + subnets<br/>network.tf</div>"]
sg["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-sg.svg' width='52' height='52' alt=''/><br/>Security groups<br/>zero inbound</div>"]
iam["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-iam.svg' width='52' height='52' alt=''/><br/>IAM roles<br/>iam.tf</div>"]
ec2["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-ec2.svg' width='52' height='52' alt=''/><br/>EC2 kubeadm cluster<br/>control plane + workers</div>"]
ami["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-ami.svg' width='52' height='52' alt=''/><br/>Parked AMIs<br/>cluster state between sessions</div>"]
end
x_ssm["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/10-cka-dojo/10-cka-dojo-x-ssm.svg' width='52' height='52' alt=''/><br/>AWS SSM Session Manager<br/>the only way in — no SSH, no public port</div>"]
x_user ==> dojo_cli
dojo_cli ==> terraform
dojo_cli ==> scenarios
dojo_cli ==> dojo_db
terraform ==> vpc
terraform ==> sg
terraform ==> iam
terraform ==> ec2
vpc --> ec2
sg --> ec2
iam --> x_ssm
x_ssm ==> ec2
scenarios -.->|"inject faults via SSM"| ec2
ec2 <==>|"dojo down parks,<br/>dojo up restores"| ami
%% Layout-only ranks: keep IAM directly below Terraform and put the SSM
%% access route beneath EC2, away from the scenario fault-injection edge.
terraform ~~~ iam
ec2 ~~~ x_ssm
%% Round only Terraform's AWS fan-out and the SSM access return. Keep the
%% rest of the diagram's established linear routing unchanged.
linkStyle 4,5,6,7,11 interpolate monotoneY
classDef host fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef ingress fill:#172554,stroke:#60a5fa,stroke-width:1.5px,color:#eff6ff
classDef media fill:#431407,stroke:#fb923c,stroke-width:1.5px,color:#fff7ed
classDef data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
classDef vpn fill:#172554,stroke:#93c5fd,stroke-width:1.5px,color:#eff6ff
classDef secure fill:#450a0a,stroke:#fca5a5,stroke-width:2px,color:#fff1f2
classDef external fill:#1e293b,stroke:#94a3b8,stroke-width:1.5px,color:#f8fafc
classDef warn fill:#422006,stroke:#facc15,stroke-width:2px,color:#fefce8
class x_user host
class dojo_cli,terraform,scenarios ai
class dojo_db data
class vpc,ec2 external
class sg,iam secure
class ami data
class x_ssm ingress
Notes
I run the Dojo from the laptop when I want a CKA break-and-fix practice session. The dojo command asks Terraform to create or restore a small kubeadm cluster, then applies one of the practice scenarios that intentionally breaks part of Kubernetes.
I connect through AWS Systems Manager rather than opening SSH or a public Kubernetes endpoint. The scenario gives me the symptoms and a bounded environment. My attempt and topic result are written to the local Dojo database so later drills can focus on weak areas.
The 17 scenarios cover practical cluster faults. Terraform owns the temporary network, roles, instances, and security rules. The command wrapper keeps startup, scenario selection, result recording, and shutdown in one workflow.
When I finish, dojo down turns the instances into parked machine images and removes the running cluster. This preserves the practice state while avoiding charges for idle compute. The useful result is the attempt history and a cluster image that can be restored for the next session.
11-automation · Operations
Automation
every unit, every timer, and where it reports
Boot order, always-on services, user-scope units, and recurring timers. The health pipeline adds a path-triggered Gadgetbridge promoter, a fallback promotion timer, a Health Connect receiver, and a daily consistent backup.
At boot, the overlay service and Docker start before homelab.service brings up the Compose projects. The user service manager separately starts Hermes Gateway, Claude Bridge, and CloudOps Chat. This split lets container applications and user-owned AI processes have different permissions and restart policies.
The CLI update timer runs each day. Its service updates tools managed by mise, updates Hermes through Hermes’s own updater, restarts the affected agent services, verifies them, and sends a Telegram message if a step fails. The result is one checked update run rather than several unrelated background updaters.
The custom-image timer rebuilds Musicportal and Bookportal each week so their application images pick up current source and base-image changes. Watchtower handles published container-image updates on its own weekly schedule. These jobs are separate because locally built images and registry images need different update methods.
The staged reboot timer checks each morning. It records the need for a reboot first and performs it on the following run, leaving time to cancel. The dependency checker runs weekly and reports stale image or base references before they become surprise breakage.
Application timers run the daily job discovery, health snapshot promotion, health database backup, and nightly investment research. Each timer starts a short-lived service, and that service exits when the work is complete. An inactive oneshot is usually waiting for its next timer, not broken.
Uptime Kuma watches important endpoints and Telegram receives failure reports. Swap, earlyoom, and container memory limits reduce the chance that one workload makes the whole host unusable.
12-invest · Operations
Invest
Authenticated overlay dashboard and nightly research pipeline
The dashboard reads a privacy-safe DuckDB projection. A system timer runs the authoritative 17-stage research pipeline; bounded model stages run only after deterministic filtering.
I open the investment dashboard from the laptop through the authenticated /invest route. The gateway forwards the request to Streamlit. Streamlit reads a separate DuckDB projection, so browsing charts and tables cannot modify the pipeline’s working database.
The nightly timer starts one locked pipeline run. The first stages refresh mutual-fund data, calculate metrics, write exports to the synchronized workspace, and prepare alerts. Later stages collect prices and filings, update fundamentals, apply deterministic screens, and build the watchlist.
Only the survivors reach bounded model research and headline sentiment. Those calls add context to a filtered set instead of asking a model to inspect every record. The final stages update tracking, produce the Telegram digest, and rank the current candidates.
The last stage creates a temporary read-only UI database, checks it, and atomically replaces the previous projection. If an optional feed fails, the pipeline keeps the last usable data and marks freshness or missing sections. The dashboard therefore shows a stable result rather than a half-written run.
I use Telegram for change alerts and the compact digest. I use the Streamlit dashboard when I want to inspect the underlying metrics and rankings. The system is research-only and has no broker order path.
13-health-data · Operations
Health data
validated wearable ingress and read-only dashboards
Wearable exports are validated, promoted atomically, retained, and served to Grafana through read-only data paths.
The phone collects wearable records through Gadgetbridge and Health Connect. Gadgetbridge is the main source for steps and heart rate. Its database export moves to the server through a send-only phone folder and a receive-only server folder.
A path unit notices a new Gadgetbridge export, and a timer provides a fallback check. The promoter waits for the file to stop changing, checks its schema and SQLite integrity, records the source hash, and atomically publishes a current snapshot. A bad or incomplete export never replaces the last good copy.
Health Connect uses a second route. The phone exporter sends authenticated requests through the overlay to the receiver. The receiver deduplicates logical records, keeps each unique raw payload in the source database, checks the generated snapshot, and replaces it atomically.
A daily timer makes a transaction-consistent backup of the Health Connect source database. This protects the write-side record independently from the read-only snapshot used by dashboards.
Grafana reads both verified snapshots without write access. The source policy prefers Gadgetbridge where the two feeds overlap and uses Health Connect only for supplementary records. I open /health from the laptop or phone to view the dashboards.
14-runbooks · Operations
Runbooks
seven operational sequences
Six sequence diagrams cover boot ordering, deployment, memory pressure, sync conflicts, CLI updates, and provider or guarded-operation incidents.
%% 14-runbooks — cold boot order. The other runbooks live in 14-runbooks-*.mmd.
%% Source of truth: src/14-runbooks.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant P hp
participant P as Power on
%% atlas-participant D docker
participant D as docker.service
%% atlas-participant T overlay
participant T as overlayd
%% atlas-participant H linux
participant H as homelab.service
%% atlas-participant C docker
participant C as compose workloads
%% atlas-participant M debian
participant M as operator user manager
%% atlas-participant U terminal
participant U as user units
P->>D: system boot starts service
P->>T: system boot starts service
T-->>T: bring up authenticated overlay
Note over H: system oneshot, After docker + overlayd
D->>H: ready
T->>H: ready
H->>C: docker compose up -d --remove-orphans
Note over C: application access waits for the authenticated overlay
C-->>H: homelab + rustdesk up
Note over M,U: operator lingering is enabled and user units are enabled
P->>M: user manager starts with lingering
M->>U: default.target starts hermes-gateway,<br/>claude-bridge, cloudops-chat
Note over U: not children of homelab.service<br/>verify user manager state separately
H-->>P: active (exited), healthy oneshot
deploy gate (no CI, no staging)
%% 14-runbooks-deploy — deploying a change to a homelab service (project-dev 9b).
%% Source of truth: src/14-runbooks-deploy.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant A github-copilot
participant A as Agent
%% atlas-participant R git
participant R as Service repo
%% atlas-participant M mcp
participant M as homelab-mcp
%% atlas-participant S linux
participant S as systemd
%% atlas-participant Y debian
participant Y as User
Note over A,Y: There is no CI and no staging here.<br/>The deploy gate IS the test.
A->>M: homelab_health (BEFORE — record it)
A->>R: branch, edit, ruff check + pytest
R-->>A: real output pasted, not summarised
A->>Y: state rollback path first
Note over Y: git revert + restart<br/>keep the previous venv or commit reachable
Y->>A: explicit instruction to deploy
Note over A,S: agents never restart a unit<br/>without that instruction in-session
A->>S: systemctl restart <unit>
S-->>A: systemctl status
A->>M: service_logs(unit) — check for errors
A->>M: one real tool call end to end
A->>M: homelab_health (AFTER — compare)
alt health moved or logs show errors
A->>S: git revert + restart
A->>Y: report failure with output
else clean
A->>Y: paste before/after evidence
Y->>R: human merge gate
end
memory pressure
%% 14-runbooks-incident — memory pressure on a 7.6 GiB box.
%% Source of truth: src/14-runbooks-incident.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant K uptime-kuma
participant K as uptime-kuma
%% atlas-participant T telegram
participant T as Telegram
%% atlas-participant Y debian
participant Y as User
%% atlas-participant E linux
participant E as earlyoom
%% atlas-participant C docker
participant C as containers
Note over K,C: 7.6 GiB RAM,<br/>swap can run close to capacity.
C-->>E: memory climbs
alt earlyoom fires first
E->>C: kill the largest offender
K->>T: service down alert
else probe fails first
K->>T: service down alert
end
T->>Y: notification
Y->>C: docker stats — find the offender
Note over Y,C: do NOT stop a container to free memory.<br/>Prefer cpu-shares / renice first.<br/>Preserve stateful job queues.
alt a heavy job is running
Y->>C: renice / lower cpu-shares
else genuinely stuck
Y->>Y: get explicit OK before stopping anything
Y->>C: stop the single offender only
end
Y->>K: confirm probes green again
Note over Y: record it in the workspace —<br/>operations log is the running log
Syncthing conflict in the workspace
%% 14-runbooks-syncconflict — a Syncthing conflict in the workspace.
%% Source of truth: src/14-runbooks-syncconflict.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant L debian
participant L as workstation
%% atlas-participant S syncthing
participant S as syncthing
%% atlas-participant E obsidian
participant E as operator workspace
%% atlas-participant A github-copilot
participant A as Agent
%% atlas-participant Y debian
participant Y as User
L->>S: edit note
E->>S: agent appends to the same note
S-->>E: cannot merge
S->>E: writes <name>.sync-conflict-<date>-<device>.md
A->>E: session start — check for conflicts
alt conflict file present
A->>Y: STOP and report the filename
Note over A: never resolve a sync-conflict file silently,<br/>never delete either side
Y->>E: reconcile by hand
else clean
A->>E: proceed — append only
end
Note over A,E: why appends: two devices editing<br/>whole-file rewrites is what creates conflicts.<br/>Appending rarely collides.
A->>E: never touch .obsidian/ .trash/ .stfolder Archive/ *.bak*
CLI update failure or partial restart
%% 14-runbooks-cli-update — scheduled CLI update failure or partial restart.
%% Source of truth: src/14-runbooks-cli-update.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant T linux
participant T as homelab-cli-update.timer
%% atlas-participant U terminal
participant U as update-cli-tools.sh
%% atlas-participant M terminal
participant M as mise
%% atlas-participant H hermes
participant H as hermes update
%% atlas-participant S linux
participant S as systemd
%% atlas-participant G telegram
participant G as Telegram
%% atlas-participant Y debian
participant Y as User
T->>U: daily 03:00 run
U->>M: mise upgrade --yes --prune
U->>H: hermes update --yes
U->>S: restart and verify user units<br/>hermes-gateway, claude-bridge, cloudops-chat
U->>S: restart and verify system jobhunt-bot
alt upgrade or any verification fails
U->>G: send failure report
G->>Y: CLI update needs attention
Y->>S: inspect unit status and safe logs
Note over Y,S: Do not retry restarts blindly. Identify the failed update or unit first.
Y->>Y: choose rollback or a deliberate repair
else all units active
U-->>T: successful update run
end
provider/config outage and MCP guard refusal
%% 14-runbooks-agent-incident — provider/config outage and MCP guard refusal.
%% Source of truth: src/14-runbooks-agent-incident.mmd. Generated outputs are never hand-edited.
sequenceDiagram
autonumber
%% atlas-participant J git
participant J as Jobhunt
%% atlas-participant I terminal
participant I as interactive client
%% atlas-participant H hermes
participant H as Hermes
%% atlas-participant L claude
participant L as Claude CLI (claude -p)
%% atlas-participant P openrouter
participant P as hosted provider
%% atlas-participant M mcp
participant M as homelab-mcp
%% atlas-participant V obsidian
participant V as guarded workspace
%% atlas-participant Y debian
participant Y as User
alt Jobhunt provider request fails
J->>H: selected provider/model request
H-->>J: provider failure
Note over H,P: Inspect provider selection and credential/config status only.<br/>Never expose or copy credential values.
opt selected provider is not Hermes default
H->>H: retry Hermes working default provider
end
alt request resolves after selected route or default retry
H-->>J: provider result
else final Claude-family failure
J->>L: direct claude -p fallback
L-->>J: fallback result
else final non-Claude failure
J->>Y: report failed/retryable route
Y->>H: repair or reauthorize deliberately
end
else interactive client provider/config incident
I->>H: gateway provider request
H-->>I: failed/retryable route
I->>Y: report route and safe status
Y->>H: repair or reauthorize deliberately
end
I->>M: tool or resource request
alt path, conflict, or operation is refused
M-->>I: blocked/guard-refused
Note over M,V: No bypass, rename, overwrite, or sync-conflict resolution.
I->>Y: report refusal and safe path/context
Y->>V: resolve conflict or approve a compliant path by hand
else allowed request
M->>V: guarded read, append, or create
V-->>M: result
M-->>I: result plus audit record
end
Notes
These sequences are the procedures I follow when normal architecture is not enough. The boot runbook shows which service managers start each part and where to check when the dashboard is unavailable after a restart.
The deployment runbook keeps code changes, validation, human approval, service restart, and post-deploy health checks in order. An agent may prepare and validate a change, but I explicitly approve any restart and keep control of merge and deployment.
The memory-pressure runbook checks the largest process first, lowers priority where possible, and asks before stopping work. The sync-conflict runbook stops automated edits and preserves both copies for human review.
The CLI update runbook covers mise-managed tools, the separate Hermes update, service verification, and Telegram failure reporting. The agent-incident runbook distinguishes provider trouble from an MCP guard refusal, so a failed model route is not mistaken for permission to bypass a data guard.
I keep the notes under each sequence because the diagram gives the order while the notes explain the decision points and expected result.