Skip to diagrams

architecture guide

Homelab Atlas

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.

Sources: Atlas source · Docker · Tailscale · Syncthing

%% 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.

Sources: Docker networking · Tailscale · Gluetun

%% 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.

Sources: Tailscale Serve · Nginx · Model Context Protocol

%% 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.

Sources: Docker · Homepage · Grafana · Uptime Kuma · Jellyfin · Immich · Miniflux · wger

%% 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.

Sources: Jellyfin · Jellyseerr · Radarr · Sonarr · Prowlarr · qBittorrent · Gluetun · Bazarr · Tdarr

%% 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.

Sources: Librarian source · Navidrome · Lidarr · slskd · Soularr · Last.fm

%% 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.

Sources: Immich · Kavita · FastAPI · Gluetun

%% 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.

Sources: Syncthing · Obsidian · Grafana

%% 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.

Sources: Agent Workbench source · Pi · mise · Claude Code · Codex · Model Context Protocol · Obsidian

%% 08-ai-plane — agent workflow and trust boundaries on the host.
%% Source of truth: src/08-ai-plane.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 30, "rankSpacing": 34, "diagramPadding": 16}}}%%
flowchart TB
    subgraph entry["People and triggers"]

        x_mcp_clients["<img src='/homelab/assets/08-ai-plane/mcp.svg' width='72' height='72' alt=''/><br/><b>MCP clients</b><br/>Claude Code · Desktop · Codex<br/>OpenCode · mobile device"]

        x_browser["<img src='/homelab/assets/08-ai-plane/user.svg' width='72' height='72' alt=''/><br/><b>User</b><br/>CloudOps browser"]
        x_telegram["<img src='/homelab/assets/08-ai-plane/telegram.svg' width='72' height='72' alt=''/><br/><b>Telegram</b><br/>gateway · Jobhunt bot"]
        x_timers["<img src='/homelab/assets/08-ai-plane/timer.svg' width='72' height='72' alt=''/><br/><b>systemd timers</b><br/>Jobhunt discovery"]
    end

    subgraph context["Local session context · no credentials"]

        mise["<img src='/homelab/assets/08-ai-plane/package.svg' width='72' height='72' alt=''/><br/><b>mise</b><br/>tool installer"]
        pi["<img src='/homelab/assets/08-ai-plane/pi.svg' width='72' height='72' alt=''/><br/><b>Pi</b><br/>client · session"]
        herdr["<img src='/homelab/assets/08-ai-plane/herdr.svg' width='72' height='72' alt=''/><br/><b>Herdr CLI</b><br/>workflow state · reconciliation"]
        instructions["<img src='/homelab/assets/08-ai-plane/instructions.svg' width='72' height='72' alt=''/><br/><b>Local instructions</b><br/>AGENTS.md · skills · guarded memory"]
        workbench["<img src='/homelab/assets/08-ai-plane/github.svg' width='72' height='72' alt=''/><br/><b>agent-workbench</b><br/>public Git reference"]
    end

    %% Runtime cards stay ungrouped so edges terminate at cards, not a giant boundary.
    cloudops["<img src='/homelab/assets/08-ai-plane/nginx.svg' width='72' height='72' alt=''/><br/><b>cloudops nginx</b><br/>:8090"]
    cloudops_chat["<img src='/homelab/assets/08-ai-plane/agent.svg' width='72' height='72' alt=''/><br/><b>cloudops-chat.service</b><br/>internal API behind CloudOps"]
    hermes_gateway["<img src='/homelab/assets/08-ai-plane/hermes.svg' width='72' height='72' alt=''/><br/><b>hermes-gateway.service</b><br/>Telegram gateway"]
    hermes["<img src='/homelab/assets/08-ai-plane/hermes.svg' width='72' height='72' alt=''/><br/><b>Hermes CLI/core</b><br/>provider · model routing"]
    jobhunt_discover["<img src='/homelab/assets/08-ai-plane/timer.svg' width='72' height='72' alt=''/><br/><b>jobhunt-discover.service</b><br/>08:00 UTC"]
    jobhunt_bot["<img src='/homelab/assets/08-ai-plane/agent.svg' width='72' height='72' alt=''/><br/><b>jobhunt-bot.service</b><br/>human-gated bot"]
    claude_bridge["<img src='/homelab/assets/08-ai-plane/claude.svg' width='72' height='72' alt=''/><br/><b>claude-bridge.service</b><br/>127.0.0.1:8787"]
    homelab_mcp["<img src='/homelab/assets/08-ai-plane/mcp.svg' width='72' height='72' alt=''/><br/><b>homelab-mcp.service</b><br/>authenticated MCP gateway"]

    subgraph providers["Provider boundary"]

        codex_provider["<img src='/homelab/assets/08-ai-plane/codex.svg' width='72' height='72' alt=''/><br/><b>OpenAI Codex provider</b><br/>Pi direct route"]
        hosted["<img src='/homelab/assets/08-ai-plane/cloud.svg' width='72' height='72' alt=''/><br/><b>Hosted providers</b><br/>selected endpoints"]
        claude_cli["<img src='/homelab/assets/08-ai-plane/claude.svg' width='72' height='72' alt=''/><br/><b>Claude CLI</b><br/>subscription · claude -p"]
        codex_cli["<img src='/homelab/assets/08-ai-plane/codex.svg' width='72' height='72' alt=''/><br/><b>Codex CLI</b>"]
        credentials["<img src='/homelab/assets/08-ai-plane/credentials.svg' width='72' height='72' alt=''/><br/><b>Credential/config stores</b><br/>OAuth · provider credentials"]
    end

    subgraph guarded["Guarded MCP contracts and allow-listed state"]

        subgraph workspace_contract["Workspace"]
            t_workspace["<img src='/homelab/assets/08-ai-plane/obsidian.svg' width='72' height='72' alt=''/><br/><b>Workspace tools</b><br/>read · search · append · create"]
            workspace["<img src='/homelab/assets/08-ai-plane/obsidian.svg' width='72' height='72' alt=''/><br/><b>shared-workspace</b><br/>append/create allow-lists"]
        end
        subgraph jobhunt_contract["Jobhunt"]
            t_jobhunt["<img src='/homelab/assets/08-ai-plane/agent.svg' width='72' height='72' alt=''/><br/><b>Jobhunt tools</b>"]
            jobhunt_db["<img src='/homelab/assets/08-ai-plane/sqlite.svg' width='72' height='72' alt=''/><br/><b>Jobhunt SQLite</b>"]
        end
        subgraph dojo_contract["Dojo"]
            t_dojo["<img src='/homelab/assets/08-ai-plane/kubernetes.svg' width='72' height='72' alt=''/><br/><b>Dojo tools</b>"]
            dojo_db["<img src='/homelab/assets/08-ai-plane/sqlite.svg' width='72' height='72' alt=''/><br/><b>Dojo SQLite</b>"]
        end
        subgraph ops_contract["Operations"]
            t_ops["<img src='/homelab/assets/08-ai-plane/docker.svg' width='72' height='72' alt=''/><br/><b>Read-only ops tools</b>"]
            ops["<img src='/homelab/assets/08-ai-plane/docker.svg' width='72' height='72' alt=''/><br/><b>Docker + journal reads</b><br/>fixed allow-list"]
        end
        subgraph resource_contract["Resources and audit"]
            r_contract["<img src='/homelab/assets/08-ai-plane/github.svg' width='72' height='72' alt=''/><br/><b>Read-only resources</b><br/>AGENTS · conventions · memory"]
            audit["<img src='/homelab/assets/08-ai-plane/database.svg' width='72' height='72' alt=''/><br/><b>Audit JSONL</b><br/>caller · tool · outcome"]
        end
    end


    mise -.->|"installs Pi"| pi
    mise -.->|"installs Herdr"| herdr
    workbench -.->|"human reference"| pi
    pi -->|"loads"| instructions
    pi -.->|"reports session/state"| herdr
    pi ==>|"direct provider call"| codex_provider
    pi -->|"tool calls"| homelab_mcp

    x_browser ==>|"opens chat"| cloudops
    cloudops ==>|"auth + API proxy"| cloudops_chat
    x_telegram ==>|"gateway message"| hermes_gateway
    x_telegram ==>|"bot message"| jobhunt_bot
    hermes_gateway ==>|"dispatches"| hermes
    x_timers ==>|"starts discovery"| jobhunt_discover
    jobhunt_discover ==>|"queues review"| jobhunt_bot
    x_mcp_clients ==>|"bearer over authenticated overlay"| homelab_mcp

    cloudops_chat ==>|"Claude route"| claude_cli
    cloudops_chat ==>|"Codex route"| codex_cli
    cloudops_chat ==>|"selected provider"| hosted
    cloudops_chat -.->|"catalog only"| hermes
    hermes ==>|"selected hosted route"| hosted
    credentials -.->|"authorizes"| hosted
    hermes ==>|"claude-cli provider"| claude_bridge
    claude_bridge ==>|"spawns"| claude_cli
    jobhunt_bot ==>|"Hermes backend"| hermes
    jobhunt_bot -.->|"Claude failure fallback"| claude_cli

    homelab_mcp -->|"workspace contract"| t_workspace
    homelab_mcp -->|"jobhunt contract"| t_jobhunt
    homelab_mcp -->|"dojo contract"| t_dojo
    homelab_mcp -->|"ops contract"| t_ops
    homelab_mcp -->|"resource contract"| r_contract
    t_workspace ==>|"guarded writes"| workspace
    r_contract -->|"guarded reads"| workspace
    t_jobhunt ==>|"bounded query"| jobhunt_db
    t_dojo ==>|"bounded query"| dojo_db
    t_ops ==>|"allow-listed read"| ops
    homelab_mcp -.->|"every tool call"| audit


    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 data fill:#134e4a,stroke:#5eead4,stroke-width:1.5px,color:#f0fdfa
    classDef ai fill:#4a044e,stroke:#f0abfc,stroke-width:1.5px,color:#fdf4ff
    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_telegram,x_browser,x_mcp_clients,x_timers,pi,instructions,herdr,mise host
    class hermes_gateway,hermes,claude_bridge,cloudops_chat,homelab_mcp,jobhunt_bot,jobhunt_discover,t_workspace,t_jobhunt,t_dojo,t_ops,r_contract ai
    class cloudops ingress
    class workspace,jobhunt_db,dojo_db,audit data
    class credentials secure
    class codex_provider,hosted,workbench external
    class claude_cli,codex_cli host
    class ops warn

Notes

  • 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.

Sources: Greenhouse · Lever · Ashby · Remotive · Jobicy · Arbeitnow · Remote OK · Adzuna · SerpAPI

%% 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.

Sources: Kubernetes · Terraform · AWS Systems Manager

%% 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.

Sources: systemd timers · mise · Watchtower · Uptime Kuma

%% 11-automation — every systemd unit and timer, and what reports where.
%% Source of truth: src/11-automation.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 22, "rankSpacing": 28, "diagramPadding": 34}}}%%
flowchart TB
    subgraph boot["Boot"]

        docker_service["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-docker-service.svg' width='52' height='52' alt=''/><br/>docker.service</div>"]
        homelab_service["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-homelab-service.svg' width='52' height='52' alt=''/><br/>homelab.service<br/>oneshot · compose up -d --remove-orphans<br/>After docker + overlayd</div>"]
        x_user_manager["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-x-user-manager.svg' width='52' height='52' alt=''/><br/>operator user manager<br/>lingering enabled</div>"]
        overlayd["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-overlayd.svg' width='52' height='52' alt=''/><br/>overlayd.service</div>"]
    end

    subgraph always["Always-on services"]

        x_user_units["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-x-user-units.svg' width='52' height='52' alt=''/><br/>user scope: hermes-gateway<br/>claude-bridge · cloudops-chat</div>"]
        homelab_mcp["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-homelab-mcp.svg' width='52' height='52' alt=''/><br/>homelab-mcp.service</div>"]
        jobhunt_bot["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-jobhunt-bot.svg' width='52' height='52' alt=''/><br/>jobhunt-bot.service</div>"]
        invest_dashboard["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-invest-dashboard.svg' width='52' height='52' alt=''/><br/>invest-dashboard.service<br/>authenticated dashboard gateway</div>"]
        health_connect["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-health-connect.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-connect.service<br/>localhost webhook · local SQLite</div>"]
        health_promote_path["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-health-promote-path.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-promote.path<br/>watches receive-only ingress</div>"]
    end

    subgraph updates["CLI updates"]
        t_cli["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-cli.svg' width='52' height='52' alt=''/><br/>homelab-cli-update.timer<br/>daily 03:00</div>"]
        s_cli["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-cli.svg' width='52' height='52' alt=''/><br/>homelab-cli-update.service<br/>mise upgrade → hermes update</div>"]
        mise["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-mise.svg' width='52' height='52' alt=''/><br/>mise upgrade --yes --prune</div>"]
        hermes_update["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-hermes-update.svg' width='52' height='52' alt=''/><br/>hermes update --yes<br/>not mise-managed</div>"]
    end
    subgraph images["Custom image rebuild"]
        t_rebuild["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-rebuild.svg' width='52' height='52' alt=''/><br/>homelab-rebuild.timer<br/>Sun 03:30</div>"]
        s_rebuild["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-rebuild.svg' width='52' height='52' alt=''/><br/>rebuild-custom.sh<br/>musicportal + bookportal images</div>"]
    end
    subgraph reboot["Staged reboot"]
        t_reboot["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-reboot.svg' width='52' height='52' alt=''/><br/>homelab-reboot.timer<br/>daily 05:00</div>"]
        s_reboot["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-reboot.svg' width='52' height='52' alt=''/><br/>staged-reboot.sh<br/>stages today, reboots next day</div>"]
    end
    subgraph deprecations["Deprecation checks"]
        t_depcheck["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-depcheck.svg' width='52' height='52' alt=''/><br/>homelab-depcheck.timer<br/>Mon 09:00</div>"]
        s_depcheck["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-depcheck.svg' width='52' height='52' alt=''/><br/>check-deprecations.sh<br/>image and base scan</div>"]
    end
    subgraph pipelines["Scheduled application work"]
        t_health_promote["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-health-promote.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-promote.timer<br/>fallback poll</div>"]
        t_jobhunt["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-jobhunt.svg' width='52' height='52' alt=''/><br/>jobhunt-discover.timer<br/>08:00 UTC · persistent · 300s jitter</div>"]
        t_health_backup["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-health-backup.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-connect-backup.timer<br/>daily archival backup</div>"]
        t_invest["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-t-invest.svg' width='52' height='52' alt=''/><br/>invest-mf.timer<br/>nightly market pipeline</div>"]
        s_health_promote["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-health-promote.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-promote.service<br/>validate · atomic snapshot</div>"]
        jobhunt_discover["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-jobhunt-discover.svg' width='52' height='52' alt=''/><br/>jobhunt-discover.service<br/>jobhunt.nightly</div>"]
        s_health_backup["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-health-backup.svg' width='52' height='52' alt=''/><br/>gadgetbridge-health-connect-backup.service<br/>consistent SQLite backup</div>"]
        s_invest["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-s-invest.svg' width='52' height='52' alt=''/><br/>invest-mf.service<br/>market pipeline oneshot</div>"]
    end

    subgraph watch["Watching"]

        watchtower["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-watchtower.svg' width='52' height='52' alt=''/><br/>watchtower<br/>weekly Sun 04:00 · --cleanup</div>"]
        uptime_kuma["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-uptime-kuma.svg' width='52' height='52' alt=''/><br/>uptime-kuma :8091</div>"]
        x_oom["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-x-oom.svg' width='52' height='52' alt=''/><br/>OOM safeguards<br/>4G swap · earlyoom · mem_limits</div>"]
    end

    x_telegram["<div style='width:170px;min-height:118px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/11-automation/11-automation-x-telegram.svg' width='52' height='52' alt=''/><br/>Telegram — failure reporting</div>"]

    docker_service ==> homelab_service
    overlayd ==> homelab_service
    homelab_service ==>|"compose projects"| watch
    x_user_manager ==> x_user_units

    t_cli ==> s_cli
    t_rebuild ==> s_rebuild
    t_reboot ==> s_reboot
    t_depcheck ==> s_depcheck
    t_jobhunt ==> jobhunt_discover
    t_health_promote ==> s_health_promote
    health_promote_path ==> s_health_promote
    t_health_backup ==> s_health_backup
    t_invest ==> s_invest

    s_cli ==>|"one run"| mise
    s_cli ==> hermes_update
    s_cli -.->|"restart + verify"| x_user_units
    s_cli -.->|"restart + verify"| jobhunt_bot
    s_rebuild -.->|"rebuilds custom images"| homelab_service
    s_reboot -.->|"reboots the box"| boot

    s_cli -.->|"failure only"| x_telegram
    s_rebuild ==> x_telegram
    s_reboot ==> x_telegram
    s_depcheck ==> x_telegram
    jobhunt_discover ==> x_telegram
    uptime_kuma ==> x_telegram

    x_oom -.->|"protects"| always


    %% Non-traffic layout constraints between existing inventory cards only.
    x_user_manager ~~~ homelab_service
    x_user_units ~~~ homelab_mcp
    jobhunt_bot ~~~ pm_gateway
    health_connect ~~~ health_promote_path
    s_depcheck ~~~ t_jobhunt
    s_rebuild ~~~ t_depcheck

    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 docker_service,overlayd,homelab_service,x_user_manager host
    class homelab_mcp,jobhunt_bot,health_connect,health_promote_path,x_user_units,hermes_update ai
    class invest_dashboard ingress
    class t_cli,t_rebuild,t_reboot,t_jobhunt,t_depcheck,t_health_promote,t_health_backup,t_invest ingress
    class s_cli,s_rebuild,s_depcheck,jobhunt_discover,s_health_promote,s_health_backup,s_invest,mise media
    class s_reboot warn
    class watchtower,uptime_kuma media
    class x_oom secure
    class x_telegram external

Notes

  • 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.

Sources: Invest Pipeline source · Streamlit · DuckDB

%% 12-invest — authenticated Authenticated overlay dashboard and nightly investment research pipeline.
%% Source of truth: src/12-invest.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 30, "rankSpacing": 38, "diagramPadding": 34}}}%%
flowchart TD
    subgraph ingress["Authenticated overlay dashboard ingress"]
        x_overlay_access["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-overlay-access.svg' width='58' height='58' alt=''/><br/>Authenticated overlay Serve /invest</div>"]
        invest_dashboard["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-invest-dashboard.svg' width='58' height='58' alt=''/><br/>invest-dashboard.service<br/>authenticated gateway</div>"]
        x_streamlit["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-streamlit.svg' width='58' height='58' alt=''/><br/>Streamlit dashboard<br/>loopback only</div>"]
    end

    subgraph schedule["Nightly systemd execution"]
        invest_mf_timer["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-invest-mf-timer.svg' width='58' height='58' alt=''/><br/>invest-mf.timer<br/>21:30 UTC + jitter</div>"]
        invest_mf_service["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-invest-mf-service.svg' width='58' height='58' alt=''/><br/>invest-mf.service<br/>oneshot · maintenance lock</div>"]
    end

    subgraph pipeline["17-stage contract"]
        x_mf_refresh["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-mf-refresh.svg' width='58' height='58' alt=''/><br/>1-6 MF refresh, metrics, export, workspace sync, alerts</div>"]
        x_market_data["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-market-data.svg' width='58' height='58' alt=''/><br/>7-11 prices, XBRL chip, fundamentals, screens, watchlist</div>"]
        x_bounded_models["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-bounded-models.svg' width='58' height='58' alt=''/><br/>12-13 bounded survivor research + headline sentiment</div>"]
        x_outputs["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-outputs.svg' width='58' height='58' alt=''/><br/>14-16 tracking, digest, ranking</div>"]
        ui_projection["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-ui-projection.svg' width='58' height='58' alt=''/><br/>17 verified read-only UI projection<br/>atomic replace · 0600</div>"]
    end

    subgraph state["Local state and outputs"]
        invest_db["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-invest-db.svg' width='58' height='58' alt=''/><br/>data/invest.duckdb<br/>writer state</div>"]
        invest_ui_db["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-invest-ui-db.svg' width='58' height='58' alt=''/><br/>data/ui/invest-ui.duckdb<br/>safe UI projection</div>"]
        x_workspace_exports["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-workspace-exports.svg' width='58' height='58' alt=''/><br/>shared-workspace Projects/invest-exports</div>"]
        x_telegram["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-telegram.svg' width='58' height='58' alt=''/><br/>Telegram change alerts and digest</div>"]
        x_stale["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-stale.svg' width='58' height='58' alt=''/><br/>fail-open chips retain last-good data<br/>warnings and stale/missing state remain visible</div>"]
    end

    subgraph sources["Read-only external sources"]
        x_mf_sources["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-mf-sources.svg' width='58' height='58' alt=''/><br/>TigZig · MFAPI · NSE</div>"]
        x_stock_sources["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-stock-sources.svg' width='58' height='58' alt=''/><br/>NSE filings · bhavcopy · market data</div>"]
        x_claude_bridge["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/12-invest/12-invest-x-claude-bridge.svg' width='58' height='58' alt=''/><br/>claude-bridge<br/>bounded model calls</div>"]
    end

    x_overlay_access --> invest_dashboard --> x_streamlit
    invest_ui_db -->|"read-only queries"| x_streamlit
    invest_mf_timer ==> invest_mf_service
    invest_mf_service ==> x_mf_refresh ==> x_market_data ==> x_bounded_models ==> x_outputs ==> ui_projection
    x_mf_refresh --> invest_db
    x_market_data --> invest_db
    x_bounded_models --> invest_db
    x_outputs --> invest_db
    ui_projection ==> invest_ui_db
    x_mf_refresh --> x_workspace_exports
    x_outputs --> x_telegram
    x_mf_sources --> x_mf_refresh
    x_stock_sources --> x_market_data
    x_claude_bridge --> x_bounded_models
    x_market_data -.-> x_stale
    x_bounded_models -.-> x_stale

    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_overlay_access,invest_dashboard ingress
    class x_streamlit host
    class invest_mf_timer ingress
    class invest_mf_service host
    class x_mf_refresh,x_market_data,x_outputs media
    class x_bounded_models,x_claude_bridge ai
    class ui_projection,invest_db,invest_ui_db,x_workspace_exports data
    class x_mf_sources,x_stock_sources,x_telegram external
    class x_stale warn

Notes

  • 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.

Sources: Gadgetbridge · Gadgetbridge source · Health Connect · Grafana · Syncthing

%% 13-health-data — wearable ingress, validation, snapshots, and Grafana reads.
%% Source of truth: src/13-health-data.mmd. Generated outputs are never hand-edited.
%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 30, "rankSpacing": 38, "diagramPadding": 34}}}%%
flowchart LR
    subgraph devices["Wearable data sources"]
        x_miband["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-x-miband.svg' width='58' height='58' alt=''/><br/>Wearable device</div>"]
        x_gadgetbridge["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-x-gadgetbridge.svg' width='58' height='58' alt=''/><br/>Wearable client<br/>primary steps and heart rate</div>"]
        x_health_connect["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-x-health-connect.svg' width='58' height='58' alt=''/><br/>Health Connect exporter<br/>supplementary records</div>"]
    end

    subgraph gadgetbridge_ingress["Gadgetbridge receive-only ingress"]
        syncthing["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-syncthing.svg' width='58' height='58' alt=''/><br/>Syncthing<br/>mobile device send-only · server receive-only</div>"]
        gb_source["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-gb-source.svg' width='58' height='58' alt=''/><br/>Gadgetbridge SQLite export</div>"]
        promote_path["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-promote-path.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-promote.path<br/>watches canonical export</div>"]
        promote_timer["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-promote-timer.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-promote.timer<br/>15-minute fallback poll</div>"]
    end

    subgraph validation["Validation and last-good publication"]
        promote_service["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-promote-service.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-promote.service<br/>stable source · schema · quick_check</div>"]
        gb_current["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-gb-current.svg' width='58' height='58' alt=''/><br/>current/Gadgetbridge<br/>atomic validated snapshot</div>"]
        gb_archive["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-gb-archive.svg' width='58' height='58' alt=''/><br/>promoted archive<br/>last-good retention</div>"]
        promotion_state["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-promotion-state.svg' width='58' height='58' alt=''/><br/>promotion state<br/>hash · schema · freshness</div>"]
    end

    subgraph hc_ingress["Token-authenticated Health Connect ingress"]
        x_overlay_access["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-x-overlay-access.svg' width='58' height='58' alt=''/><br/>Authenticated overlay Serve<br/>/health-connect</div>"]
        hc_receiver["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-receiver.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-connect.service<br/>127.0.0.1:8788</div>"]
        hc_source["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-source.svg' width='58' height='58' alt=''/><br/>source.sqlite<br/>raw payload preservation</div>"]
        hc_snapshot["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-snapshot.svg' width='58' height='58' alt=''/><br/>health-connect.sqlite<br/>verified atomic snapshot</div>"]
        hc_backup_timer["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-backup-timer.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-connect-backup.timer<br/>daily + jitter</div>"]
        hc_backup_service["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-backup-service.svg' width='58' height='58' alt=''/><br/>gadgetbridge-health-connect-backup.service<br/>transaction-consistent backup</div>"]
        hc_backups["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-hc-backups.svg' width='58' height='58' alt=''/><br/>archival backup retention</div>"]
    end

    subgraph read_path["Read-only dashboard path"]
        source_policy["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-source-policy.svg' width='58' height='58' alt=''/><br/>conservative source policy<br/>Gadgetbridge primary · duplicate HC excluded<br/>sleep remains gated</div>"]
        grafana["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-grafana.svg' width='58' height='58' alt=''/><br/>Grafana<br/>read-only health mounts</div>"]
        x_health_ui["<div style='width:190px;min-height:132px;text-align:center;white-space:normal;overflow-wrap:anywhere'><img src='/homelab/assets/13-health-data/13-health-data-x-health-ui.svg' width='58' height='58' alt=''/><br/>Authenticated overlay /health<br/>health dashboards</div>"]
    end

    x_miband ==> x_gadgetbridge ==> syncthing ==> gb_source
    gb_source ==> promote_path ==> promote_service
    promote_timer ==> promote_service
    promote_service ==>|"validated atomic replace"| gb_current
    gb_current -.->|"previous valid copy"| gb_archive
    promote_service --> promotion_state

    x_health_connect ==> x_overlay_access ==> hc_receiver
    hc_receiver ==> hc_source
    hc_receiver ==>|"quick_check then atomic replace"| hc_snapshot
    hc_backup_timer ==> hc_backup_service ==> hc_backups
    hc_source ==> hc_backup_service

    gb_current ==> source_policy
    hc_snapshot ==> source_policy
    source_policy ==> grafana ==> x_health_ui

    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_miband,x_gadgetbridge,x_health_connect external
    class syncthing,grafana media
    class gb_source,gb_current,gb_archive,promotion_state,hc_snapshot,hc_backups data
    class promote_path,promote_timer,x_overlay_access,hc_backup_timer,x_health_ui ingress
    class promote_service,hc_receiver,hc_backup_service,source_policy host
    class hc_source secure

Notes

  • 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.

Sources: Agent Workbench workflows · systemd

cold boot order

%% 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.