Homelab Infrastructure, services, and remote access

The homelab is a small Proxmox cluster plus a Mac Studio running Ed’s self-hosted services, media stack, GIS pipeline, and Claude infrastructure. Most things are reachable on the LAN at 192.168.8.0/24 and on the NetBird mesh anywhere else.

Topology at a glance
Host Address Role
hpve (Proxmox) 192.168.8.221, also 10.10.10.2 over TB4 Hypervisor for CT100–CT105
CT100 192.168.8.100 Docker host β€” most services live here
CT101 192.168.8.101 Immich (photos)
CT102 192.168.8.102 Pi-hole DNS
CT103 192.168.8.54 Caddy reverse proxy β†’ *.edmd.me
CT104 β€” retired (Vaultwarden, May 2026)
CT105 192.168.8.105 Roon Server
Mac Studio 192.168.8.180 Hugo build, Life Archive embed/API, dictation pipeline, MCP servers, Claude Desktop / Cowork
fpve (Farm Proxmox) NetBird 100.123.49.175 β†’ 192.168.0.0/24 Home Assistant + farm-side services (currently unreachable)
Where to start
Retired services

Tombstone pages are kept on purpose so that searches for retired services land somewhere useful instead of a 404:

  • Vaultwarden β€” replaced by ~/Sync/ED/SECRETS.md + per-service secrets.env files (May 2026)
  • Pangolin β€” replaced by NetBird mesh (April 2026)
Application Docs

Reference documentation for every application running across the homelab. Each entry covers what the app does, where it lives, how to access it, configuration notes, and any quirks worth remembering.


Portainer

What it is: Web UI for managing Docker containers, images, volumes, and networks. The control plane for everything running in CT 100.

URL https://192.168.8.100:9443
Host CT 100 (192.168.8.100)
Image portainer/portainer-ce:lts
Data /var/lib/docker/volumes/portainer_data

Notes:

  • Use Portainer to inspect container logs, restart services, and update images (pull β†’ recreate)
  • All other Docker services are deployed via docker-compose files in /opt/docker/ on CT 100
  • Portainer itself is started via a standalone docker run command, not compose
Uptime Kuma

What it is: Self-hosted uptime monitoring dashboard. Pings services on a schedule and alerts via Gotify when something goes down.

URL http://192.168.8.100:3001
Host CT 100 (192.168.8.100)
Image louislam/uptime-kuma:1
Data /opt/docker/uptime-kuma/data

Monitored services:

  • Proxmox Web UI (192.168.8.221:8006)
  • Portainer (192.168.8.100:9443)
  • Gotify (192.168.8.100:8070)
  • N8N (192.168.8.100:5678)
  • Mac Studio ping (192.168.8.180)

Notes:

  • Notifications route to Gotify (push to phone)
  • Add new monitors from the dashboard β€” no config file needed
Gotify

What it is: Self-hosted push notification server. Uptime Kuma and other services send alerts here; the Gotify app on your phone receives them.

URL http://192.168.8.100:8070
Host CT 100 (192.168.8.100)
Image gotify/server:latest
Data /opt/docker/gotify/data

Notes:

  • Install the Gotify app on iPhone and point it at http://192.168.8.100:8070
  • Each sending service (Uptime Kuma, N8N, etc.) needs its own application token β€” create in the Gotify web UI under Apps
  • Messages are stored and browsable in the web UI
N8N

What it is: Visual workflow automation engine β€” think self-hosted Zapier/Make. Connects services together via trigger β†’ action workflows.

URL http://192.168.8.100:5678
Host CT 100 (192.168.8.100)
Image n8nio/n8n:latest
Data /opt/docker/n8n/data

Notes:

  • Workflows are stored in the data volume β€” back this up before updating the image
  • Can trigger on webhooks, schedules, or incoming messages
  • Integrates with Gotify for sending alerts from custom workflows
  • Accessible externally via NetBird mesh if a webhook endpoint is needed from off-LAN; for true public webhooks, route via the VPS Caddy at edge01
Lidarr

What it is: Automated music collection manager. Monitors wanted artists/albums, finds them on Usenet via NZBHydra2, and sends download requests to NZBGet on the seedbox.

URL http://192.168.8.100:8686
Host CT 100 (192.168.8.100)
Image lscr.io/linuxserver/lidarr:nightly
Data /opt/lidarr/data
Music /mnt/music (bind mount β†’ /nvmepool/music)

Pipeline:

  1. Daily 4am cron triggers MissingAlbumSearch β€” Lidarr actively searches all monitored missing albums
  2. Lidarr queries Headphones VIP indexer (primary β€” proper t=music support) and NZBHydra2 (broken for music β€” altHUB doesn’t support t=music)
  3. Matched releases sent to NZBGet on seedbox (tunnel at 192.168.8.221:16789)
  4. NZBGet downloads to completed/Music/ on seedbox at ~70 MB/s
  5. sync-seedbox.sh (every 2 min) pulls to /BIGGIE/Seedbox/Music/ on Proxmox
  6. Lidarr import picks up files and moves to /mnt/music (requires β‰₯80% MusicBrainz match)
  7. Navidrome rescans and adds to library

Notes:

  • Image: lscr.io/linuxserver/lidarr:nightly (nightly required for plugin support)
  • Quality profile: Lossless (FLAC) preferred
  • Release profile blocks: Greatest Hits, Best Of, Collection, Anthology, etc. (prevents import loop)
  • 114 monitored artists, ~3,819 missing albums (March 2026)
  • Plugins planned: Tidal (TrevTV), Tubifarry (TypNull)
  • See Music Pipeline page for full detail
Audiobookshelf

What it is: Self-hosted audiobook and podcast server with a polished web UI and mobile apps.

URL http://192.168.8.100:13378
Host CT 100 (192.168.8.100)
Image ghcr.io/advplyr/audiobookshelf:latest
Books /mnt/audiobookshelf (bind mount β†’ /nvmepool/audiobookshelf)
Data /opt/docker/audiobookshelf/data

Notes:

  • iOS app available β€” connects to the local URL on LAN or via NetBird mesh for remote access (use audiobookshelf.edmd.me)
  • Supports progress sync across devices
  • Podcast feeds can be added directly β€” no separate podcast app needed
  • Metadata fetched from Audible and Google Books automatically
Bookshelf (Hardcover)

What it is: Book tracking and discovery app β€” think self-hosted Goodreads backed by the Hardcover catalog.

URL http://192.168.8.100:8787
Host CT 100 (192.168.8.100)
Image ghcr.io/pennydreadful/bookshelf:hardcover
Data /mnt/bookshelf (bind mount β†’ /nvmepool/bookshelf)

Notes:

  • Uses the Hardcover API for book metadata and cover art
  • Track read/reading/want-to-read status
  • Separate from Audiobookshelf β€” this is for physical/ebook tracking, not audio playback
Shelfmark

What it is: Book and audiobook search tool that aggregates sources. Proxied outbound through the seedbox SOCKS5 tunnel for exit via Netherlands IP.

URL http://192.168.8.100:8084
Host CT 100 (192.168.8.100)
Image ghcr.io/calibrain/shelfmark:latest
Proxy SOCKS5 via 192.168.8.100:1080 β†’ seedbox (ismene.usbx.me, NL exit)

Notes:

  • The SOCKS5 proxy is provided by seedbox-socks.service (autossh systemd service on CT 100)
  • Remote access via NetBird mesh using shelfmark.edmd.me β€” no port forwarding required
  • If searches fail, check that seedbox-socks.service is running: systemctl status seedbox-socks.service on CT 100
FreshRSS

What it is: Self-hosted RSS feed aggregator with a clean web UI and API support for mobile clients.

URL http://192.168.8.100:8180
Host CT 100 (192.168.8.100)
Image freshrss/freshrss:latest
Data /opt/docker/freshrss/data

Notes:

  • Compatible with Fever and Google Reader APIs β€” most RSS apps on iOS connect via one of these
  • OPML import/export supported for migrating feeds
  • Feeds refresh on a configurable schedule (default every hour)
Nextcloud β€” RETIRED

Retired April 2026. Nextcloud on CT100 was decommissioned along with the MariaDB backend container. File-sync is now handled by Syncthing (peer-to-peer, no central server); file-sharing by the SMB share at files.edmd.me (Samba on the VPS, NetBird-only).

The Docker volumes are gone. If you find a reference somewhere to http://192.168.8.100:8280, it’s stale β€” flag and remove.

Pangolin + Gerbil + Traefik β€” RETIRED

Retired April 19, 2026. The Pangolin/Gerbil/Traefik stack on the SSDNodes VPS was decommissioned and replaced by NetBird mesh (/homelab/netbird/). All Newt services on hpve and fpve are gone, the VPS dashboard at pangolin.troglodyteconsulting.com is offline, and the VPS itself has been repurposed for the SMB share at files.edmd.me.

See Pangolin tombstone and the NetBird docs for the current remote-access pattern.

Mac Studio Services (192.168.8.180)

Services running directly on macOS β€” not containerized.

Service Port URL Notes
Hugo Hub 1313 http://192.168.8.180:1313 This site β€” run with hugo server in bee-hub directory
Paperless-NGX 8100 http://192.168.8.180:8100 Document management β€” Docker on Mac
Life Archive API 8900 http://192.168.8.180:8900 RAG search API for personal knowledge base
Life Archive MCP 8901 http://192.168.8.180:8901/mcp MCP server β€” exposes Life Archive to Claude
Embed Server 1235 localhost:1235 gte-Qwen2-7B on Apple MPS β€” local only
SyncThing 8384 http://192.168.8.180:8384 File sync between devices

Notes:

  • Hugo Hub serves the bee-hub documentation site during development; rebuild with hugo to update /public
  • Life Archive API and MCP server start via launch agents or manual scripts β€” check ~/scripts/ for startup commands
  • Embed server (LM Studio or custom) must be running for Life Archive RAG embeddings to work
Seedbox Services (ismene.usbx.me)

Remote Usenet server accessed via SSH tunnels on Proxmox.

Service Seedbox Port Local Tunnel Notes
NZBGet 13036 http://192.168.8.221:16789 Usenet downloader
NZBHydra2 13033 http://192.168.8.221:15076 Indexer aggregator
SOCKS5 β€” 192.168.8.100:1080 Outbound proxy for Shelfmark via CT 100
SSH 22 β€” ssh delgross@46.232.210.50

Tunnel management: Both NZBGet and NZBHydra2 tunnels run as systemd services on Proxmox (nzbget-tunnel.service, nzbhydra2-tunnel.service). Check with systemctl status nzbget-tunnel on 192.168.8.221.

Cockpit

What it is: Lightweight web-based server management UI β€” CPU, memory, disk, storage, logs, services, terminal, and updates all in one browser tab. Runs on both Proxmox and the VPS.

Instance URL Notes
Proxmox https://192.168.8.221:9090 System management for Proxmox host
VPS https://172.93.50.184:9090 System management for SSDNodes VPS

Notes:

  • Cockpit is socket-activated β€” cockpit.service shows inactive until you open the URL, which is normal. cockpit.socket is always listening on port 9090.
  • Login with the system root credentials
  • Useful for checking logs (journalctl), restarting services, monitoring disk/CPU, and running a quick terminal session without SSH
  • Self-signed cert β€” browser will warn on first visit, just accept
Home Assistant

What it is: Open-source smart home automation platform. Runs at the Farm (Brownsville, 192.168.0.x subnet).

Local URL http://192.168.0.10:8123
Remote URL https://ha.troglodyteconsulting.com
Host Farm Docker CT 100 (192.168.0.6)
Network Farm subnet 192.168.0.x β€” separate from home 192.168.8.x

Remote access:

  • Exposed via Pangolin tunnel from Farm Proxmox (192.168.0.191)
  • Farm Proxmox runs its own Newt as a systemd service (not Docker)
  • Accessible at ha.troglodyteconsulting.com when the Farm tunnel is up

Notes:

  • Farm Proxmox (192.168.0.191) is on a separate network and not always reachable from home β€” use Pangolin remote URL when off-site or if LAN route is unavailable
  • Farm also runs its own Portainer (192.168.0.6:9443), Uptime Kuma (192.168.0.6:3001), and Gotify (192.168.0.6:8070)
  • HA configuration lives in the Docker data volume on Farm CT 100 β€” back up before updates
Paperless-NGX

What it is: Self-hosted document management system with OCR, auto-tagging, and full-text search. Runs on the Mac Studio via Docker.

URL http://192.168.8.180:8100
Host Mac Studio (192.168.8.180)
Compose file ~/paperless-ngx/docker/docker-compose.yml
Images paperless-ngx, postgres:16, redis:7, paperless-ai (stopped)

Start/stop:

cd ~/paperless-ngx/docker
docker compose up -d
docker compose down

Consume folder: Drop files into ~/paperless-ngx/consume/ to ingest. Paperless OCRs, tags, and indexes automatically.

Key volumes:

Host path Purpose
~/paperless-ngx/data/ SQLite DB and search index
~/paperless-ngx/media/ Stored documents
~/paperless-ngx/consume/ Drop files here to ingest
~/paperless-ngx/export/ Bulk export output

Notes:

  • paperless-ai container is stopped β€” was used for AI auto-tagging, disabled after memory issues
  • Integrated with Life Archive β€” Paperless documents are a source for the RAG pipeline
  • Admin login: delgross / see secure notes
Architecture & Dependencies

The dependency graph for the homelab. When something breaks, walk up the graph to find which upstream is the actual problem β€” most “X is broken” stories end at DNS, the router, or a specific Proxmox host.

Network path (any client β†’ service)
flowchart TD
    Client[Client device
Mac / iPhone / iPad] NetBird[NetBird mesh
WireGuard P2P + relays] OPNsense[OPNsense router
192.168.8.1] Pihole[Pi-hole DNS
CT102 192.168.8.53] Unbound[Unbound recursive
CT100 :5335] Caddy[Caddy reverse proxy
CT103 192.168.8.54] Services[Services on CT100
192.168.8.100] Immich[CT101 Immich
192.168.8.103] Roon[CT105 Roon
192.168.8.105] Client -->|DNS query *.edmd.me| Pihole Client -->|HTTPS request| OPNsense Pihole --> Unbound Unbound -->|forward *.edmd.me| Pihole OPNsense -->|192.168.8.54| Caddy Caddy -->|reverse_proxy| Services Caddy -->|reverse_proxy| Immich Caddy -->|reverse_proxy| Roon Client -.->|off-LAN| NetBird NetBird -.->|via hpve subnet route| OPNsense

When DNS is broken, every .edmd.me URL fails. Check Pi-hole first, then Unbound. When DNS works but a specific service is unreachable, the issue is in Caddy or the service itself.

Home ↔ Farm cross-site
flowchart LR
    subgraph home["Home LAN β€” 192.168.8.0/24"]
        hpve[hpve
Proxmox] homeCTs[CT100-CT105] studio[Mac Studio] hpve --> homeCTs end subgraph farm["Farm LAN β€” 192.168.0.0/24"] fpve[fpve
Proxmox] farmCTs[CT100-CT103] ha[Home Assistant
192.168.0.10] fpve --> farmCTs end subgraph mesh["NetBird mesh"] nb[NetBird cloud] end hpve -.->|peer 100.123.31.199
routes 192.168.8.0/24| nb fpve -.->|peer 100.123.49.175
routes 192.168.0.0/24| nb studio -.->|peer 100.123.217.253| nb nb -.->|advertised routes| studio

Failure pattern: when fpve falls off the mesh, the farm LAN becomes unreachable from home β€” even though fpve itself is fine inside the farm LAN. The dependency is on fpve being NetBird-connected, not on it being alive.

Data flows β€” dictation + briefing + doc-sync
flowchart TD
    JPR[Just Press Record
Watch / iPhone] iCloud[iCloud Drive] Studio[Mac Studio] Whisper[Whisper large-v3
mlx-whisper] Parse[parse-dictation.py
dash-command parser] Tasks[TASKS.md] Diary[~/Sync/ED/dictation/diary/] Tana[Tana #interaction nodes] JPR --> iCloud iCloud -->|brctl download| Studio Studio --> Whisper Whisper --> Parse Parse --> Tasks Parse --> Diary Parse --> Tana Briefing[daily-briefing
Cowork 04:00] ArrHelper[arr-briefing-data.py] BackupHelper[homelab-backup-status.py] Snapshot[~/.homelab-snapshot.json] Email[Gmail msmtp] Drafts[Drafts note] BriefMd[morning-briefing.md] Briefing --> ArrHelper Briefing --> BackupHelper Briefing --> Snapshot Briefing --> Tasks Briefing --> Diary Briefing --> BriefMd Briefing --> Email Briefing --> Drafts DocSync[doc-sync
launchd 03:00] Key[~/.config/anthropic-api-key] Transcripts[Yesterday's Claude transcripts] Report[~/Sync/ED/.doc-sync-log/.md] DocSync --> Key DocSync --> Transcripts DocSync -->|patches| Tasks DocSync -->|patches| Diary DocSync --> Report

The briefing reaches 5+ data sources. If any single source is unavailable, the SKILL is hardened to print [source unavailable] and continue β€” no single failure kills the whole briefing.

Implicit dependencies

These don’t show on the diagrams but matter when something breaks:

  • ~/.homelab-snapshot.json is written by com.bee.homelab-snapshot.plist every few minutes. If the launchd job is dead, the snapshot ages out and the briefing’s homelab-health section falls back to live SSH (which is slower and brittle if pve is busy).
  • Time synchronization (NTP) matters everywhere β€” backup timestamps, log alignment between hpve and Mac Studio, doc-sync’s “yesterday” calculation. macOS handles it automatically; pve uses systemd-timesyncd.
  • Cloudflare is implicit upstream for DNS resolution outside the LAN, wildcard TLS cert issuance for Caddy, and the edmd.me domain. A Cloudflare account outage breaks new cert renewals (existing certs survive for ~30 days).
  • Anthropic API is implicit upstream for doc-sync, the briefing’s prompt assembly, dictation parsing, and most of Cowork. An outage cascades to most automation.
  • Syncthing relay infrastructure (run by the Syncthing project) is implicit upstream when home and MacBook can’t establish a direct P2P link. Default-on, free, but if it goes down sync degrades silently.
When X breaks, look upstream
Symptom Walk up to
*.edmd.me cert errors Caddy β†’ Cloudflare wildcard cert state
All .edmd.me unreachable Caddy β†’ DNS path (Pi-hole β†’ Unbound)
One specific .edmd.me URL fails Caddy’s Caddyfile (port mapping for that subdomain)
Whole LAN slow OPNsense β†’ ISP
Off-LAN can’t reach anything NetBird mesh β†’ device’s NetBird client state
ha-mcp dead fpve NetBird state β†’ farm internet β†’ fpve uptime
Briefing missing doc-sync auth state β†’ API key β†’ Cowork scheduler
Dictation not in diary iCloud sync state β†’ whisper venv β†’ parse-dictation.py
Doc-sync producing 300-byte reports API key state (~/.config/anthropic-api-key) β€” see doc-sync runbook
Authentik SSO

Single sign-on for all *.edmd.me services via forward-auth. Deployed May 17, 2026 on CT100.

Overview

Authentik provides centralized authentication for homelab services. Instead of each service managing its own login, Caddy checks with Authentik before allowing access. If you’re not authenticated, you get redirected to the Authentik login page at auth.edmd.me. After login, a session cookie valid across all *.edmd.me services keeps you authenticated everywhere.

Version 2025.4
Container authentik-server, authentik-worker, authentik-redis
Host CT100 (192.168.8.100)
Ports 9100 (HTTP β†’ internal 9000), 9444 (HTTPS β†’ internal 9443)
URL https://auth.edmd.me
Mode Forward-domain (single provider covers all *.edmd.me)
Cookie domain edmd.me
Compose file /opt/authentik/docker-compose.yml on CT100
Data dir /mnt/container-data/authentik/ (media, redis, certs, templates)
Database PostgreSQL 17 on CT100 β€” database authentik, user authentik
Architecture

The authentication flow works like this:

  1. User requests https://grafana.edmd.me/
  2. Caddy (CT103) sends a forward-auth subrequest to Authentik (CT100:9100) at /outpost.goauthentik.io/auth/caddy
  3. Authentik’s embedded outpost checks for a valid session cookie
  4. If authenticated: Returns 200 with user identity headers β†’ Caddy proxies to the backend
  5. If not authenticated: Returns 302 redirect β†’ user sees the Authentik login at auth.edmd.me
  6. After login, Authentik sets a cookie on .edmd.me and redirects back to the original URL
  7. Subsequent requests to any *.edmd.me service reuse the same cookie β€” no re-login needed

Key components:

Component Role
authentik-server Main application server + embedded outpost (Go proxy)
authentik-worker Background tasks (Celery)
authentik-redis Session store + cache
PostgreSQL (existing) Stores users, flows, providers, applications
Caddy forward_auth Intercepts requests and checks auth status
Caddy Forward-Auth Configuration

To protect a service with Authentik, add the forward_auth block before the reverse_proxy in that service’s Caddy handle on CT103:

@grafana host grafana.edmd.me metrics.edmd.me
handle @grafana {
    forward_auth 192.168.8.100:9100 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
        trusted_proxies private_ranges
    }
    reverse_proxy 192.168.8.100:3200
}

Adding auth to a new service β€” step by step:

  1. SSH into CT103: ssh root@192.168.8.221 then pct exec 103 -- bash
  2. Edit /etc/caddy/Caddyfile
  3. Find the service’s handle block
  4. Add the forward_auth block (copy from above) immediately before reverse_proxy
  5. Validate: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
  6. Reload: caddy reload --config /etc/caddy/Caddyfile
  7. Test: visit the service URL β€” you should get redirected to auth.edmd.me

The copy_headers line passes user identity downstream. Backend services can read X-Authentik-Username, X-Authentik-Email, etc. to know who’s logged in without implementing their own auth.

The trusted_proxies private_ranges line tells Caddy to trust forwarded headers from private IPs (the Authentik server).

Provider & Application
Setting Value
Provider name caddy-forward-auth
Provider PK 1
Mode forward_domain
External host https://auth.edmd.me
Cookie domain edmd.me
Access token validity 24 hours
Application name Homelab Services
Application slug homelab-services
Authorization flow default-provider-authorization-implicit-consent
Authentication flow default-authentication-flow
Invalidation flow default-invalidation-flow
Outpost Embedded (built into authentik-server)

The forward_domain mode is what allows a single provider to protect all *.edmd.me services. It sets a cookie on the .edmd.me domain, so once authenticated at any service, you’re authenticated everywhere.

Docker Compose

Located at /opt/authentik/docker-compose.yml on CT100:

services:
  authentik-redis:
    image: redis:alpine
    container_name: authentik-redis
    restart: unless-stopped
    command: --save 60 1 --loglevel warning
    volumes:
      - /mnt/container-data/authentik/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      start_period: 20s
      interval: 30s
      retries: 5
      timeout: 3s
    networks:
      - authentik

  authentik-server:
    image: ghcr.io/goauthentik/server:2025.4
    container_name: authentik-server
    restart: unless-stopped
    command: server
    environment:
      AUTHENTIK_SECRET_KEY: "<redacted>"
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: 192.168.8.100
      AUTHENTIK_POSTGRESQL__PORT: 5432
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: "<redacted>"
      AUTHENTIK_HOST: "https://auth.edmd.me"
      AUTHENTIK_HOST_BROWSER: "https://auth.edmd.me"
    volumes:
      - /mnt/container-data/authentik/media:/media
      - /mnt/container-data/authentik/custom-templates:/templates
    ports:
      - "9100:9000"
      - "9444:9443"
    depends_on:
      authentik-redis:
        condition: service_healthy
    networks:
      - authentik

  authentik-worker:
    image: ghcr.io/goauthentik/server:2025.4
    container_name: authentik-worker
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_SECRET_KEY: "<redacted>"
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: 192.168.8.100
      AUTHENTIK_POSTGRESQL__PORT: 5432
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: "<redacted>"
      AUTHENTIK_HOST: "https://auth.edmd.me"
      AUTHENTIK_HOST_BROWSER: "https://auth.edmd.me"
    volumes:
      - /mnt/container-data/authentik/media:/media
      - /mnt/container-data/authentik/custom-templates:/templates
      - /mnt/container-data/authentik/certs:/certs
    depends_on:
      authentik-redis:
        condition: service_healthy
    networks:
      - authentik

networks:
  authentik:
    driver: bridge

Important environment variables:

Variable Purpose
AUTHENTIK_HOST Tells the embedded outpost where to find itself externally
AUTHENTIK_HOST_BROWSER URL the browser uses for redirects
AUTHENTIK_SECRET_KEY Encryption key for sessions and tokens
AUTHENTIK_POSTGRESQL__HOST Uses CT100’s host IP (not container name β€” bridge network doesn’t resolve DNS)
Data Directories

All on /mnt/container-data/authentik/ (nvmepool on CT100):

Path Owner Purpose
media/ UID 1000 Uploaded icons, branding assets
redis/ UID 999 Redis RDB snapshots
certs/ UID 1000 Custom certificates (if needed)
custom-templates/ UID 1000 Custom login page templates

Ownership matters: Authentik runs as UID 1000, Redis as UID 999. Wrong ownership causes crashes.

Administration

Admin interface: https://auth.edmd.me/if/admin/

Common tasks:

Task How
Add a user Admin β†’ Directory β†’ Users β†’ Create
Change auth flow Admin β†’ Flows & Stages
View active sessions Admin β†’ Applications β†’ homelab-services β†’ Sessions
Check outpost health Admin β†’ Applications β†’ Outposts β†’ authentik Embedded Outpost
API access curl -H "Authorization: Bearer <token>" http://192.168.8.100:9100/api/v3/

API token (for automation scripts): Stored in Vaultwarden. Create new tokens at Admin β†’ Directory β†’ Tokens & App Passwords.

Troubleshooting
Symptom Cause Fix
404 on protected service Outpost not matching the Host header Check provider mode is forward_domain with cookie_domain edmd.me. Restart authentik-server after changes
“Failed to detect forward URL from Caddy” Missing X-Forwarded-* headers Ensure requests come through Caddy’s forward_auth directive, not direct curl
500 on auth endpoint Outpost matched but missing forwarded headers Normal for bare curl tests; real Caddy requests include the needed headers
Login works but cookie doesn’t persist across services Cookie domain wrong Verify provider cookie_domain is edmd.me (not auth.edmd.me)
Outpost shows unhealthy Server restart needed after config change docker restart authentik-server on CT100
Redis crash on startup Wrong ownership on redis data dir chown -R 999:999 /mnt/container-data/authentik/redis
Worker crash / permission denied Wrong ownership on media dir chown -R 1000:1000 /mnt/container-data/authentik/media
Brand domain mismatch Brand still set to default API: PATCH /api/v3/core/brands/<uuid>/ with {"domain": "auth.edmd.me"}

Useful debug commands:

# Check outpost health (from CT100)
docker exec authentik-server ak healthcheck

# View server logs
docker logs --tail 50 authentik-server

# Check outpost endpoint directly
curl -s http://localhost:9100/outpost.goauthentik.io/auth/caddy
# Should return 200 (when called from localhost)

# Test from Caddy's perspective (from CT103)
curl -s -o /dev/null -w '%{http_code}' \
  -H "Host: auth.edmd.me" \
  -H "X-Forwarded-Host: grafana.edmd.me" \
  -H "X-Forwarded-Proto: https" \
  -H "X-Forwarded-Uri: /" \
  http://192.168.8.100:9100/outpost.goauthentik.io/auth/caddy
# Should return 401 (unauthenticated) or 302 (redirect to login)
Gotchas & Lessons Learned

These are the issues encountered during deployment (May 2026) so they don’t bite again:

  1. Brand domain must match external URL. Authentik’s brand/tenant domain field must be set to auth.edmd.me. The default is authentik-default which causes 404s on the outpost.

  2. AUTHENTIK_HOST env var is required. Without it, the embedded outpost doesn’t know its external URL and can’t construct redirect URLs. Set it in docker-compose, not just the outpost config.

  3. Provider mode matters. forward_single requires one provider per service and matches on the Host header. forward_domain handles all subdomains with one provider using a cookie domain β€” use this for *.edmd.me.

  4. Restart after config changes. The outpost caches its configuration. After changing brand domain, provider mode, or outpost config via API, always docker restart authentik-server.

  5. Port 9100 not 9000. Authentik listens on 9000 internally, but we map to 9100 externally because Roon Server uses 9100-9200 on CT105 (no conflict since different container, but avoids confusion with the standard port).

  6. PostgreSQL via host IP. The authentik containers run on a custom bridge network (authentik). They can’t resolve other containers by name on the default bridge. Use CT100’s host IP 192.168.8.100 for PostgreSQL since port 5432 is published on all interfaces.

  7. Don’t add header_up Host to forward_auth. In forward_domain mode, the outpost needs to see the original Host header to know which domain is being accessed. Caddy’s forward_auth correctly passes it through.

Currently Protected Services
Service URL Status
Grafana grafana.edmd.me / metrics.edmd.me βœ… Protected

To protect additional services, add the forward_auth block from the Caddy Integration section to their Caddy handle.

Recommended candidates for protection:

  • Portainer (admin access to all containers)
  • Proxmox web UI (hypervisor control)
  • N8N (automation with full system access)
  • Prometheus (infrastructure metrics)
  • Dozzle (container logs)

Services to leave unprotected:

  • Plex (has its own auth, used by Plexamp on mobile)
  • Immich (has its own auth, used by mobile app)
  • Vaultwarden β€” retired May 2026; CT104 destroyed. Credentials live in ~/Sync/ED/SECRETS.md. See Vaultwarden tombstone.
Bee Hub (This Site)

BeeDifferent Hub is a Hugo-powered personal reference site. Source lives on the Mac Studio; deploy-vps.sh builds with Hugo and pushes to two live hosts β€” the public VPS and the internal CT103.

Build pipeline (every 30 min via cron)

deploy-vps.sh runs these steps in order β€” any failure before Hugo aborts the deploy without touching the live trees:

  1. Validate TASKS.md (~/scripts/validate-tasks.sh) β€” structural check, abort on error.
  2. Validate skills (~/scripts/validate-skills.sh) β€” every SKILL.md frontmatter parses.
  3. Regenerate BEE_HUB_INDEX.md (scripts/build-index.py) β€” walks content/, emits the ~76 KB greppable map at ~/Sync/ED/BEE_HUB_INDEX.md. Also writes content/recent/_index.md with the 40 most recently-edited pages, sorted by mtime descending.
  4. Refresh the home-page status block (scripts/build-status.py) β€” reads ~/Sync/ED/.homelab-snapshot.json, TASKS.md, the doc-sync logs, and the deploy log, then patches the <!-- STATUS-START -->/<!-- STATUS-END --> block in content/_index.md with current state (last deploy, briefing freshness, doc-sync status, active task count, ZFS pool capacities, container counts).
  5. Build Ed’s dashboard (content/ed/build-ed-page.sh) β€” pulls latest briefing + diary + active tasks into content/ed/_index.md.
  6. Hugo build in strict mode (--panicOnWarning --printPathWarnings) β€” aborts deploy on any error (unclosed shortcode, frontmatter parse, missing partial). Previous stale public/ survives if the new build fails.
  7. Pagefind indexes public/ into public/pagefind/ β€” drives the search box in the top-right.
  8. rsync to VPS + CT103 β€” only if all the above succeeded.
Conventions for new pages
  • Frontmatter: title: required; subtitle: if it’s a section landing; page_links: for top-of-page nav (label + url, plus external: true for off-site).
  • Sidebar: sections use sidebar_sections: for a flat list, or sidebar_groups: for categorized groups (each with title + items: [{url, name}]). The home page (/homelab/) demonstrates sidebar_groups.
  • Section shortcode: wrap discrete content blocks in section shortcodes with an id and title. The id becomes the anchor; the title is what shows in the sidebar’s section list.
  • Mermaid diagrams: fenced mermaid blocks render on the client. Use sparingly β€” see /homelab/architecture/ for an example.
  • Taxonomies (new): tags: [foo, bar] and status: active|retired|planned in frontmatter generate /tags/<name>/ and /status/<name>/ index pages automatically. Opt-in per page.
  • “Last substantive update”: file mtime lies (auto-commits, typo fixes bump it). When a page gets a real content change worth signaling, add a last_substantive_update: YYYY-MM-DD frontmatter field β€” future Recently Updated views can prefer that signal over mtime.
Companion files outside Hugo
Path What
~/Sync/ED/BEE_HUB_INDEX.md Greppable index β€” every page with title + summary + mtime. Regenerated on every deploy.
~/Sync/ED/homelab/bee_hub/AUDIT-2026-05-25.md Per-section progress tracker from the 2026-05-25 deep-clean. Useful baseline if a future deep-edit pass picks up.
~/Sync/ED/homelab/bee_hub/scripts/build-index.py Generates the index + recent-updated page
~/Sync/ED/homelab/bee_hub/scripts/build-status.py Generates the home-page status block
~/scripts/arr-briefing-data.py SSH-Python-heredoc helper for the daily briefing’s media-imports section
~/scripts/homelab-backup-status.py Same pattern for the backup-status section
Deployment targets

The site is deployed to two places from the same source tree. Each is independently reachable:

Host URL Files at Reachable
VPS (edge01) https://troglodyteconsulting.com /var/www/bee-hub Public internet (currently limited β€” see SSDNodes issue below)
CT103 (caddy) https://hub.edmd.me /var/www/bee-hub LAN + NetBird peers only

Deployment is one command and updates both in sequence. If one target is unreachable (VPS routing is occasionally flaky), the other still updates.

Deploy script
cd ~/Sync/ED/homelab/bee_hub
bash deploy-vps.sh

What it does:

  1. hugo --quiet builds the static site into public/
  2. tar czf - public/ streams the build to each target
  3. Extracts to /var/www/bee-hub/ on each host
  4. Logs to ~/Library/Logs/bee-hub-deploy.log

Automatic deploys: cron runs the script every 30 minutes.

*/30 * * * * /Users/bee/Sync/ED/homelab/bee_hub/deploy-vps.sh

Targets are configured in the script at TARGETS=(...). To add a new target, add user@host:/path to the array. SSH keys are pre-shared via ~/.ssh/id_ed25519.

Editing

All content is plain Markdown with YAML frontmatter. Edit any _index.md file and save. The next deploy (manual or the 30-min cron) ships the change to both live hosts.

Item Value
Site root ~/Sync/ED/homelab/bee_hub/
Content ~/Sync/ED/homelab/bee_hub/content/
Theme themes/bee-theme/
Config hugo.toml
Build output public/ (gitignored, rebuilt every deploy)
Deploy script deploy-vps.sh
Deploy log ~/Library/Logs/bee-hub-deploy.log
Local Preview Server

A persistent Hugo dev server runs on the Mac Studio via launchd, providing live-preview at all times (rebuilds within ~1s on save):

Item Value
URL (local) http://localhost:1313
URL (LAN) http://192.168.8.180:1313
launchd agent com.bee.hugo-beehub
Plist ~/Library/LaunchAgents/com.bee.hugo-beehub.plist
Working dir ~/Sync/ED/homelab/bee_hub
Logs /tmp/hugo-beehub.log, /tmp/hugo-beehub.err
Behavior RunAtLoad + KeepAlive (always running, restarts on crash)

This is separate from the deployed versions at hub.edmd.me and troglodyteconsulting.com. Use it for active authoring and verification; deploy-vps.sh pushes to production.

# Restart the local preview server
launchctl kickstart -k gui/$(id -u)/com.bee.hugo-beehub

# Check status
launchctl print gui/$(id -u)/com.bee.hugo-beehub

# View logs
tail -f /tmp/hugo-beehub.log
Site Structure

Every page is a directory containing an _index.md file. The top-level nav is defined in hugo.toml:

Weight Tab URL
1 Mac Apps /mac-apps/
2 Terminal /terminal/
3 System Settings /system-settings/
4 Menu Bar /menu-bar/
5 Claude /claude/
6 Homelab /homelab/
7 Farm /farm/
8 Automation /automation/
9 Mac Studio /mac-studio/
10 Tana /tana/
11 Meshtastic /meshtastic/
12 Docs /docs/

Content tree (homelab section example):

content/homelab/
β”œβ”€β”€ _index.md              β€” Category landing (sidebar list)
β”œβ”€β”€ proxmox/_index.md      β€” Proxmox VE reference
β”œβ”€β”€ services/_index.md     β€” Services directory
β”œβ”€β”€ music-pipeline/_index.md
β”œβ”€β”€ life-archive/_index.md
β”œβ”€β”€ mcp-servers/_index.md
β”œβ”€β”€ bee-hub/_index.md      β€” This page
└── shelfmark/_index.md
Frontmatter Reference

Category landing page β€” has a sidebar with sub-page links:

title: "Homelab"
subtitle: "Infrastructure, services, and remote access"
sidebar_sections:
  - { url: "/homelab/proxmox/", name: "Proxmox VE" }
sidebar_links:
  - { name: "External Link", url: "https://example.com" }

Individual page β€” has quick-access link buttons at top:

title: "Music Pipeline"
page_links:
  - { label: "Lidarr", url: "http://192.168.8.100:8686", external: true }
  - { label: "Internal Link", url: "/homelab/services/", external: false }
Theme Shortcodes

The bee-theme provides shortcodes for structuring content. All shortcode files live in themes/bee-theme/layouts/shortcodes/.

Shortcode Purpose Parameters
section Collapsible content block id, title
app-card App info card with links title, page, docs, shortcuts, cheatsheet
app-grid Grid wrapper for app-cards none
cmd Styled terminal command block none (content is the command)
grid Two-column grid layout none
tip Tip/note callout box none

The app-card shortcode supports a docs parameter that renders a πŸ“– Docs β†— link directly on the card β€” already used throughout the Mac Apps pages.

Adding & Editing Pages

Edit an existing page: Open content/section/page/_index.md in any editor (BBEdit, VS Code, Obsidian). Hugo hot-reloads within ~1 second.

Add a new page:

mkdir -p ~/Sync/ED/homelab/bee_hub/content/homelab/new-page
# Create _index.md with frontmatter and content
# Add to parent _index.md sidebar_sections list

Add a new nav tab:

  1. Create content/new-section/_index.md
  2. Add entry to hugo.toml with a name, url, and weight
  3. Restart the Hugo service β€” menu changes need a restart, content changes do not

Can I edit in Obsidian? Yes β€” all Hugo content is plain Markdown. Open ~/Sync/ED/homelab/bee_hub/content/ as an Obsidian vault. The YAML frontmatter block must stay intact. Hugo shortcodes show as raw text in Obsidian’s preview but edit safely.

Rebuilding manually

For day-to-day edits, deploy-vps.sh handles builds automatically.

To regenerate the static /public/ folder without deploying:

cd ~/Sync/ED/homelab/bee_hub
hugo
# Output goes to /public/
Brave Bookmarks Sync

A small Python script keeps a edmd.me Services folder in Brave’s bookmarks bar automatically synced with the services defined in the CT103 Caddyfile.

Why
Every time a service gets added to the Caddyfile, we want it in the bookmarks bar without manual work. The Caddyfile is the source of truth β€” if it’s routed through Caddy, it should be a bookmark.
How it works

The script reads /Users/bee/tmp-immich-stack/caddy-ct103-Caddyfile, extracts every @matcher host ... entry, and rebuilds a flat alphabetical list inside a folder named edmd.me Services in Brave’s bookmarks bar.

Item Value
Script /Users/bee/tmp-immich-stack/sync-bookmarks.py
Reads /Users/bee/tmp-immich-stack/caddy-ct103-Caddyfile
Writes ~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Bookmarks
Backup Same dir, .bak.YYYYMMDD-HHMMSS files
Folder name edmd.me Services
Structure Flat alphabetical list (aliases included as separate entries)

The script quits Brave before editing (Brave overwrites the file in memory), makes a timestamped backup, removes any existing folder with that name, rebuilds it, and relaunches Brave.

Running it
# Normal run (quits Brave, edits, relaunches)
python3 /Users/bee/tmp-immich-stack/sync-bookmarks.py

# Dry run β€” show what would happen without touching anything
python3 /Users/bee/tmp-immich-stack/sync-bookmarks.py --dry-run

# Edit without relaunching (useful for cron)
python3 /Users/bee/tmp-immich-stack/sync-bookmarks.py --no-restart
Scheduling

To keep bookmarks auto-updated without manual runs, add to crontab -e:

0 */6 * * * /usr/bin/python3 /Users/bee/tmp-immich-stack/sync-bookmarks.py --no-restart >> ~/Library/Logs/sync-bookmarks.log 2>&1

That runs every 6 hours. The --no-restart flag means Brave isn’t interrupted β€” the bookmarks file is edited in place and Brave picks up the changes on its next read (typically the next launch, or within a minute for the current session).

Adding a new service
  1. Add the @matcher host newservice.edmd.me + handle @matcher block to /Users/bee/tmp-immich-stack/caddy-ct103-Caddyfile
  2. Push to CT103: scp caddy-ct103-Caddyfile root@192.168.8.221:/tmp/ && ssh root@192.168.8.221 'pct push 103 /tmp/caddy-ct103-Caddyfile /etc/caddy/Caddyfile && pct exec 103 -- systemctl reload caddy'
  3. Add the Cloudflare A record (script at /Users/bee/tmp-immich-stack/add-cf-records.sh handles multiple at once)
  4. Run python3 /Users/bee/tmp-immich-stack/sync-bookmarks.py β€” or let the cron catch it within 6 hours
Implementation notes
  • Brave’s bookmark file is Chrome’s format β€” this same script works unchanged against Chrome/Chromium/Arc with only the path changed
  • Brave assigns its own guid and checksum values on load, so the script doesn’t generate them β€” it lets Brave rebuild them
  • IDs are allocated by scanning the entire JSON for the max existing ID and incrementing from there
  • Timestamps use Chrome’s “microseconds since 1601-01-01 UTC” format β€” the script converts Unix epoch to this
  • If you manually rearrange/delete items in the folder, the next sync run will wipe your changes and rebuild from the Caddyfile. The folder is machine-managed β€” use other bookmark folders for ad-hoc bookmarks
Caddy Reverse Proxy

Two Caddy deployments: the VPS (edge01) for public web serving, and CT103 (caddy) on Proxmox for internal *.edmd.me reverse proxying with wildcard HTTPS.

Architecture
VPS Caddy CT103 Caddy (internal)
Host edge01 (172.93.50.184) CT103 @ 192.168.8.54
Role Public web server Private reverse proxy
Domains troglodyteconsulting.com *.edmd.me (41 services)
Cert issuance HTTP-01 (port 80) DNS-01 via Cloudflare
Reachable from Public internet LAN + NetBird peers only
Installed April 19, 2026 April 19, 2026

Both use the same custom Caddy build: v2.11.2 + github.com/caddy-dns/cloudflare module, built via xcaddy.

Internal Services (CT103 β€” *.edmd.me)

Every LAN service has a clean HTTPS URL with real Let’s Encrypt certs. Accessible from any device on the LAN or via NetBird mesh; DNS A records in Cloudflare point *.edmd.me β†’ 192.168.8.54.

Media & Arr Stack β€” plex.edmd.me, calibre.edmd.me, lidarr.edmd.me, sonarr.edmd.me, radarr.edmd.me, prowlarr.edmd.me, bookshelf.edmd.me, audiobookshelf.edmd.me (alias: audiobooks.edmd.me), navidrome.edmd.me (alias: music.edmd.me), kiwix.edmd.me (alias: wiki.edmd.me)

Automation & Monitoring β€” n8n.edmd.me, kuma.edmd.me (alias: uptime.edmd.me), gotify.edmd.me (alias: notify.edmd.me), grafana.edmd.me (alias: metrics.edmd.me), prometheus.edmd.me, dozzle.edmd.me (alias: logs.edmd.me)

Reading & Content β€” freshrss.edmd.me (alias: rss.edmd.me), wallabag.edmd.me (alias: read.edmd.me), immich.edmd.me (alias: photos.edmd.me), shelfmark.edmd.me, aurral.edmd.me

Utilities β€” convertx.edmd.me (alias: convert.edmd.me), flaresolverr.edmd.me, homepage.edmd.me (alias: home.edmd.me)

Infrastructure Admin β€” portainer.edmd.me, proxmox.edmd.me (aliases: hpve.edmd.me, pve.edmd.me), cockpit.edmd.me, pihole.edmd.me (alias: dns.edmd.me)

Identity & Auth β€” auth.edmd.me (Authentik SSO β€” forward-domain auth for all services above)

Total: 42 service URLs (26 primary hostnames + 16 aliases).

CT103 Caddyfile structure

Uses a single wildcard site block *.edmd.me with named matchers (@service) and handle directives. One wildcard cert covers every subdomain, so adding a new service is three lines:

@newservice host newservice.edmd.me
handle @newservice {
    reverse_proxy 192.168.8.100:PORT
}

Then add an A record newservice.edmd.me β†’ 192.168.8.54 in Cloudflare and systemctl reload caddy. Cert is already covered by the existing wildcard.

Multi-host matchers use space-separated hostnames (not commas β€” Caddy treats the comma as part of the name):

@kuma host kuma.edmd.me uptime.edmd.me

Backends with self-signed certs (Portainer on 9443, Proxmox on 8006, Cockpit on 9090) require tls_insecure_skip_verify:

reverse_proxy https://192.168.8.100:9443 {
    transport http {
        tls_insecure_skip_verify
    }
}

Installed April 19, 2026 on edge01 VPS (172.93.50.184). Replaces Pangolin/Traefik as the VPS’s reverse proxy and public web server.

Overview

Caddy is the single reverse proxy fronting public-facing services on the VPS. It handles:

  • Automatic HTTPS β€” cert issuance and renewal via Let’s Encrypt. No certbot, no cron jobs, no manual work forever.
  • Static file serving β€” hosts the Bee Hub at troglodyteconsulting.com.
  • Reverse proxy β€” routes subdomains to LAN services via NetBird mesh (once NetBird is set up).
  • HTTP/3 support β€” out of the box on port 443/udp.
  • Cloudflare DNS-01 challenge β€” for wildcard certs on *.edmd.me (via the custom build with the Cloudflare DNS module).
Version v2.11.2
Custom build Yes (xcaddy + github.com/caddy-dns/cloudflare)
Binary /usr/bin/caddy
Config /etc/caddy/Caddyfile
Env file /etc/caddy/cloudflare.env (CF_API_TOKEN)
Data dir /var/lib/caddy/
Web root /var/www/bee-hub/
Service systemctl {status,reload,restart} caddy
Logs journalctl -u caddy
How automatic HTTPS works

When you add a site block to the Caddyfile:

n8n.troglodyteconsulting.com {
    reverse_proxy 192.168.8.100:5678
}

On systemctl reload caddy, Caddy:

  1. Notices the domain is public and has no cert yet
  2. Contacts Let’s Encrypt via ACME
  3. Proves ownership β€” HTTP-01 challenge (default) or DNS-01 (for wildcards)
  4. Receives and installs the cert
  5. Starts serving HTTPS on 443
  6. Redirects HTTP β†’ HTTPS automatically

All of that in seconds. Renewals (at 30 days remaining) happen silently in the background. You never touch certs again.

What Caddy handles forever:

  • Initial cert request
  • Automatic renewal
  • OCSP stapling
  • Fallback from Let’s Encrypt to ZeroSSL if LE is down
  • Modern TLS (1.3, correct ciphers, HSTS)
  • Certificate hot-reload without dropping connections
Caddyfile structure

Every site block is independent. The starter Caddyfile looks like:

{
    # Global options
    email doctor@edwarddelgrosso.com
}

# Main Bee Hub site β€” static files
troglodyteconsulting.com, www.troglodyteconsulting.com {
    root * /var/www/bee-hub
    file_server
    encode gzip zstd
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}

# Subdomain reverse-proxy (requires NetBird mesh)
# n8n.troglodyteconsulting.com {
#     reverse_proxy 192.168.8.100:5678
# }

# Wildcard via Cloudflare DNS-01 (needs CF_API_TOKEN)
# *.edmd.me {
#     tls {
#         dns cloudflare {env.CF_API_TOKEN}
#     }
#     @lidarr host lidarr.edmd.me
#     handle @lidarr { reverse_proxy 192.168.8.100:8686 }
# }

# Catch-all 404 for unknown Host headers
:80, :443 {
    respond "Not configured" 404
}

Adding a new service is three lines:

newservice.troglodyteconsulting.com {
    reverse_proxy 192.168.8.100:PORT
}

Then: systemctl reload caddy. Cert issued within seconds, service live.

Cloudflare DNS-01 (wildcard certs)

For *.edmd.me wildcard certs, Caddy uses DNS-01 challenge via Cloudflare’s API.

  1. API Token lives in /etc/caddy/cloudflare.env as CF_API_TOKEN=... (mode 600, caddy:caddy ownership)
  2. Scope β€” Edit zone DNS on both edmd.me and troglodyteconsulting.com zones
  3. Referenced in Caddyfile as {env.CF_API_TOKEN} inside the tls block:
*.edmd.me {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    # ... site blocks ...
}

Creating a new token β€” dash.cloudflare.com/profile/api-tokens, pick “Edit zone DNS” template, select the target zones.

Rotation β€” after replacing the token, systemctl reload caddy picks up the new env file.

Operations

Test config before reload (always):

caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

Reload (graceful, no dropped connections):

systemctl reload caddy

Watch cert issuance in real time:

journalctl -u caddy -f | grep -iE 'cert|acme|tls'

List loaded modules (including cloudflare):

caddy list-modules | grep -iE 'cloudflare|dns'

Certificates stored in: /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/

Common troubleshooting:

Symptom Likely cause Fix
HTTP 404 on domain but DNS resolves Site block missing from Caddyfile Add block, reload
Cert issuance fails Port 80 blocked OR DNS hasn’t propagated Check UFW, dig the domain
{env.CF_API_TOKEN} is empty cloudflare.env not loaded by systemd Check systemd unit EnvironmentFile= directive
Reload silently fails Syntax error in Caddyfile caddy validate first
Slow first request after reload Cert being issued Check journal; second request is fast
Things to know
  • No certbot, ever. Caddy manages all ACME interactions internally. Any certbot tutorial you find online does not apply.
  • Port 80 must be open for HTTP-01 challenge. UFW allows it on edge01.
  • Cloudflare proxy (orange cloud) is OK β€” DNS-01 doesn’t require the domain to resolve directly to the VPS. HTTP-01 requires it though, so prefer DNS-01 when Cloudflare proxy is on.
  • Caddyfile syntax is indentation-loose but block braces matter. Always caddy validate before reload.
  • Env vars in Caddyfile use {env.VAR_NAME} syntax β€” note the dot separator, not underscore.
  • Reverse-proxying to LAN services (192.168.8.x) only works over NetBird. Without the mesh, the VPS can’t reach LAN IPs.
  • HTTP/3 works out of the box once port 443/udp is open in UFW. No extra config.
  • systemctl reload vs restart β€” always prefer reload. Restart drops in-flight connections; reload does graceful handoff.
  • Deploy from Mac uses /Users/bee/Sync/ED/homelab/bee_hub/deploy-vps.sh β€” now targeting root@172.93.50.184:/var/www/bee-hub. Cron runs every 30 min.
  • Cloudflare token rotation after any suspected exposure. Template: Edit zone DNS, both zones.
Why Caddy (vs nginx / Traefik)
Caddy nginx + certbot Traefik
Cert mgmt Automatic, zero config Manual (certbot + cron) Automatic
Config lines per site ~3 ~15-20 YAML, more verbose
Systemd unit 1 2 (nginx + certbot.timer) 1
HTTP/3 Out of the box Requires build flags Config flag
Docker-native No (but doesn’t need to be) No Yes, via labels
Best for Self-hosted reverse proxy, mixed static + reverse-proxy Heavy custom rewrite rules, fine-grained caching Docker Compose stacks with many services

Chose Caddy because edge01 is primarily a reverse proxy for a handful of LAN services plus the static Bee Hub site. Caddy’s zero-config HTTPS eliminates the certbot-renewal maintenance burden that plagued the previous Traefik/Pangolin setup.

Claude Multi-Machine Setup

Full-parity Claude experience across Mac Studio (192.168.8.180) and MacBook (192.168.8.218). Both machines run Claude Desktop with Cowork mode, the same MCP servers, same skills, same context, same memory. Established 2026-05-19.

Architecture Overview

The goal: open a Cowork session on either machine and get the same experience β€” same skills, same MCPs, same context bundle, same memory graph, same tasks. No “Claude doesn’t know what I’m talking about” on the MacBook.

Six layers that must stay in sync:

Layer What Where Sync Method
1. Claude Code config Plugin list, marketplaces, MCP registrations ~/.claude/settings.json Syncthing claude-config
2. Claude Desktop config Desktop-app MCP servers (tana, firecrawl, farm-data) ~/Sync/ED/config/claude_desktop_config.json (symlinked) Syncthing claude-ed
3. Working directory Context bundle, TASKS.md, SECRETS.md, scripts, homelab docs ~/Sync/ED/ Syncthing claude-ed
4. MCP server code Custom Python/Node MCP servers ~/.mcp-servers/ Syncthing mcp-servers
5. MCP Python venvs Per-server virtual environments ~/.mcp-servers/*/. venv/ launchd auto-rebuild
6. Cowork skills snapshot Cached skill definitions Cowork reads at session start ~/Library/Application Support/Claude/.../skills-plugin/ sync-cowork-snapshot.sh

Layers 1–5 are fully automatic. Layer 6 requires running sync-cowork-snapshot.sh on the Mac Studio after editing skills β€” it pushes to the MacBook if reachable.

Syncthing Folder Configuration

Four Syncthing folders handle the Mac-to-Mac sync (all bidirectional, no Proxmox relay):

Folder ID Path .stignore Notes
claude-config ~/.claude/ 19 patterns (sessions, cache, telemetry, etc.) Settings, plugins, Claude Code MCP config
claude-ed ~/Sync/ED/ None Context bundle, tasks, secrets, skills source, memory, homelab docs
mcp-servers ~/.mcp-servers/ .venv, __pycache__, node_modules, .DS_Store, *.pyc MCP server source code only β€” venvs excluded
farm-data-sync ~/Sync/farm/ None Emlid exports, voice memos

Device IDs:

Device Syncthing ID
Mac Studio UXJMRP2-N2ZX2B7-KWI6OO2-IT7W5LC-GESRIJC-JV2DGTD-FEND4MO-F46LPAS
MacBook VLIWDBL-5VD3VSC-XQTOXYS-EGU3NSB-BCHQOML-ACRYBI3-VFT2SPR-WDNBEAF

API keys (for programmatic Syncthing management):

Device API Key
Mac Studio CCuJcwA9wTsfDecNXtymtZwfpQvYWAU7
MacBook WY4Wrco6VnUsYJLzyDa5djTMni4fYz4L
Claude Desktop Config (Symlink)

The Claude Desktop app reads its MCP server configuration from ~/Library/Application Support/Claude/claude_desktop_config.json. To keep this in sync, the actual file lives in the Syncthing’d directory and both machines symlink to it:

~/Library/Application Support/Claude/claude_desktop_config.json
  β†’ ~/Sync/ED/config/claude_desktop_config.json  (symlink)

This file defines three MCP servers:

Server Command Notes
tana-local HTTP to 127.0.0.1:8262 Only works if Tana app is running locally
firecrawl npx -y firecrawl-mcp Cloud API, works anywhere
farm-data python3 ~/.mcp-servers/farm-data/server.py Needs network access to CT100 PostgreSQL

All paths use /Users/bee/.mcp-servers/ (with leading dot). Both machines have the same username (bee) so paths are identical.

MCP Servers

All custom MCP servers live in ~/.mcp-servers/. Source code syncs via Syncthing; venvs are built locally per-machine.

Server Type Dependencies Purpose
farm-data Python asyncpg, mcp[cli] PostgreSQL farm database queries
homelab-snapshot Python fastmcp Proxmox/Docker infrastructure status
structured-bash Python fastmcp Shell execution with structured argv (no quoting hell)
nws-weather Python mcp[cli], httpx National Weather Service forecasts
inaturalist Python mcp[cli], httpx Species identification via iNaturalist API
usda-plants Python mcp[cli], httpx USDA plant database lookups
wallabag Python mcp[cli], httpx Read-later article management
tidal-mcp Python (uv) β€” Tidal music service
cron-validator β€” β€” Cron expression validation
diff-preview β€” β€” Diff preview tool
git-semantic β€” β€” Git semantic search
image-gen β€” β€” Image generation
json-schema β€” β€” JSON Schema validation
mermaid-render β€” β€” Mermaid diagram rendering

Python version requirement: MCP SDK requires Python 3.10+. The MacBook’s system Python is 3.9 (Xcode). All venvs are built with /opt/homebrew/bin/python3 (currently 3.14).

Venv auto-rebuild (MacBook only):

A launchd agent watches ~/.mcp-servers/ and rebuilds venvs when Syncthing delivers new code:

Setting Value
Plist ~/Library/LaunchAgents/com.bee.rebuild-mcp-venvs.plist
Script ~/scripts/rebuild-mcp-venvs.sh
Trigger WatchPaths on ~/.mcp-servers/
Throttle 300 seconds (max once per 5 minutes)
Log ~/.mcp-servers/.venv-rebuild.log

The script only rebuilds venvs that are missing or have a requirements.txt newer than the existing .venv/. It maps server names to their dependencies (farm-data β†’ asyncpg, homelab-snapshot β†’ fastmcp, etc.).

Cowork Skills

Skills source lives in ~/Sync/ED/skills/ (synced via claude-ed). The plugin is registered as bee-farm-skills in ~/.claude/settings.json with source type directory.

The snapshot problem: Cowork doesn’t read skills from the source directory at session start. It reads from a per-account snapshot cached at:

~/Library/Application Support/Claude/local-agent-mode-sessions/skills-plugin/
  8d800cde-a725-4eaf-a5d9-0c44525af93d/
    3364911c-2ed9-4d54-8786-41d805a22426/
      skills/

This snapshot is keyed on stable account+plugin UUIDs and is NOT refreshed by restarting Claude Desktop or reinstalling the plugin. The only way to update it is sync-cowork-snapshot.sh.

After editing any SKILL.md:

~/scripts/sync-cowork-snapshot.sh

This script validates all skills, rsyncs from source to snapshot, and (as of 2026-05-19) pushes the snapshot to the MacBook if it’s reachable. Then open a new Cowork session on both machines.

Key Files on ~/Sync/ED/

These files are critical for Claude context and must be present on both machines:

File Purpose
.claude-context.md Deterministic session prime β€” who Ed is, preferences, conventions, active focus
TASKS.md Master task list
SECRETS.md Credential consumer map and rotation runbook
SKILLS_INDEX.md Auto-generated skills inventory
CRON_AND_SERVICES.md Automation registry summary
BEE_HUB_INDEX.md Bee Hub page lookup index
memory/knowledge.json Memory MCP knowledge graph
config/claude_desktop_config.json Shared Claude Desktop MCP config (symlinked on both machines)
skills/ All custom Cowork skills source code
Initial Setup (New Machine)

If setting up Claude on a new Mac from scratch:

  1. Install prerequisites: brew install syncthing python node
  2. Configure Syncthing: Add the four folder syncs (claude-config, claude-ed, mcp-servers, farm-data-sync) pointing to the Mac Studio
  3. Wait for initial sync to complete (check Syncthing UI)
  4. Symlink the Desktop config:
    ln -sf ~/Sync/ED/config/claude_desktop_config.json \
      ~/Library/Application\ Support/Claude/claude_desktop_config.json
    
  5. Build MCP venvs: ~/scripts/rebuild-mcp-venvs.sh
  6. Install the venv rebuild launchd agent:
    cp ~/scripts/com.bee.rebuild-mcp-venvs.plist ~/Library/LaunchAgents/
    launchctl load ~/Library/LaunchAgents/com.bee.rebuild-mcp-venvs.plist
    
  7. Push the Cowork snapshot: Run sync-cowork-snapshot.sh on the Mac Studio
  8. Restart Claude Desktop (Cmd+Q, reopen)

Alternatively, run the all-in-one setup script:

bash ~/Sync/ED/setup-macbook-claude.sh

This handles steps 4–6 automatically (assuming Syncthing is already configured).

Troubleshooting

“Claude has no MCPs” on the MacBook:

  1. Check Syncthing is running: brew services list | grep syncthing
  2. Verify ~/Sync/ED/.claude-context.md exists (Syncthing working?)
  3. Check ~/.mcp-servers/farm-data/.venv/ exists (venvs built?)
  4. Verify the Desktop config symlink: ls -la ~/Library/Application\ Support/Claude/claude_desktop_config.json
  5. Restart Claude Desktop (Cmd+Q, reopen)

Skills missing in Cowork:

  1. Check ~/Sync/ED/skills/ has content (Syncthing working?)
  2. Run sync-cowork-snapshot.sh on the Mac Studio
  3. Open a NEW Cowork session (existing sessions can’t be fixed mid-flight)

MCP server fails to start:

  1. Check the venv exists: ls ~/.mcp-servers/SERVER_NAME/.venv/
  2. Check Python version: .venv/bin/python3 --version (must be 3.10+)
  3. Rebuild: cd ~/.mcp-servers/SERVER_NAME && rm -rf .venv && ~/scripts/rebuild-mcp-venvs.sh
  4. Check network: nc -z 192.168.8.100 5432 (for farm-data β€” needs CT100 reachable)

Syncthing sync stuck:

  1. Check both Syncthing UIs for errors
  2. Verify folder IDs match on both machines
  3. Check .stignore isn’t filtering the missing files
  4. Force rescan: curl -X POST http://localhost:8384/rest/db/scan -H "X-API-Key: KEY" -d 'folder=FOLDER_ID'
History
Date Change
2026-05-12 Initial Claude sync: three subfolder Syncthing syncs (claude-config, claude-memory, claude-skills)
2026-05-19 Replaced subfolder syncs with parent claude-ed for all of ~/Sync/ED/
2026-05-19 Added mcp-servers Syncthing folder with .stignore for venvs
2026-05-19 Consolidated ~/mcp-servers/ and ~/.mcp-servers/ into ~/.mcp-servers/ on Mac Studio
2026-05-19 Symlinked claude_desktop_config.json into ~/Sync/ED/config/ for automatic sync
2026-05-19 Created rebuild-mcp-venvs.sh + launchd agent for auto venv rebuild on MacBook
2026-05-19 Updated sync-cowork-snapshot.sh to push snapshot to MacBook
2026-05-19 Fixed claude_desktop_config.json path from ~/mcp-servers/ to ~/.mcp-servers/
Content Roadmap

Planned content pipelines and data sources β€” things to build in upcoming sessions.

Status snapshot (May 2026): All four items below were “not started” as of late April. Item 2 (Maps & GIS) is now partially unblocked β€” farmdb PostGIS is live on CT100 with species, plantings, plant_observations, plant_locations, gis_features tables, and 158+ GPS points loaded via Emlid RS3. The GIS-data-into-PostGIS direction is the remaining work. Items 1, 3, and 4 are still on the shelf.


1. Podcasts in Audiobookshelf

Status: Not started β€” ready to build

Audiobookshelf already supports podcasts natively. Needs a dedicated library directory and podcast feeds configured.

Setup steps:

  1. Create podcast storage: nvmepool/podcasts dataset on Proxmox (or subdirectory of existing audiobookshelf mount)
  2. Bind mount into CT100 and add to Audiobookshelf docker-compose
  3. Create “Podcasts” library in Audiobookshelf UI
  4. Add RSS feeds β€” auto-download new episodes, configurable retention

Podcast feeds to start with (by interest area):

Beekeeping & Pollinators:

  • Beekeeping Today Podcast
  • Two Bees in a Podcast (UF/IFAS)
  • The Bee Informed Podcast
  • Pollinator Partnership

Permaculture & Farming:

  • The Permaculture Podcast
  • The Small Farms Podcast
  • Farmer to Farmer
  • No-Till Growers
  • Growing Farmers

Self-Hosting & Homelab:

  • Self-Hosted (Jupiter Broadcasting)
  • Homelab Rat
  • The Changelog
  • Linux Unplugged

Woodworking:

  • The Wood Whisperer
  • Shop Talk Live (Fine Woodworking)
  • The Woodworking Podcast

Nature & Science:

  • Ologies (Alie Ward)
  • In Defense of Plants
  • The Native Plant Podcast

Infrastructure notes:

  • Audiobookshelf mobile app supports offline podcast playback
  • Episodes auto-delete after configurable retention period
  • Can set per-feed download limits (last N episodes)
  • Podcast library is separate from audiobook library in Audiobookshelf
2. Maps & GIS Data for Farm

Status: Partial β€” PostGIS farm DB is live (Farm DB docs); downloading the external GIS sources below into it is the remaining work.

Downloadable geospatial data for the 93-acre Brownsville property (Monroe County, Ohio, Zone 6b). All sources are free. Data should be stored and eventually loaded into PostGIS for spatial queries.

Data sources:

Source What Format Size URL
USDA SSURGO Soil types, drainage class, pH, organic matter, depth to water table, flooding frequency Shapefile + tabular ~50-200 MB per county websoilsurvey.sc.egov.usda.gov
NAIP Aerial Imagery High-resolution overhead photos (1m/pixel), updated ~every 2 years GeoTIFF ~1-5 GB per county datagateway.nrcs.usda.gov
USGS Topo Maps Elevation contours, water features, roads, structures GeoPDF / GeoTIFF ~50-100 MB per quad store.usgs.gov
Ohio LiDAR Precise elevation data (sub-meter), terrain modeling LAS / LAZ point cloud ~1-10 GB per county gis.ohio.gov
FEMA Flood Maps Floodplain boundaries, flood zones Shapefile Small msc.fema.gov
Ohio LBRS Property parcels, boundaries, ownership Shapefile ~10-50 MB per county County auditor or gis.ohio.gov
USGS NHD Streams, rivers, watersheds, water bodies Shapefile ~100-500 MB per HUC usgs.gov/nhd
NRCS Plant Data Native plant species lists by state/county CSV / API Small plants.usda.gov

How this fits the architecture:

  • Download GIS data β†’ Store on nvmepool
  • Load into PostGIS (when farm DB is built)
  • Overlay with planting zones, sensor locations
  • Query: “what soil type is under Zone 3?”
  • Grafana GeoJSON panels for visual maps

Storage plan: Create nvmepool/gis dataset or store under nvmepool/sync/ED/farm/gis/. Estimated total: 5-15 GB depending on resolution choices.

Immediate value even before PostGIS:

  • Soil survey tells you pH, drainage, and organic matter by location β€” critical for planting decisions
  • Aerial imagery gives you a basemap for planning bed layouts and infrastructure
  • LiDAR reveals drainage patterns and slope β€” important for erosion control and irrigation
3. Video Courses in Plex

Status: Not started β€” needs Plex library + sourcing strategy

Plex can serve educational video content in a dedicated library separate from movies/TV.

Setup steps:

  1. Create nvmepool/courses dataset on Proxmox
  2. Bind mount into CT100 β†’ /mnt/courses
  3. Add “Courses” library in Plex (type: Other Videos or Movies)
  4. Organize by topic: courses/Woodworking/Course Name/, courses/Permaculture/Course Name/, etc.

Content areas:

Woodworking: Hand tool techniques, joinery, finishing, lathe turning, carving, shop setup and tool maintenance.

Permaculture & Land Management: Permaculture Design Certificate (PDC) courses, keyline design, swale construction, food forest establishment, holistic land management.

Beekeeping: Beginning to advanced beekeeping, queen rearing, splits, swarm management, integrated pest management, honey processing and value-added products.

Homesteading / Self-Sufficiency: Fruit tree grafting and pruning, mushroom cultivation, fermentation and food preservation.

Sourcing: Manual acquisition β€” video courses aren’t handled well by automated tools like the *arr stack. Download, organize into folder structure, drop into the courses library. Plex serves them with metadata, progress tracking, and multi-device playback.

Naming convention:

/mnt/courses/
  Woodworking/
    Hand Tool Essentials/
      01 - Sharpening Fundamentals.mp4
      02 - Basic Joinery.mp4
  Permaculture/
    PDC Course Name/
      01 - Introduction to Permaculture.mp4
4. Kiwix Library Expansion (Bonus)

Status: Low priority β€” easy to do anytime

Current Kiwix has only wikipedia_en_simple_all_nopic_2026-02.zim (922 MB). The Biggest/Kiwix mount has plenty of space.

High-value ZIMs to add:

ZIM Size Why
English Wikipedia (full, with images) ~97 GB Complete offline Wikipedia
Stack Overflow ~50 GB Programming reference
iFixit ~3 GB Repair guides for equipment
WikiHow ~8 GB Practical how-to guides
Project Gutenberg ~70 GB 60,000+ free books
Khan Academy ~30 GB Math, science, computing courses
TED Talks ~20 GB Lectures and talks

Download from library.kiwix.org. Drop ZIMs into Biggest/Kiwix/, the cron watcher auto-restarts Kiwix to pick them up.

Cron Jobs & Scheduled Tasks

All cron jobs, launchd agents, and persistent systemd services across every machine. Updated May 12, 2026.

Cron error routing: All cron scripts are wrapped with cron-gotify-wrapper.sh which captures stderr and pushes errors to Gotify at priority 5 (β†’ Telegram). MAILTO="" is set in both hpve and CT100 crontabs to prevent local mail delivery.

Health check script updated Apr 18: now monitors nvmepool, Biggest, backups, offsite (was referencing retired pool names). CWA cleanup cron re-added to CT100.


Mac Studio (192.168.8.180)

User Crontab (crontab -e as bee)

Schedule Command Purpose
*/30 * * * * /Users/bee/Sync/ED/homelab/bee_hub/deploy-vps.sh Build Hugo site and rsync public/ to VPS nginx β€” every 30 min

Homebrew Services

Service Purpose Port
syncthing Peer-to-peer file sync β€” hub-and-spoke via Proxmox 22000 (sync), 8384 (UI, localhost only)

launchd Agents (~/Library/LaunchAgents/)

Persistent services (RunAtLoad: true, KeepAlive: true):

Plist Script/Binary Port Purpose
com.beedifferent.embed-server ~/Sync/ED/life_archive/embed_server.py 1235 gte-Qwen2-7B embedding server on MPS
com.beedifferent.hugo-hub /opt/homebrew/bin/hugo server 1313 Bee Hub docs site on LAN
com.beedifferent.life-archive-api ~/Sync/ED/life_archive/http_api.py 8900 Life Archive RAG FastAPI
com.beedifferent.life-archive-mcp-http ~/Sync/ED/life_archive/mcp_server_http.py 8901 Life Archive MCP HTTP server

Scheduled skills (Mac Studio ~/Sync/ED/skills/, managed via Claude Cowork) β€” use StartCalendarInterval, no KeepAlive:

Plist Schedule Script Purpose
com.bee.browser-history Daily 5:30 AM ~/Sync/ED/skills/browser-history/scripts/analyze.py --days 90 Snapshot Brave history β†’ ranked site report
com.bee.mac-inventory Weekly Sun 6:00 AM ~/Sync/ED/skills/mac-inventory/scripts/inventory.py Snapshot Homebrew, casks, App Store, launchd, login items
com.bee.skills-index Daily 5:45 AM ~/Sync/ED/skills/scripts/generate_index.py Walk ~/Sync/ED/skills/, regenerate SKILLS_INDEX.md
com.edmd.check-telegram-bridge Every 30 min /Users/bee/Scripts/check-telegram-bridge.sh Check Gotify-Telegram bridge health via SSH; send iMessage alert to Ed if bridge is failing, recovery message when restored
com.bee.doc-sync Daily 3:00 AM ~/Sync/ED/skills/doc-sync/scripts/run.sh Read previous day’s Claude conversations, compare against TASKS.md + memory + .claude-context.md, patch them, send email summary via msmtp then Gotify. Auth: Reads ~/.config/anthropic-api-key (chmod 600, no trailing newline, no -n prefix β€” the script defensively strips -n since manual echo -n corrupted the file on 2026-05-24); Step 0 fail-fast precheck hits /v1/models and exits clean with a Gotify alert if 401 (instead of burning 5+ minutes on a 1 MB prompt)
com.bee.mcp-health-check 6Γ—/day (06:15, 09:15, 12:15, 15:15, 18:15, 21:15) ~/scripts/check-mcp-health.py Scan each MCP’s log in ~/Library/Logs/Claude/mcp-server-<name>.log for Server transport closed unexpectedly, connect-timeout, fetch-failed, [error] lines in the last 6 hours. Gotify-alert on any failure. State log at ~/Library/Logs/mcp-health-check.log
com.bee.pull-homelab-config Daily 3:00 AM git -C ~/homelab-config pull --ff-only Pulls homelab-config repo (cloned from pve over SSH) to keep the Mac-side mirror fresh for git-semantic MCP queries. Pve writes the canonical copy via collect-configs.sh at 02:45
com.bee.rebuild-mcp-venvs WatchPath on ~/.mcp-servers/ (MacBook-side mostly) ~/scripts/rebuild-mcp-venvs.sh When MCP server source files change via Syncthing, walk ~/.mcp-servers/, rebuild any .venv whose requirements.txt is newer than .venv/bin/python3. Prefers python3.14β†’3.13β†’3.12β†’3.11β†’3.10β†’3
com.bee.homelab-snapshot Every few minutes ~/.mcp-servers/homelab-snapshot/snapshot.py Writes ~/Sync/ED/.homelab-snapshot.json with current zfs/docker/disk state. Read this file directly for state; faster than the MCP for known-stale-OK queries
com.bee.copy-life-archive Daily 9 AM (self-disabling) rsync wrapper Copy life_archive_backup to Biggest pool. Self-disables after a successful run
launchctl list | grep -E 'beedifferent|com.bee'
launchctl kickstart -k gui/$(id -u)/com.beedifferent.hugo-hub
launchctl kickstart -k gui/$(id -u)/com.bee.skills-index

Proxmox VE Host (192.168.8.221)

Root Crontab

Schedule Command Purpose
*/15 * * * * system-health-check.sh Disk, ZFS, backup, USB monitoring β†’ Gotify (wrapped: stderr β†’ Gotify pri 5)
0 1 * * * backup-nvmepool-nightly.sh rsync nvmepool β†’ Biggest/nvmepool-backup (wrapped: stderr β†’ Gotify pri 5)
45 2 * * * collect-configs.sh Nightly git backup of all homelab configs to GitHub (wrapped). Fixed May 2026: PATH and SSH key issues
0 3 1 * * zpool scrub Biggest Monthly scrub β€” 1st of month
0 3 8 * * zpool scrub nvmepool Monthly scrub β€” 8th
0 3 22 * * zpool scrub mediapool Monthly scrub β€” 22nd

Removed / moved:

  • sync-mac.sh (daily 2am) β€” removed 2026-04-13, was failing with rsync error 12
  • sync-seedbox.sh (every 2 min) β€” removed 2026-04-13
  • seedbox-sync.sh (every 15 min) β€” moved to CT100 crontab
  • books-ingest-pipeline.sh (every 15 min) β€” moved to CT100 crontab
  • Lidarr MissingAlbumSearch (daily 4 AM) β€” removed

Systemd Services

Service Purpose
syncthing@root.service File sync hub β€” UI at :8384
netbird.service NetBird mesh VPN β€” replaces Pangolin
transmission-tunnel.service SSH tunnel :13010 β†’ seedbox Transmission RPC β€” required for *arr app download management

Removed / inactive:

  • nzbget-tunnel.service β€” SSH tunnel :16789 β†’ seedbox NZBGet (moved to CT100 as autossh-nzbget)
  • nzbhydra2-tunnel.service β€” SSH tunnel :15076 β†’ seedbox NZBHydra2
  • deluge-tunnel.service β€” SSH tunnel :58846 β†’ seedbox Deluge (SSL cert issue, never used)
  • qbit-tunnel.service β€” SSH tunnel :18080 β†’ seedbox qBittorrent (Squid proxy blocks HTTP, never used)

ZFS Auto-Snapshots (Proxmox built-in)

Frequency Keep
Every 15 min 4
Hourly 24
Daily 31
Weekly 8
Monthly 12

Proxmox vzdump Backup

Daily 2:00 AM, all VMs/CTs, snapshot mode, zstd compression, 3 copies retained β†’ /backups/dump


CT 100 β€” Docker Host (192.168.8.100)

Root Crontab

Schedule Command Purpose
*/5 * * * * kiwix-watcher.sh Kiwix content watcher
*/5 * * * * check-socks-tunnel.sh Monitor SOCKS5 tunnel health
*/15 * * * * seedbox-sync.sh Pull from seedbox β€” nzbget Music+Books + general complete (moved from hpve)
*/15 * * * * books-ingest-pipeline.sh Move book files from seedbox ingest β†’ CWA ingest folder (moved from hpve)
*/30 * * * * beet-full-pipeline.sh Beets pipeline: import, tag, art (wrapped)
30 2 * * * backup-farmdb.sh FarmDB pg_dump backup (wrapped)
30 2 * * * backup-all-dbs.sh Backup all PostgreSQL databases
0 4 * * 0 docker system prune -a -f Weekly Docker cleanup β€” removes unused images, containers, networks. Logs to /var/log/docker-prune.log
0 5 * * * clean-cwa-processed.sh Clean CWA processed_books older than 7 days (wrapped)

Systemd Services

Service Purpose
docker.service Docker runtime for all containers
autossh-socks.service autossh SOCKS5 :1080 β†’ seedbox (NL exit) β€” legacy, replaced by WireGuard for most apps but still running
autossh-transmission.service autossh tunnel :13010 β†’ seedbox Transmission RPC β€” required for *arr app download management
autossh-nzbget.service autossh tunnel :16789 β†’ seedbox NZBGet β€” required for Usenet downloads
wg-quick@wg0.service WireGuard tunnel to UltraCC NL β€” 45.86.221.26:13012. All Docker outbound exits via this tunnel. Persists boot via /etc/iptables/rules.v4 (kill-switch + MSS clamp). Added Apr 29 2026. See WireGuard tunnel

SMB Shares (Proxmox, all in /etc/samba/smb.conf)

Share Path Access
Review /Biggest/Maple read-write
Sync /nvmepool/sync read-only
Music /nvmepool/music read-write
Books /nvmepool/books read-write
Movies /nvmepool/movies read-write
Video /nvmepool/video read-write
Media Staging /Biggest/media-staging read-write
backups /backuppool read-only
nvmepool-backup /Biggest/nvmepool-backup read-only
Possible Delete /Biggest/Possible Delete read-write

All shares: valid users = bee, no registry shares (migrated 2026-04-13).


Cowork Scheduled Tasks (Claude Desktop)

Managed in the Claude Desktop app under Settings β†’ Scheduled Tasks. These run as Cowork sessions on the Mac Studio.

Task Schedule Purpose
daily-briefing Daily 4:00 AM Morning briefing β€” media imports, homelab health (via homelab-snapshot MCP + ~/Sync/ED/.homelab-snapshot.json fallback), seedbox queue, farm DB changes, backups (via ~/scripts/homelab-backup-status.py), weather (NWS), Uptime Kuma summary, dictation items, diary, tasks. Delivered to ~/Sync/ED/{todays,morning}-briefing.md + email via msmtp + Drafts note. *arr data via ~/scripts/arr-briefing-data.py (one clean SSH-Python-heredoc call instead of inline shell-string chains)
claude-email-review Hourly Scan Gmail for actionable items, draft responses, flag urgent messages. Uses Gmail MCP connector (OAuth β€” may need re-auth periodically)
process-dictation Hourly Transcribe new JPR voice recordings (Whisper large-v3 via mlx-whisper), parse dash-commands, extract tasks/observations/diary. Date-keyed by recording start time (from JPR folder name), never script-run time. No-content placeholder when run produces no narrative
tana-mac-contacts-sync Weekly Sun 8:00 AM Sync Mac Contacts ↔ Tana #contact nodes β€” Tana wins conflicts
calendar-duplicate-cleanup Daily 7:07 AM Scan all Google Calendars for duplicate events (fuzzy venue-name normalization for Dawes / Licking Park District / trailing parens), delete extras

Gmail connector auth: The claude-email-review task uses an OAuth connector that expires periodically. When it fails, the Gotify-Telegram bridge sends an iMessage fallback alert. Re-authenticate in Claude Desktop β†’ Settings β†’ Connectors β†’ Gmail.


Quick Reference β€” All Schedules

Time Machine Job
Every 5 min CT 100 kiwix-watcher.sh
Every 5 min CT 100 check-socks-tunnel.sh β€” SOCKS5 tunnel health monitor
Every 15 min Proxmox system-health-check.sh
Every 15 min CT 100 seedbox-sync.sh β€” pull from seedbox (moved from hpve)
Every 15 min CT 100 books-ingest-pipeline.sh β€” seedbox books β†’ CWA (moved from hpve)
Every 30 min CT 100 beet-full-pipeline.sh
Every 30 min Mac Studio deploy-vps.sh β€” hugo build + push
Every 30 min Mac Studio com.edmd.check-telegram-bridge β€” Telegram bridge health monitor + iMessage fallback
Hourly Proxmox ZFS hourly snapshot (keep 24)
Daily 1:00 AM Proxmox backup-nvmepool-nightly.sh
Daily 2:00 AM Proxmox vzdump β€” backup all VMs/CTs to Biggest/backups
Daily 2:30 AM CT 100 backup-farmdb.sh β€” pg_dump farmdb (Gotify wrapped)
Daily 2:30 AM CT 100 backup-all-dbs.sh β€” backup all PostgreSQL databases
Daily 2:45 AM Proxmox collect-configs.sh β€” git backup all configs to GitHub (wrapped)
Daily 3:00 AM Mac Studio backup-farmdb-gdrive.sh β€” pull dump, push to Google Drive
Daily 3:00 AM Mac Studio com.bee.doc-sync β€” Claude conversation drift detector
Daily 3:00 AM Mac Studio com.bee.pull-homelab-config β€” git pull –ff-only ~/homelab-config
Daily 4:00 AM Cowork daily-briefing β€” morning briefing
Daily 5:00 AM CT 100 clean-cwa-processed.sh
Daily 5:30 AM Mac Studio com.bee.browser-history β€” Brave history snapshot
Daily 5:45 AM Mac Studio com.bee.skills-index β€” regenerate SKILLS_INDEX.md
6Γ—/day at :15 Mac Studio com.bee.mcp-health-check β€” scan MCP logs for transport-closed / errors β†’ Gotify
Daily 7:07 AM Cowork calendar-duplicate-cleanup β€” dedupe Google Calendar
Hourly Cowork claude-email-review β€” Gmail scan + draft responses
Hourly Cowork process-dictation β€” voice notes β†’ structured tasks
Weekly Sun 4:00 AM CT 100 docker system prune -a -f β€” weekly Docker cleanup
Weekly Sun 6:00 AM Mac Studio com.bee.mac-inventory β€” installed-software snapshot
Weekly Sun 8:00 AM Cowork tana-mac-contacts-sync β€” sync Mac Contacts ↔ Tana #contact nodes
Daily Proxmox ZFS daily snapshot (keep 31)
Weekly Proxmox ZFS weekly snapshot (keep 8)
1st of month Proxmox zpool scrub Biggest
8th of month Proxmox zpool scrub nvmepool
22nd of month Proxmox zpool scrub mediapool
Monthly Proxmox ZFS monthly snapshot (keep 12)
Directus

Discovered already running and configured Apr 30, 2026. All 34 farm tables registered as collections with icons and display templates.

Summary
URL https://directus.edmd.me
Local http://192.168.8.100:8055
Container directus/directus:latest on CT100
Database farmdb on 192.168.8.100:5432 (Directus stores its own metadata in directus_* tables in the same DB)
Login doctor@edwarddelgrosso.com (changed from delgross@ on Apr 30)
Admin password Stored in container env ADMIN_PASSWORD β€” rotate and move to Vaultwarden
Volumes /mnt/container-data/directus/{uploads,extensions,database}
Why Directus

Directus connects to your existing PostgreSQL tables and presents them as editable collections in a polished admin UI. Unlike NocoDB, which is more spreadsheet-shaped, Directus is more form/admin-shaped β€” better for:

  • Editing one record at a time with proper field types
  • Managing relationships between tables (foreign keys auto-detected)
  • File uploads attached to records
  • Role-based access if you want to give helpers read-only or limited-edit access

It does NOT replace your data β€” it sits on top of farmdb. The Farm MCP server, direct psql, and any other tool see the same tables and the same edits.

Configured Collections

All 34 farm tables registered. Top-level collections have proper icons, colors, display templates, and sort orders configured via the Directus API on Apr 30:

Collection Icon Display Template
species local_florist (green) {{common_name}} ({{scientific_name}})
plantings yard (dark green) {{species_id.common_name}} β€” {{location_id.name}}
harvests agriculture (yellow) {{planting_id.species_id.common_name}} β€” {{harvest_date}}
hives hive (amber) {{name}}
hive_inspections inventory (amber) {{hive_id.name}} β€” {{inspection_date}}
locations place (brown) {{name}}
suppliers store (blue) {{name}}
inventory inventory_2 (blue) (default)
equipment build (gray) (default)
observations visibility (purple) sorted by observation_date
orders shopping_cart (blue) sorted by order_date
order_items list (blue) (default)
collections collections (magenta) {{name}}
species_collections link (magenta) (default)
species_images image (green) (default)

The 19 species_* type-specific subtables (annual, aquatic, berry, biennial, bulb, cover_crop, fern, fruit_tree, grass, groundcover, herb, mushroom, nut_tree, perennial, rose, shrub, tree, vegetable, vine) are grouped under “species” in the navigation sidebar with the category icon, so they collapse together and don’t dominate the nav.

Caveats
  • Two UIs editing the same data: Directus and NocoDB both edit farmdb. Don’t use them long-term simultaneously β€” pick one after a few days of comparison.
  • directus_* system tables in farmdb: 30 internal tables sit alongside your 14 farm tables. Mostly harmless but visible in any direct psql session.
  • Bootstrap email is in container env: ADMIN_EMAIL=delgross@edwarddelgrosso.com is in the compose env but only matters for first-run user creation. The actual user record was updated to doctor@edwarddelgrosso.com via API and is durable. If the database is ever wiped, the env var would recreate with the old email.
Security
The ADMIN_PASSWORD and DIRECTUS_KEY are exposed in the container’s environment variables. Both are also in chat history. Both should be rotated and moved to Vaultwarden.
Farm β€” Brownsville

The Farm is on its own network (192.168.0.x), connected back to home (192.168.8.x) via NetBird mesh. Farm Proxmox (fpve) is a NetBird peer and acts as the subnet router for 192.168.0.0/24.

⚠️ Status (late May 2026): fpve has been disconnected from NetBird since 2026-05-24 and the farm LAN is currently unreachable from home β€” even pve (home Proxmox) can’t ping fpve.netbird.cloud. Either the farm network itself or fpve is down; needs physical / farm-LAN access to diagnose. The ha-mcp MCP is wired correctly but blocked on this outage. The Uptime Kuma monitor for ha-mcp at the farm is parked pending fpve coming back.

Network

Router/Switch: TP-Link Omada ER8411 (router) + Omada Controller at 192.168.0.2. ISP: Starlink in Bypass Mode; native IPv6 delegated via DHCPv6-PD (/56) to Omada.

Device IP URL Notes
Omada Gateway 192.168.0.1 β€” Router (unchangeable)
Omada Controller 192.168.0.2 omada.edmd.me Site: “Bee Different”
Farm Pi-hole (CT102) 192.168.0.5 pihole-farm.edmd.me LAN + NetBird DNS
Farm Docker-Host (CT100) 192.168.0.6 fportainer.edmd.me Portainer, Uptime-Kuma, Gotify
Tempest Weather 192.168.0.8 β€” WeatherFlow Tempest
Home Assistant 192.168.0.10 ha.edmd.me Smart home automation
Farm Caddy (CT103) 192.168.0.54 β€” Reverse proxy for *.edmd.me
Farm Proxmox (fpve) 192.168.0.191 fpve.edmd.me Hypervisor; NetBird peer

All of these have DHCP reservations in Omada (Settings β†’ Wired Networks β†’ LAN β†’ Address Reservation).

Farm Proxmox (fpve)

Host: 192.168.0.191 (also reachable via NetBird mesh as fpve.netbird.cloud / 100.123.49.175).

LXCs:

VMID Name IP Services
100 docker-host 192.168.0.6 Portainer, Uptime-Kuma, Gotify
102 pihole 192.168.0.5 Pi-hole DNS
103 caddy 192.168.0.54 Caddy reverse proxy (*.edmd.me)

SSH: ssh root@192.168.0.191 (on LAN) or ssh root@100.123.49.175 (via NetBird).

NetBird Mesh

Replaced Pangolin on April 19, 2026. Current peers (8 total): hpve, fpve, vps, studio, macbook, iphone, ipad, roon (CT105). See Remote Access for the full peer table with NetBird IPs.

Subnet routes (NetBird dashboard β†’ Networks):

  • 192.168.0.0/24 β†’ via fpve (farm LAN)
  • 192.168.8.0/24 β†’ via hpve (home LAN)

Distribution group: BeeDifferent (contains all peers allowed cross-site access).

Check fpve status:

ssh root@192.168.0.191 "netbird status"

Restart if needed:

ssh root@192.168.0.191 "systemctl restart netbird"
Docker Services (CT 100 β€” 192.168.0.6)
Service Port URL
Portainer 9443 https://192.168.0.6:9443 β€” fportainer.edmd.me
Uptime Kuma 3001 http://192.168.0.6:3001 β€” fkuma.edmd.me
Gotify 8070 http://192.168.0.6:8070 β€” fgotify.edmd.me
Home Assistant

HA is the automation hub for the Farm β€” smart plugs, sensors, the Tempest weather station, Zigbee/Z-Wave devices, and Reolink cameras.

Local URL http://192.168.0.10:8123
Pretty URL ha.edmd.me
mDNS homeassistant.local:8123

Zigbee/Z-Wave coordinators (SLZB over Ethernet):

Device Role Notes
SLZB-MRW10U (house) Zigbee + Z-Wave Multiradio; Z-Wave firmware is prototype/non-certified
SLZB-06P7 (outbuilding) Zigbee TCP :6638
SLZB-06P7-2 (outbuilding) Zigbee TCP :6638
Sunkown1 (sic) Zigbee TCP :6638
IPv6

IPv6 is fully enabled end-to-end at the farm.

  • Starlink delegates a /56 to Omada (currently 2605:59ca:2b5f:d300::/56)
  • Omada LAN advertises SLAAC+RDNSS for 2605:59ca:2b5f:d300::/64 on vmbr0-equivalent
  • fpve picks up a global v6 address via RA

⚠️ Proxmox sysctls are NOT persistent across reboots. After any fpve reboot, manually run:

sysctl -w net.ipv6.conf.vmbr0.accept_ra=2
sysctl -w net.ipv6.conf.vmbr0.autoconf=1
sysctl -w net.ipv6.conf.vmbr0.accept_ra_defrtr=1
sysctl -w net.ipv6.conf.vmbr0.accept_ra_pinfo=1

Or add to /etc/sysctl.d/99-ipv6.conf to make persistent.

Property Notes
Size 93 acres
Location Brownsville, Ohio β€” Licking County, Zone 6b
Coordinates 39.947Β°N 82.256Β°W (ZIP 43721)
Primary goal Pollinator paradise β€” ecological, agricultural, and conservation development
Beekeeping Active hives on property
Weather station Tempest (at 192.168.0.8) + Davis Vantage Pro 2 (“Orchard Weather”)

Infrastructure projects: solar-powered PoE for remote sensors, Meshtastic nodes for off-grid communication coverage across the property.

FreshRSS

Self-hosted RSS feed reader with full-text article retrieval. Running on Proxmox CT 100 (docker-host).

Overview

FreshRSS aggregates RSS/Atom feeds into a single web-based reader. The instance runs as a Docker container on CT 100 alongside a Readability service for full-text content extraction. Feeds are checked every 30 minutes via built-in cron.

Setting Value
URL http://192.168.8.100:8180
Host CT 100 (192.168.8.100)
Image freshrss/freshrss:latest
Port 8180 β†’ 80
Timezone America/New_York
Cron Minutes 1 and 31 (every 30 min)
User delgross
Docker Compose

Compose file lives at /opt/freshrss/docker-compose.yml on CT 100. Both services share the freshrss_default network so FreshRSS can reach the Readability container by hostname.

services:
  freshrss:
    image: freshrss/freshrss:latest
    container_name: freshrss
    restart: unless-stopped
    ports:
      - "8180:80"
    volumes:
      - freshrss_data:/var/www/FreshRSS/data
      - freshrss_extensions:/var/www/FreshRSS/extensions
    environment:
      - TZ=America/New_York
      - CRON_MIN=1,31

  readability:
    image: phpdockerio/readability-js-server
    container_name: readability
    restart: unless-stopped

volumes:
  freshrss_data:
  freshrss_extensions:

Volumes:

Volume Container path Purpose
freshrss_data /var/www/FreshRSS/data User config, SQLite database, logs
freshrss_extensions /var/www/FreshRSS/extensions All installed extensions
Full-Text Retrieval

By default, many RSS feeds only include a truncated summary. FreshRSS uses the Article Full Text (Af_Readability) extension to fetch the complete article content from the original website when new articles arrive. This runs the Fivefilters Readability.php library directly inside the FreshRSS container β€” no external service required.

How it works:

  1. FreshRSS fetches a feed and finds new articles (every 30 min via cron)
  2. For each new article in an enabled category, the extension makes an HTTP request to the article’s original URL
  3. Readability.php extracts the main article content, stripping navigation, ads, and other clutter
  4. The extracted full text replaces the truncated summary in FreshRSS

Configuration: All 5 categories are enabled for full-text extraction. The config is stored in /var/www/FreshRSS/data/users/delgross/config.php as:

'ext_af_readability_categories' => '{"1":true,"2":true,"3":true,"4":true,"5":true}',

Important notes:

  • Only applies to new articles fetched after the extension is enabled. Existing truncated articles are not retroactively updated.
  • Some websites rate-limit rapid requests. The extension fetches each article URL sequentially without delay, so high-volume feeds from a single site may occasionally have missing content.
  • To reprocess old articles for a feed: delete the feed’s articles in FreshRSS, then let the next cron cycle refetch them.
Readability Service (Backup)

A standalone readability-js-server container is also deployed alongside FreshRSS. This is used by the xExtension-Readable extension (installed but not currently active) and provides an alternative full-text extraction backend via a Node.js API.

Setting Value
Container readability
Image phpdockerio/readability-js-server
Internal URL http://readability:3000 (accessible from FreshRSS on same Docker network)
External port None (internal only)

This service is available if you want to switch from the built-in Readability.php library to the external readability-js parser. To activate: enable the Readable extension in FreshRSS settings, set the Readability Host to http://readability:3000, and select which feeds/categories to process.

Installed Extensions

Extensions are stored in the freshrss_extensions Docker volume (host path: /var/lib/docker/volumes/freshrss_freshrss_extensions/_data/).

Extension Status Description
Af_Readability βœ… Enabled Full-text article extraction using Fivefilters Readability.php β€” no external service needed. Applied to all categories.
Clickable Links βœ… Enabled Makes URLs in article content clickable.
Title-Wrap βœ… Enabled Wraps long article titles in the feed list.
Readable Installed (not enabled) Alternative full-text extraction via external Readability/Mercury/FiveFilters services. Available as a backup β€” uses the readability container.
ArticleSummary Installed (not enabled) AI-powered article summarization via OpenAI-compatible APIs.
Kagi Summarizer Installed (not enabled) Summarize articles using Kagi Universal Summarizer.
ThreePanesView Installed (not enabled) Three-column layout with article preview pane.
YouTube Video Feed Installed (not enabled) Displays YouTube videos inline in feeds.
User CSS Installed (not enabled) Custom CSS styling for the FreshRSS UI.

To install new extensions, clone or copy the extension folder into the extensions volume on CT 100:

cd /var/lib/docker/volumes/freshrss_freshrss_extensions/_data/
git clone https://github.com/<author>/<extension>.git
Feed Inventory
Category Feed
Bees Bee Culture Magazine
Gardening Regenerative Flower Farming Blog
Gardening The Spruce Β· Home Design Ideas and How Tos
Tech MakeUseOf
Uncategorized FreshRSS releases
Uncategorized New in Feedly
Woodworking Popular Woodworking

Total: 7 feeds across 5 categories.

Maintenance

Updating FreshRSS:

cd /opt/freshrss
docker compose pull
docker compose up -d

Backup: User data (config, database, extension settings) lives in the freshrss_data named volume. Back up /var/lib/docker/volumes/freshrss_freshrss_data/_data/ before major updates.

OPML export: FreshRSS supports OPML import/export from the web UI under Settings β†’ Import/Export. Feed URLs remain clean (no rewriting) since full-text retrieval is handled by the extension, not URL manipulation.

Adding full-text to a new category: If you add a new category in FreshRSS, update the ext_af_readability_categories value in /var/www/FreshRSS/data/users/delgross/config.php (inside the container) to include the new category ID, or toggle it on from the extension’s configuration page in the FreshRSS web UI under Settings β†’ Extensions β†’ Article Full Text.

HA-MCP (Home Assistant MCP)

⚠️ Status (late May 2026): ha-mcp is currently unreachable because fpve has been disconnected from NetBird since 2026-05-24. The wiring below is correct; the blocker is the farm-network outage β€” even pve (home Proxmox) can’t ping fpve.netbird.cloud. The MCP itself loaded fine after Claude Desktop restart; it just can’t reach 192.168.0.10. See Farm β€” Brownsville for the outage details.

Overview

The ha-mcp add-on runs inside Home Assistant OS and exposes 85 tools to Claude Desktop via the Model Context Protocol. Claude can query devices, control entities, manage automations, inspect integrations, and more β€” all without leaving the chat.

  • Add-on: addon_81f33d0f_ha_mcp (v7.5.0)
  • URL: http://192.168.0.10:9583/private_<secret-path>
  • Auth model: Secret-path-in-URL (no Bearer token). The secret path is generated on first install and persists at /data/secret_path.txt inside the add-on container across restarts.
  • Tools: 85 (discovery, device/integration mgmt, state control, CRUD, templates, traces, history, addons, HACS, system, blueprints, camera, todo, bundled docs)
Claude Desktop Config

The config block lives in ~/Sync/ED/config/claude_desktop_config.json (symlinked to ~/Library/Application Support/Claude/claude_desktop_config.json on both Mac Studio and MacBook).

"ha-mcp": {
  "command": "npx",
  "args": [
    "-y", "mcp-remote",
    "http://192.168.0.10:9583/private_<secret-path>",
    "--transport", "http-only",
    "--allow-http"
  ]
}

Critical: The --allow-http flag is required because mcp-remote refuses non-HTTPS URLs without it. This was the blocker on initial MacBook setup (2026-05-23).

Network Topology
Claude Desktop (Mac Studio / MacBook)
  β†’ NetBird mesh VPN
  β†’ fpve.netbird.cloud (100.123.49.175)
     routes 192.168.0.0/24 (farm LAN)
  β†’ Home Assistant at 192.168.0.10:9583

HA does not run NetBird itself. Reachability from any NetBird peer goes through fpve’s advertised route for the 192.168.0.0/24 subnet. Both Mac Studio and MacBook must have that route enabled in their NetBird client (NetBird app β†’ Routes β†’ confirm 192.168.0.0/24 is selected).

On the farm LAN (MacBook at Brownsville), HA is reachable directly at 192.168.0.10:9583 β€” no NetBird needed.

Secret Rotation

To rotate the secret path:

  1. SSH into HA: ssh ha (user delgross, port 22, key ~/.ssh/ha_id_ed25519)
  2. Delete the secret file inside the add-on container (path varies by supervisor version)
  3. Restart the add-on β€” it regenerates a new secret path
  4. Update claude_desktop_config.json with the new URL on both machines
  5. Restart Claude Desktop on both Studio and MacBook

Full consumer map entry in ~/Sync/ED/SECRETS.md.

Troubleshooting

Claude Desktop log: ~/Library/Logs/Claude/mcp-server-ha-mcp.log

Common issues:

  • “HTTPS required” error β†’ missing --allow-http flag in config args
  • Connection refused β†’ NetBird route for 192.168.0.0/24 not enabled, or HA is down
  • Timeout β†’ fpve (farm Proxmox) is offline or NetBird mesh not converged
  • 403 / auth error β†’ secret path changed; check /data/secret_path.txt on HA

Smoke test prompt: “List the areas in Home Assistant” β€” should return 13 areas (Barn, Garage, Kitchen, etc.)

Home Network

Updated April 29, 2026 β€” after OPNsense cutover. Subnet: 192.168.8.0/24.

Router β€” OPNsense (orchard.edmd.me)

Replaced the GL.iNet GL-MT3000 (Beryl AX) on April 29, 2026 with OPNsense 25.7.11_9 on dedicated x86 hardware. See the OPNsense page for full configuration.

Hostname orchard.edmd.me
LAN IP 192.168.8.1
WAN IP 192.168.254.65 (double-NATted behind ISP gateway)
DHCP server Dnsmasq (range .150–.242)
LAN DNS Unbound (port 53) β€” forwards edmd.me to Pi-hole CT102
Web UI https://192.168.8.1 β€” root only, key-auth from Mac Studio

WAN double-NAT (ISP gateway β†’ OPNsense WAN at 192.168.254.65) blocks IPv6 and inbound UDP 51820 forwarding to NetBird. Both upstream limitations, not OPNsense’s.

Infrastructure
IP Device Hostname Notes
192.168.8.1 OPNsense Router orchard.edmd.me Replaces GL.iNet GL-MT3000 (retired Apr 29 2026)
192.168.8.53 CT102 Pi-hole pihole DNS + ad blocking, NetBird nameserver
192.168.8.54 CT103 Caddy caddy Internal *.edmd.me reverse proxy
192.168.8.100 CT100 Docker docker-host All Docker services (Sonarr/Radarr/Plex/etc.)
192.168.8.103 CT101 Immich immich Photo management
192.168.8.180 Mac Studio (Ethernet) Mac-Studio.lan Static reservation; MAC 1C:1D:D3:E1:A1:EC (10GbE en0)
192.168.8.221 Proxmox VE (hpve) hpve Hypervisor β€” runs all CTs above
192.168.8.245 Weather Station Orchard-Weather.lan Local weather monitor

DHCP range: .150–.242. Mac Studio is the only static reservation.

DNS architecture
LAN client
    β”‚
    β–Ό
OPNsense Unbound (192.168.8.1:53)
    β”œβ”€ edmd.me β†’ forwards to Pi-hole (192.168.8.53)
    └─ everything else β†’ public roots (with DoT)

NetBird peer (mesh)
    β”‚
    β–Ό
NetBird magic DNS (100.123.255.254)
    └─ forwards everything to Pi-hole (192.168.8.53)

Pi-hole (CT102, 192.168.8.53)
    β”œβ”€ ad blocking (StevenBlack list)
    β”œβ”€ short names (hpve, portainer, immich, etc.)
    β”œβ”€ *.edmd.me β†’ 192.168.8.54 (Caddy)  [via /etc/dnsmasq.d/03-edmd-wildcard.conf]
    └─ everything else β†’ Cloudflare (1.1.1.1) + Quad9

The OPNsense β†’ Pi-hole forward was added to Unbound on Apr 29 2026 to handle clients that query the router directly (some Mac/iOS DNS resolvers do). Without this, those queries would fail because Unbound’s <privateaddress> filter rejects RFC1918 answers from public DNS β€” see the Pi-hole docs.

Docker Services on CT100
Service Port Public URL
Plex 32400 plex.edmd.me
Calibre-Web 8083 calibre.edmd.me
Sonarr 8989 sonarr.edmd.me
Radarr 7878 radarr.edmd.me
Lidarr 8686 lidarr.edmd.me
Prowlarr 9696 prowlarr.edmd.me
Shelfmark 8084 shelfmark.edmd.me
Audiobookshelf 13378 audiobookshelf.edmd.me
Navidrome 4533 navidrome.edmd.me
Bookshelf 8787 bookshelf.edmd.me
FreshRSS 8180 freshrss.edmd.me
Wallabag 8480 wallabag.edmd.me
Immich 2283 (CT101) immich.edmd.me
Kiwix 8380 kiwix.edmd.me
Portainer 9443 portainer.edmd.me
Uptime Kuma 3001 kuma.edmd.me
Gotify 8070 gotify.edmd.me
N8N 5678 n8n.edmd.me
Homepage 3000 homepage.edmd.me
Prometheus 9090 prometheus.edmd.me
Grafana 3200 grafana.edmd.me
Dozzle 9999 dozzle.edmd.me
ConvertX 3100 convertx.edmd.me
FlareSolverr 8191 flaresolverr.edmd.me

See Caddy for full URL aliases. See Services for the master directory.

CT100 outbound traffic exits via WireGuard tunnel to UltraCC NL (added Apr 29 2026). Public IP from CT100’s perspective is 45.86.221.26. The kill-switch firewall blocks all egress if the tunnel drops. See WireGuard tunnel for details.

Mac Studio Services (192.168.8.180)
Service Port URL
SSH 22 ssh bee@192.168.8.180
Screen Sharing 5900 vnc://192.168.8.180
Hugo Hub 1313 http://192.168.8.180:1313
Syncthing 8384 http://192.168.8.180:8384
Paperless-NGX 8100 http://192.168.8.180:8100
Life Archive API 8900 http://192.168.8.180:8900
Life Archive MCP 8901 http://192.168.8.180:8901/mcp
LM Studio 1234 http://192.168.8.180:1234
Embed Server 1235 http://localhost:1235 (local only)
Proxmox Services (192.168.8.221)
Service Port URL
Proxmox Web UI 8006 https://hpve.edmd.me (alias: pve, proxmox)
Cockpit 9090 https://cockpit.edmd.me
SSH 22 ssh root@192.168.8.221
SMB Share 445 \\192.168.8.221\shared (user: bee)
Syncthing 8384 http://192.168.8.221:8384
Transmission RPC (tunneled) 13010 SSH tunnel β†’ seedbox β€” required for *arr app download management

NetBird daemon runs as netbird.service on hpve, advertising 192.168.8.0/24 to the mesh.

Eero Mesh WiFi (5 nodes)
IP Node Hostname
192.168.8.140 eero (main) eero.lan
192.168.8.123 eero #2 eero-d066.local
192.168.8.203 eero #3 eero-3y3p.local
192.168.8.212 eero #4 eero-f8js.local
192.168.8.169 eero #5 eero-kchd.local

Eeros run in bridge mode behind OPNsense β€” they handle WiFi only, OPNsense handles routing/DHCP/DNS.

Entertainment & Audio
IP Device
192.168.8.115 Sonos Speaker
192.168.8.202 Sonos Speaker (Living Room)
192.168.8.116 YouTube TV (Google TV Streamer)
192.168.8.141 WiiM Ultra
192.168.8.233 TiVo Stream 4K
Smart Home
IP Device
192.168.8.224 Homey (smart home hub)
192.168.8.245 Weather Station (Orchard-Weather)
Printers
IP Device
192.168.8.190 Brother HL-L3280CDW (Office)
192.168.8.240 Brother HL-L2460DW
VPS β€” edge01
Public IP external (SSDNodes)
NetBird IP 100.123.69.155
Role Public Caddy for troglodyteconsulting.com; NetBird mesh peer
Access ssh admin@<vps-ip> (LAN/NetBird-side admin)

Pangolin was retired Apr 19 2026 and replaced with NetBird mesh. The VPS no longer hosts a Pangolin dashboard.

Farm Network β€” Brownsville (192.168.0.x)

Farm runs on 192.168.0.x (separate subnet from home’s 192.168.8.x). Connected via NetBird mesh β€” fpve peer at 192.168.0.191 advertises the 192.168.0.0/24 subnet to the mesh.

IP Device Description
192.168.0.10 Home Assistant Smart home automation
192.168.0.191 Farm Proxmox Hypervisor, NetBird peer (fpve)

See Farm for the full farm inventory.

Life Archive

Personal information retrieval system β€” 278K documents across 7 sources, queryable via RAG pipeline on Mac Studio.

Overview

Life Archive is a full RAG (Retrieval-Augmented Generation) pipeline that indexes ~278K personal records spanning decades β€” Evernote notes, emails, magazine archives, Tana nodes, and Paperless-NGX documents β€” into a searchable knowledge base. It runs entirely on the Mac Studio using local embeddings (gte-Qwen2-7B on Apple MPS) and LanceDB for vector storage.

What it answers: “What did I write about X?”, “When did I meet Y?”, “What happened during Z trip?” β€” any question against a lifetime of personal documents.

Key paths:

Path Content
~/Sync/ED/life_archive/ Project root β€” all code, configs, data
~/Sync/ED/life_archive/.venv/ Python virtual environment
~/Sync/ED/life_archive/lancedb_data/ LanceDB vector database (~50 GB)
~/Sync/ED/life_archive/knowledge_graph.db SQLite knowledge graph (~356 MB)
Architecture

Data flow:

  1. Source extraction β€” Raw documents parsed from Evernote exports, email archives, magazine PDFs, Tana JSON, Paperless-NGX API
  2. Enrichment β€” Text cleaning, section splitting, paragraph chunking, QA pair generation
  3. Embedding β€” gte-Qwen2-7B encodes text into dense vectors (local, MPS-accelerated)
  4. Storage β€” LanceDB tables for docs, sections, paragraphs, QA pairs; SQLite for knowledge graph
  5. Query β€” Multi-strategy retrieval with fusion and reranking

Retrieval strategies (all run in parallel per query):

Strategy What it does
Dense vectors Semantic similarity search against paragraph embeddings
SPLADE keywords Sparse keyword matching for exact terms
QA pairs Matches against pre-generated question-answer pairs
Knowledge graph Entity and relationship lookup
HyDE Hypothetical Document Embedding β€” generates a synthetic answer, then searches for similar real content

Results from all strategies are fused via Reciprocal Rank Fusion (RRF), then reranked with a cross-encoder model for final ordering.

Running Services

Four persistent services on Mac Studio, all managed via launchd:

Service Port launchd Label Purpose
Embed Server 1235 com.beedifferent.embed-server gte-Qwen2-7B on MPS β€” generates embeddings
Life Archive API 8900 com.beedifferent.life-archive-api FastAPI HTTP wrapper for remote queries
MCP HTTP Server 8901 com.beedifferent.life-archive-mcp-http Streamable HTTP MCP server for remote Claude clients
Paperless-NGX 8100 (manual / runserver) Document ingestion and OCR

All launchd plists are in ~/Library/LaunchAgents/.

API endpoints (port 8900):

Method Path Description
POST /search Full RAG search with all retrieval strategies
POST /entity Knowledge graph entity lookup
POST /temporal Temporal anchor search (events, dates, periods)
GET /stats Database statistics
GET /health Service health check
GET /docs Interactive Swagger UI

MCP endpoint (port 8901): http://192.168.8.180:8901/mcp β€” Streamable HTTP transport for Claude Desktop, Claude Code, or any MCP client.

Remote access:

Service Pangolin VPN Address
Life Archive API 100.96.128.19:8900
MCP HTTP Server 100.96.128.20:8901
Database Stats

LanceDB (as of 2026-03-12):

Table Rows
Documents 74,041
Paragraphs 2,689,330
Sections 714,451
QA pairs 289,356
Communities 0 (GraphRAG not run)
Total size ~63 GB

Knowledge Graph:

Table Count
Entities 276,348
Relationships 230,855
Doc-entity links 1,153,312
Assets 456,321
Temporal anchors 391,565
Entity aliases 167
Correspondents 18,385
DB size ~368 MB

Entity types: person (92,377) Β· org (85,519) Β· thing (52,346) Β· location (46,106)

Source breakdown:

Source Docs in LanceDB Notes
magazine_article 28,309 βœ“ loaded
paperless_doc 22,555 βœ“ loaded
tana_node 14,807 βœ“ loaded
evernote_pdf 5,069 βœ“ loaded
evernote_note 3,301 βœ“ loaded
epub_articles 0 vectors exist (17 GB), not yet loaded
emails 0 enriched but not embedded (157K records)
MCP Tools (Claude Integration)

The Life Archive is also available as MCP tools inside Claude Code and Cowork, enabling natural-language queries without the HTTP API.

Tool Purpose
life_archive_search Full RAG search β€” main query interface
life_archive_entity_lookup Find people, orgs, locations in the knowledge graph
life_archive_temporal_search Search for events, dates, time periods
life_archive_stats Database health and statistics
life_archive_graph_explore Deep-dive any entity β€” connections, source docs, aliases
life_archive_graph_traverse Multi-hop graph walk β€” map the neighborhood of any entity
life_archive_graph_search Find entities by name, filter by type

Two transport modes:

Transport Server Use Case
stdio mcp_server.py Local β€” spawned on demand by Claude Code/Cowork on the Mac Studio
Streamable HTTP mcp_server_http.py Remote β€” any MCP client on the network or over Pangolin VPN

Remote MCP client config (Claude Desktop / Claude Code):

"mcpServers": {
    "life-archive": {
        "url": "http://100.96.128.20:8901/mcp"
    }
}
Key Scripts

All scripts live in ~/Sync/ED/life_archive/:

Script Purpose
query.py Core query engine β€” LifeArchiveQuery class
http_api.py FastAPI HTTP wrapper
embed_server.py Embedding server (gte-Qwen2-7B on MPS)
load_lancedb.py Loads extracted data into LanceDB tables
load_knowledge_graph.py Builds SQLite knowledge graph from extracted entities
resolve_entities.py Fuzzy dedup of knowledge graph entities
retry_entity_resolution.py Retry failed entity resolution batches
eval_queries.py Evaluation framework for query quality
mcp_server.py MCP stdio server for Claude integration
mcp_server_http.py MCP streamable HTTP server for remote access (port 8901)
Manual Operations

Check service status:

launchctl list | grep beedifferent

Restart embed server:

launchctl kickstart -k gui/$(id -u)/com.beedifferent.embed-server

Restart Life Archive API:

launchctl kickstart -k gui/$(id -u)/com.beedifferent.life-archive-api

Test API health:

curl http://localhost:8900/health

Run a search via API:

curl -X POST http://localhost:8900/search \
  -H "Content-Type: application/json" \
  -d '{"query": "beekeeping notes from 2023"}'

View logs:

tail -f ~/Sync/ED/life_archive/http_api.stdout.log
tail -f ~/Sync/ED/life_archive/http_api.stderr.log

Load new data into LanceDB:

cd ~/Sync/ED/life_archive
.venv/bin/python load_lancedb.py --source <source_name>

Rebuild knowledge graph:

cd ~/Sync/ED/life_archive
.venv/bin/python load_knowledge_graph.py
Knowledge Graph API (Universal Access)

The knowledge graph is exposed as a live API that any client can query β€” Claude, Obsidian, Tana, local LLMs, browsers, scripts. Three endpoints provide entity exploration, multi-hop traversal, and search, all with source document links back to the original archive content.

Live endpoints (port 8900):

Endpoint Method Purpose
/graph/explore POST Full entity deep-dive: info, connections, source docs, aliases
/graph/traverse POST Multi-hop subgraph: walk N hops from any starting entity
/graph/search POST Find entities by name, filter by type
/docs GET Interactive Swagger UI for all endpoints

Web explorer: http://192.168.8.180:1313/kg/ β€” interactive D3.js force-directed graph backed by the live API.

Example: Explore an entity

curl -X POST http://192.168.8.180:8900/graph/explore \
  -H "Content-Type: application/json" \
  -d '{"entity": "thomas brown", "max_connections": 20, "max_sources": 5}'

Returns: entity info, all connections with relationship labels, source documents with titles and summaries, total document count.

Example: Traverse the graph (2 hops from Colorado)

curl -X POST http://192.168.8.180:8900/graph/traverse \
  -H "Content-Type: application/json" \
  -d '{"entity": "colorado", "depth": 2, "max_per_hop": 15}'

Returns: full subgraph of nodes and edges reachable within N hops. Each node tagged with hop distance from root.

Example: Search entities

curl -X POST http://192.168.8.180:8900/graph/search \
  -H "Content-Type: application/json" \
  -d '{"query": "brown", "entity_type": "person", "limit": 10}'

MCP tools (same functionality): life_archive_graph_explore, life_archive_graph_traverse, life_archive_graph_search β€” available via stdio and HTTP MCP servers. Any Claude session or MCP-compatible LLM can call these.

Client compatibility:

Client How to connect
Claude (Code/Cowork) MCP tools β€” already registered, just ask in natural language
Local LLM (LM Studio, etc.) Point MCP client at http://192.168.8.180:8901/mcp
Obsidian HTTP API via Templater/Dataview, or Obsidian notes export (export_kg_obsidian.py)
Tana API integration to /graph/explore endpoint
Browser Swagger UI at /docs or web explorer at /kg/
Scripts curl / Python requests / any HTTP client

Key files:

File Purpose
graph_api.py Shared graph traversal logic (KnowledgeGraphAPI class)
http_api.py FastAPI HTTP endpoints (port 8900)
mcp_server.py MCP stdio server with graph tools
mcp_server_http.py MCP HTTP server with graph tools (port 8901)
export_kg_obsidian.py Export KG to Obsidian vault as markdown notes with wikilinks
export_kg_d3.py Export KG to JSON for D3.js visualization
Knowledge Graph Visualization

The knowledge graph can be exported to GEXF format for interactive exploration in Gephi or Cosmograph.

Export script: ~/Sync/ED/life_archive/export_kg_gexf.py

Pre-built exports (in ~/Sync/ED/life_archive/exports/):

File Nodes Edges Size Use case
life_archive_kg_full.gexf 276K 231K 173 MB Full graph β€” Gephi or Cosmograph
life_archive_kg_top5000.gexf 5K 38K 13 MB Curated β€” best for first exploration

Color scheme:

Entity Type Color
Person Blue
Organization Red
Location Green
Thing Yellow
Concept Purple

Node sizes scale logarithmically by mention count.

Viewing in Gephi:

  1. Install: brew install --cask gephi
  2. File β†’ Open β†’ choose a .gexf export
  3. Layout β†’ ForceAtlas 2 β†’ Run (let settle 30–60 sec) β†’ Stop
  4. Appearance β†’ Nodes β†’ Color β†’ Partition β†’ entity_type
  5. Statistics β†’ Modularity β†’ Run β†’ then color by modularity class to see communities
  6. Use Data Laboratory tab to search/filter entities by name

Viewing in Cosmograph:

  1. Go to cosmograph.app
  2. Drag and drop the .gexf file
  3. WebGL renders instantly β€” supports the full 276K-node graph

Custom exports:

cd ~/Sync/ED/life_archive

# Only people and orgs
python3 export_kg_gexf.py --types person org

# Entities mentioned 5+ times
python3 export_kg_gexf.py --min-mentions 5

# Top 10,000 by mention count
python3 export_kg_gexf.py --top 10000
Current Status & Pending Work

Snapshot is from late March 2026; the contextual re-embedding work in particular has been quiet β€” check ~/Sync/ED/TASKS.md for any newer status before acting on the pending items.

Item Status
LanceDB loaded βœ“ 74K docs, 2.69M paragraphs
Knowledge graph βœ“ 276K entities, 231K relationships
Services running βœ“ API :8900, MCP :8901, Embed :1235
Eval baseline βœ“ 1.91/3.0 avg quality (2026-03-15)
epub_articles in LanceDB βœ— Vectors exist, not loaded
Emails embedded βœ— 157K records deferred
Contextual re-embedding ⚠️ Pending β€” RunPod run needed

Contextual re-embedding is the most important pending item. All existing embeddings were generated without document-level context prefixed to chunks. New runpod_embed.py adds this (35-50% retrieval improvement). Previous RunPod run (2026-03-17 to 2026-03-21) failed at source 3/7 with OOM. Scripts fixed 2026-03-21 β€” ready for new pod.

See ~/Sync/ED/TASKS.md for step-by-step next actions.

Remaining Work
Task Status Notes
Entity resolution Done 37 groups merged (7 original + 30 via Claude Sonnet), 177 aliases
Graph API + traversal Done /graph/explore, /graph/traverse, /graph/search + MCP tools
Email body embedding Deferred 157K email bodies not yet embedded (headers indexed)
Evaluation set Framework ready eval_queries.py exists, needs execution
Rule-based query routing Planned Replace LLM router with deterministic rules
New Paperless doc extraction Planned Process recently ingested 1,115 Evernote imports
MakeMKV

Installed Apr 30, 2026 to handle BDMV folder rips that Radarr can’t import.

Summary
URL https://makemkv.edmd.me
Local http://192.168.8.100:5800
Container jlesage/makemkv:latest on CT100
Compose /opt/makemkv/docker-compose.yml
Config /mnt/container-data/makemkv/config
Output /mnt/container-data/makemkv/output
Source /mnt/seedbox/movies (mounted read-only)
License 30-day eval mode β€” no key registered
Why this exists

Some Blu-Ray rips arrive as BDMV folder structures rather than .mkv files. Radarr can’t import these β€” it only handles single-file containers. MakeMKV converts BDMV folders to .mkv files that Radarr can ingest.

Workflow:

  1. BDMV folder lands in /mnt/seedbox/movies/ (e.g. BLENDED/, Inherit.the.Wind.1960.MULTi.COMPLETE.BLURAY-OLDHAM/)
  2. Open MakeMKV web UI: File β†’ Open Files β†’ navigate to /storage/
  3. Select the main feature title (usually the longest)
  4. Output to /output/
  5. Move resulting .mkv into /mnt/seedbox/movies/ for Radarr import
Critical config gotcha

The jlesage/makemkv image defaults MAKEMKV_KEY=BETA, which on container start tries to fetch a fresh beta key from forum.makemkv.com. This fetch hangs indefinitely over the WireGuard tunnel (CT100’s outbound goes through UltraCC NL), and the container never finishes initializing.

Fix: explicitly set MAKEMKV_KEY: "" in compose. Empty string skips the key-fetch logic entirely and the container boots normally. The 30-day eval license is fine for occasional use.

services:
  makemkv:
    image: jlesage/makemkv:latest
    container_name: makemkv
    environment:
      MAKEMKV_KEY: ""        # CRITICAL β€” prevents beta-key fetch hang
      USER_ID: 0
      KEEP_APP_RUNNING: 1
    ports:
      - 5800:5800
    volumes:
      - /mnt/container-data/makemkv/config:/config
      - /mnt/seedbox/movies:/storage:ro
      - /mnt/container-data/makemkv/output:/output
Security
No authentication on the web UI. Reachable via Caddy at makemkv.edmd.me (LAN + NetBird only by default). If exposing publicly, add HTTP basic auth in Caddy.
MCP Servers

MCP (Model Context Protocol) servers extend Claude with tools β€” file access, web search, Tana, farm data, homelab ops, and more. Active server list lives in ~/Library/Application Support/Claude/claude_desktop_config.json (symlinked to ~/Sync/ED/config/claude_desktop_config.json so it Syncthing-replicates).

This page tracks what’s actually loaded right now plus what’s been built but is dormant. The list shifts as MCPs are added, retired, or moved between active and on-shelf β€” for the canonical current state, the bundle (~/Sync/ED/.claude-context.md) is always more current than this page.

Active β€” load at Claude Desktop start (13 servers, May 2026)

These thirteen are configured and load when Claude Desktop launches. Tools-by-server count is approximate; check each MCP’s list_tools for the live list.

Server Where it lives Purpose
farm-data ~/.mcp-servers/farm-data/server.py Farm DB β€” species catalog, plantings, observations, harvests, inventory, hive inspections, GIS feature lookup. Farm DB docs
structured-exec ~/.mcp-servers/structured-bash/server.py Argv-array shell execution β€” exec, ssh_exec, pct_exec. No shell-string composition. Renamed in config from “structured-bash” because Cowork silently filters MCP servers with “bash” in the name
memory ~/.mcp-servers/memory/server.py Persistent knowledge graph at ~/Sync/ED/memory/knowledge.json β€” memory_search (BM25 ranked), memory_get, memory_append_observation, memory_create_entity, memory_list_entities, memory_stats. All mutations back up to knowledge.json.bak.<ts> and re-validate JSONL before swap
tana-local npx mcp-remote β†’ http://127.0.0.1:8262/mcp Tana desktop app’s own MCP server. Bearer PAT auth (NOT older Input-API token). 20 tools. Token in SECRETS.md row 22. Tana details
ha-mcp npx mcp-remote β†’ http://192.168.0.10:9583/private_<path> Home Assistant β€” 85 tools. Currently unreachable: fpve.netbird.cloud is offline (late May 2026 outage)
firecrawl ~/.mcp-servers/firecrawl/server.py Web scraping, crawling, content extraction, monitoring
cron-validator ~/.mcp-servers/cron-validator/server.py Validate cron expressions, parse launchd .plist files (StartInterval, single-dict StartCalendarInterval, multi-schedule lists), check schedule overlap
git-semantic ~/.mcp-servers/git-semantic/server.py git_repo_overview, git_search_commits, git_commit_detail, git_change_summary, git_blame_lines. Param name is repo (not repo_path). DEFAULT_REPO = ~/homelab-config (cloned from pve, pulled daily by com.bee.pull-homelab-config)
netbird ~/.mcp-servers/netbird/server.py NetBird mesh β€” list peers, list groups, list networks/routes/setup-keys, get/add/remove peer groups, delete peers
usda-plants ~/.mcp-servers/usda-plants/server.py Plant search, native-status, symbol lookup against the USDA Plants public API
inaturalist ~/.mcp-servers/inaturalist/server.py Species observations near coords, pollinators, county species, taxon lookup. Defaults to Brownsville coords + Licking County place_id
mermaid-render ~/.mcp-servers/mermaid-render/server.py mermaid_render (PNG/SVG via mmdc at /opt/homebrew/bin/mmdc), mermaid_validate, mermaid_template
spotify-mcp ~/.mcp-servers/spotify-mcp/server.py Spotify Web API β€” 28 tools across playlist read/write, library (unified /me/library), listening history, basic playback. PKCE OAuth, refresh token cached on disk. Feb 2026 endpoint migration applied 2026-05-26. Spotify MCP docs
Built but dormant (on disk, not currently configured)

These have working server code but aren’t in claude_desktop_config.json. Wire them up when needed β€” most have been deferred either because the upstream is redundant (HA covers weather) or because the API costs money.

Server Status
homelab-snapshot Runs as a launchd job (com.bee.homelab-snapshot.plist) every few minutes, writes ~/Sync/ED/.homelab-snapshot.json. Read that file directly for state instead of wiring the MCP.
tidal-mcp Tidal account active, server built, not currently wired. Spotify is the active music surface.
nws-weather Skipped β€” Home Assistant integration covers weather more thoroughly.
wallabag Server built; pending credential rotation.
json-schema Server built; tier-2 install.
diff-preview Wrapper around ~/scripts/diff-preview.sh. Tier-2.
image-gen Needs OpenAI key. Cowork canvas-design covers basics.
Retired / explicitly disabled
Server Retired Why
sequential-thinking 2026-05-24 Internal reasoning + behavioral rules cover the original failure mode. Don’t reintroduce without explicit ask.
brave / kagi / tavily / exa search MCPs rotated out as keys lapsed Web research now goes through WebSearch + mcp__workspace__web_fetch. Re-wire any of these if they earn their keep.
navidrome-mcp rotated out Direct Navidrome web UI sufficient.
desktop-commander replaced by structured-exec The argv-array pattern killed the shell-string-composition class of bugs. Keep desktop-commander as fallback only for interactive REPLs.
Cowork-managed integrations (per session)

In addition to the locally-configured 12, each Cowork session ships with a set of “connected” integrations the user has authorized via Cowork β€” Gmail, Google Calendar, GitHub, Spotify, Drafts, Read/Send iMessages, PDF Tools, Claude in Chrome, Control your Mac (osascript), and others. These appear as deferred tools at session start; load their schemas with ToolSearch before calling.

These are managed by Cowork and not in claude_desktop_config.json.

Config file

Path: ~/Library/Application Support/Claude/claude_desktop_config.json Symlink target: ~/Sync/ED/config/claude_desktop_config.json (Syncthing-replicated, so Studio and MacBook share one source of truth).

After editing, Quit Claude Desktop fully (Cmd-Q from the menu bar, not just close the window) and relaunch to reload servers. MCP venvs auto-rebuild on the MacBook via com.bee.rebuild-mcp-venvs.plist when ~/.mcp-servers/ changes.

Health monitor: ~/scripts/check-mcp-health.py runs 6Γ—/day (06:15, 09:15, 12:15, 15:15, 18:15, 21:15) via com.bee.mcp-health-check.plist. Scans each MCP’s log in ~/Library/Logs/Claude/mcp-server-<name>.log for Server transport closed unexpectedly, connect-timeout, fetch-failed, [error] lines in the last 6 hours; pushes a Gotify alert on any failure.

Music Pipeline

Automated music acquisition, tagging, and streaming β€” running on Proxmox CT 100 (docker-host).

Pipeline Overview

Music flows through a fully automated chain: Lidarr searches for wanted albums via Headphones VIP indexer, sends grabs to NZBGet on the remote seedbox, rsync pulls completed downloads to Proxmox, Lidarr imports and organizes them, and Navidrome serves the final library for streaming.

Data flow:

Step Component Detail
1 Lidarr (Docker, CT 100) Searches Headphones VIP via MissingAlbumSearch (daily 4am cron). RSS sync runs every 15 min but grabs 0 β€” Headphones VIP RSS feeds random new releases, not library-specific albums.
2 NZBGet (seedbox ismene.usbx.me) Downloads grabbed NZBs to ~/downloads/nzbget/completed/Music/ at ~70 MB/s average.
3 seedbox-sync.sh (cron, every 15 min) rsync pulls completed/Music/, completed/Books/, and complete/ β†’ /nvmepool/ingest/ on Proxmox. Uses --remove-source-files to clean seedbox as files transfer. Partial resuming enabled.
4 LXC bind mount /nvmepool/ingest β†’ /mnt/seedbox inside CT 100.
5 Docker bind mount /mnt/seedbox β†’ /downloads inside Lidarr container.
6 Lidarr import Monitors /downloads/Music/ and imports matched albums to /music/, renaming per configured format. Requires β‰₯80% MusicBrainz metadata match.
7 Navidrome (Docker, CT 100) Serves /mnt/music to audio clients (Feishin on desktop, Subsonic apps on mobile).
Lidarr Configuration
Setting Value
URL http://192.168.8.100:8686
Image lscr.io/linuxserver/lidarr:nightly
Version 3.1.2.4928 (nightly β€” required for plugin support)
API key 3dc17d20ca664be4ac90fb89004f91b8
Config mount /opt/lidarr/data β†’ /config
Downloads mount /mnt/seedbox β†’ /downloads
Music mount /mnt/music β†’ /music
Monitored artists 241
Missing albums ~10,000+ (93 artists added April 13)

Indexers:

Indexer Status Notes
Headphones VIP βœ… Working Primary source. Dedicated music Usenet indexer with proper t=music support.
NZBHydra2 βœ… Working Usenet indexer aggregator on seedbox. API key: BM7BCBP0RNBFIIRTJ32LGN9G4M. Base URL in Lidarr: http://192.168.8.221:15076/nzbhydra2. Note: altHUB (underlying indexer) lacks music-search caps so music-specific results are limited, but general searches work.

Download client: NZBGet at 192.168.8.221:16789 (SSH tunnel to seedbox port 13036).

Quality profiles: Any, Lossless (preferred), Standard. Set artists to Lossless for FLAC.

Release profile β€” ignored terms (compilations and greatest hits are blocked to prevent import loop):

  • Greatest Hits, Best Of, The Essential, The Very Best
  • Collection, Definitive, Complete, Anthology, Ultimate
  • YouTube rip, SoundCloud rip, ytrip, camrip, Rock Mix, Rancho Texicano

Track naming format: {Album Title} ({Release Year})/{Artist Name} - {Album Title} - {track:00} - {Track Title}

Automation & Scheduling

Lidarr has no built-in scheduled MissingAlbumSearch β€” this is a known design gap. The RSS sync (every 15 min) only grabs random new releases from the Headphones VIP feed, not library-specific missing albums. A Proxmox cron job compensates:

Cron job Schedule Purpose
seedbox-sync.sh Every 15 min Consolidated pull from seedbox β€” both nzbget/completed/Music/ + Books/ and downloads/complete/ in one script
MissingAlbumSearch API call Daily 4:00 AM Actively searches all missing albums against all indexers β€” the primary download trigger

Trigger MissingAlbumSearch manually:

curl -s -X POST 'http://192.168.8.100:8686/api/v1/command?apikey=3dc17d20ca664be4ac90fb89004f91b8' \
  -H 'Content-Type: application/json' -d '{"name":"MissingAlbumSearch"}'
Beets Post-Processor

Beets 2.7.1 is installed on CT 100 at /usr/local/bin/beet. Config at /root/.config/beets/config.yaml. Primary use is importing new music from the seedbox and handling compilations that Lidarr’s 80% threshold rejects.

Setting Value
Music directory /mnt/music
Library DB /root/.config/beets/library.db (~29MB)
Import mode move: yes, quiet: yes, quiet_fallback: asis
Match threshold 0.15 (distance) β€” much looser than Lidarr’s 80%
Duplicate action skip
Compilations path Compilations/$album/$track $title

Plugins: musicbrainz, fetchart, embedart, lastgenre, scrub, chroma

Pipeline script: /usr/local/bin/beet-full-pipeline.sh β€” DISABLED as of April 13, 2026. The initial bulk import is complete: 2,112 albums / 33,654 tracks / 1.4 TiB across 197 artists. The 30-minute cron was removed because the import had finished but was re-scanning 2,662 folders of duplicates/junk every 3 hours in an infinite loop. 134 potential hi-res/deluxe keepers were preserved in /mnt/seedbox/Music-Duplicates/ for manual review; 658 non-keepers and 1,870 junk-only folders were deleted.

Pipeline behavior:

  1. Checks for new files in /mnt/seedbox/Music
  2. If found, runs beet import -q /mnt/seedbox/Music to match, tag, and move to /mnt/music
  3. Cleans up empty directories left behind
  4. If no new files, exits immediately

Manual maintenance commands (run on CT 100 only when needed, not scheduled):

# Fetch missing cover art for albums without artwork
/usr/local/bin/beet fetchart

# Embed cover art into audio file metadata
/usr/local/bin/beet embedart

# Tag genres from Last.fm
/usr/local/bin/beet lastgenre

# Re-catalog any files in /mnt/music not yet in beets DB
/usr/local/bin/beet import -qA /mnt/music

Using beets for compilations (albums blocked by Lidarr’s release profile):

# SSH into CT 100 and run:
/usr/local/bin/beet import /downloads/Music/SomeAlbum/

Beets matches with 15% threshold, auto-fetches art, tags genre from Last.fm, moves to Compilations/ folder. Navidrome picks up automatically.

Docker Services
Service Image Port Purpose
Navidrome deluan/navidrome:latest 4533 Music streaming server (Subsonic-compatible). Scan schedule: 24h (filesystem watcher handles real-time). Config: env vars only, no toml. Volumes: /var/lib/navidrome:/data, /mnt/music:/music:ro
Lidarr lscr.io/linuxserver/lidarr:nightly 8686 Music collection manager and Usenet requester. Note: Lidarr has a known memory leak β€” restart periodically if RAM usage exceeds 2GB (docker restart lidarr)
Planned Enhancements

Two Lidarr plugins are planned to supplement the Usenet pipeline. Both require the nightly branch (already active).

Plugin GitHub Purpose Status
Tidal (TrevTV) Lidarr.Plugin.Tidal Direct Tidal downloads β€” FLAC lossless, Dolby Atmos, vast catalog. Best fix for compilations/greatest hits. Requires active Tidal subscription. Planned
Tubifarry (TypNull) Tubifarry YouTube fallback (128-256kbps AAC) + Soulseek via Slskd. Use as last resort only. Planned

Installing a plugin: System β†’ Plugins in Lidarr UI β†’ paste GitHub URL β†’ Install β†’ Restart.

Tidal auth flow (quirky): Add indexer β†’ enter data path β†’ Test (will error) β†’ Cancel β†’ refresh page β†’ re-open Tidal indexer β†’ copy the OAuth URL β†’ log into Tidal in browser β†’ copy the redirect URL β†’ paste back into Lidarr. Redirect URLs are single-use.

Storage
Path (CT 100) Host ZFS dataset Purpose
/mnt/music nvmepool/music Tagged music library (Navidrome source) β€” 33,654 tracks, 241 artists (197 from beets + 93 added to Lidarr April 13)
/mnt/seedbox nvmepool/ingest Seedbox landing zone (rsync target)
/mnt/seedbox/Music/ β€” Empty β€” beets import complete. New Lidarr grabs land here.
/mnt/seedbox/Music-Duplicates/ β€” 134 potential hi-res/deluxe keepers pending review
/mnt/seedbox/Books/ β€” Book library

Seedbox state (as of 2026-04-13):

  • completed/Music/: Active β€” consolidated seedbox-sync.sh runs every 15 min, pulls from both nzbget and general complete paths
  • NZBGet: idle when queue is empty, average ~70 MB/s when downloading
  • Beets import complete β€” /mnt/seedbox/Music/ is empty and ready for new Lidarr grabs
Troubleshooting
Symptom Cause Fix
Navidrome UI slow to display music Beets pipeline re-cataloging entire library every 30 min, causing constant Navidrome rescans Fixed April 2026: pipeline now only imports new seedbox files. Navidrome scan reduced to 24h (filesystem watcher handles real-time)
Lidarr using 4GB+ RAM Memory leak after extended uptime (13+ days observed) docker restart lidarr β€” drops back to ~200MB
NZBGet idle, nothing downloading MissingAlbumSearch completed its run, queue empty Trigger manually (see Automation section) or wait for 4am cron
Queue full, NZBGet idle 60-item queue full of importFailed items blocking new grabs Blocklist + remove importFailed items via Activity β†’ Queue
importFailed: album match not close enough Lidarr’s 80% MusicBrainz threshold not met For compilations: use beets. For others: blocklist and let Lidarr find different release
/downloads/Music/ empty in container Docker bind mount broken (Docker started before LXC mount) docker restart lidarr
RSS Sync: 0 grabbed Normal β€” Headphones VIP RSS feeds random new music Expected behavior. Grabs only come from MissingAlbumSearch
importFailed: permissions error File ownership issue from NZBGet Check /downloads/Music/ permissions inside container
Stale lockfile blocking sync Previous rsync killed mid-run rm /tmp/sync-seedbox.lock on Proxmox
NetBird Mesh

Installed April 19, 2026. Replaces Pangolin as the mesh VPN for home ↔ farm ↔ mobile access. WireGuard-based, peer-to-peer where possible, relayed as fallback.

Why NetBird

Pangolin was a single-VPS hub architecture β€” every packet routed through the VPS. That was fine for admin but meant latency compounded and a VPS outage killed remote access entirely. NetBird gives us:

  • Direct P2P between peers whenever network conditions allow
  • Relay fallback only when necessary
  • Subnet routing β€” one peer on each LAN advertises the whole LAN to the mesh
  • Zero public port exposure β€” no open ports on the home router required
  • Clean client apps for every OS
Current peers
Peer NetBird IP LAN IP Location Role
hpve 100.123.31.199 192.168.8.221 Home Proxmox host, advertises 192.168.8.0/24
fpve 100.123.49.175 192.168.0.191 Farm Farm Proxmox, advertises 192.168.0.0/24
vps 100.123.69.155 (SSDNodes public IP) edge01 Public VPS β€” Caddy edge
studio 100.123.217.253 192.168.8.180 Home Mac Studio

To add more peers (iPad, MacBook, phone), invite from the dashboard or use a setup key.

Installing on a new peer

Linux (Debian/Ubuntu):

curl -fsSL https://pkgs.netbird.io/install.sh | bash
netbird up --management-url https://api.netbird.io --hostname <peer-name>

You’ll get a one-time auth URL in the terminal β€” open it in a browser to authorize.

Or with a setup key (for headless provisioning):

netbird up --setup-key <KEY> --hostname <peer-name>

macOS / Windows / iOS / Android: download from netbird.io/download. Sign in with the same account.

DNS integration

Pi-hole on CT102 is registered as a NetBird nameserver, so mesh peers get local DNS automatically:

  • *.edmd.me β†’ 192.168.8.54 (Caddy)
  • Short names (hpve, portainer, immich, etc.) β†’ resolve directly
  • *.netbird.cloud β†’ resolved by NetBird client locally (peer names)

Peers appended netbird.cloud as a search domain, so typing just hpve in a browser reaches hpve.netbird.cloud (the hpve peer’s mesh address).

Subnet routing

The hpve peer advertises 192.168.8.0/24 to the mesh. The fpve peer advertises 192.168.0.0/24. Any peer connected can reach any host on either LAN by its local IP address.

Routes are configured in the NetBird dashboard under Network Routes and must be selected on each peer (netbird routes select <route-id>).

Daemon commands (Linux peers)
netbird status              # Show connection state
netbird status --detail     # Per-peer connection info (direct vs relayed)
netbird up                  # Start
netbird down                # Stop
systemctl status netbird    # Service status
journalctl -u netbird -f    # Live logs
Connection paths

NetBird tries paths in this order:

  1. Direct P2P over IPv6 β€” fastest, no NAT issues. Requires IPv6 on both ends.
  2. Direct P2P over IPv4 via STUN/ICE β€” works through most NATs, can fail with strict CGNAT (Starlink).
  3. Relayed through NetBird relay servers β€” always works, adds 30-80ms latency.

Check which path is being used: netbird status --detail shows each peer’s connection_type as P2P or Relayed.

Improving P2P success rate

In an ideal home network, two improvements would help direct P2P:

  • Enable IPv6 at both ends. Bypasses IPv4 NAT issues entirely.
  • Port-forward UDP 51820 on the home router β†’ 192.168.8.221 (hpve). Gives the home peer a stable public endpoint.

At our current home, neither is feasible β€” the ISP gives OPNsense a private WAN IP (192.168.254.65) behind their managed gateway. We can’t get IPv6 through the upstream gateway, and we can’t open inbound ports on the upstream we don’t control. Until the ISP service tier changes, peers fall back to STUN-punched UDP4 (which works for most paths) or relayed (small latency cost).

In practice this means: peers that are both on residential ISPs with reasonable NAT will reach each other directly via STUN; mobile peers on cellular often relay. Both are functional.

Troubleshooting
Problem Check
Can’t reach *.edmd.me URLs Is NetBird connected? netbird status
Connection reports as relayed Often unavoidable on home ISP (see P2P note). Mobile peers on cellular tend to relay. Functional, just adds latency.
Peer shows offline in dashboard Check journalctl -u netbird on that peer
LAN devices unreachable hpve peer might be down, or subnet routing not enabled
Wrong DNS result for short name Pi-hole CT102 β€” check /etc/pihole/hosts/custom.list. Or OPNsense Unbound forward β€” see Pi-hole.
NocoDB

Installed Apr 30, 2026 alongside Directus to compare UX over a few days.

Summary
URL https://nocodb.edmd.me
Local http://192.168.8.100:8080
Container nocodb/nocodb:latest on CT100
Compose /opt/nocodb/docker-compose.yml
Metadata DB nocodb_meta on 192.168.8.100:5432 (separate from farmdb to avoid pollution)
Data DB farmdb (added as a Data Source via the NocoDB UI)
Volume /mnt/container-data/nocodb
Setup notes
  • Auth secret at /opt/nocodb/secrets.env (NC_AUTH_JWT_SECRET, random hex 48). Should be moved to Vaultwarden.
  • Telemetry disabled: NC_DISABLE_TELE=true
  • Public URL: NC_PUBLIC_URL=https://nocodb.edmd.me set so links emitted by NocoDB use the right domain.
  • Critical for private-network DBs: NC_ALLOW_LOCAL_EXTERNAL_DBS=true β€” without this, NocoDB’s SSRF protection (tightened in v0.301.4 March 2026) blocks connections to RFC1918 addresses like 192.168.8.100:5432. Symptom: “Forbidden host name or IP address” when adding a Data Source. The deprecated SSRF_ALLOWED_DOMAINS env var no longer works.
Why NocoDB

NocoDB presents a more spreadsheet-shaped UI compared to Directus’s form-shaped admin UI. Strengths:

  • Airtable-style grid view with inline editing, sorting, filtering
  • Multiple views per table (grid, gallery, kanban, calendar)
  • Easier to scan many records at once
  • More familiar to non-technical users

Like Directus, it sits on top of farmdb without restructuring data. Edits made in NocoDB are visible in Directus, the Farm MCP, and direct psql.

Caveats
  • License: NocoDB switched to “Sustainable Use License” β€” fine for personal/homelab use, less open than it was. Directus is BSL but free under usage threshold.
  • Two UIs editing the same data: Don’t run Directus and NocoDB long-term simultaneously. Decision deadline: ~1 week of side-by-side use, then commit to one.
  • directus_* tables visible: When connecting NocoDB to farmdb, deselect the 30 directus_* system tables during schema import to avoid sidebar clutter.
OPNsense Router

Cutover April 29, 2026. Replaces the GL.iNet GL-MT3000 (Beryl AX) that previously routed home traffic.

Summary
Hostname orchard.edmd.me
Version OPNsense 25.7.11_9 (amd64) β€” upgraded from 25.7 on cutover day
LAN IP 192.168.8.1
WAN IP 192.168.254.65 (DHCP from upstream ISP gateway)
Web UI https://192.168.8.1
SSH ssh root@192.168.8.1 (key-only from Mac Studio)
Root password lloovies β€” rotation pending (was passed in chat during setup)
Console password Same as root
Why we replaced the GL.iNet
The Beryl AX is a great travel router but had quirks at home β€” DHCP/DNS coupling was opaque, IPv6 support was inconsistent, and we couldn’t easily move admin services to a NetBird-only access policy. OPNsense gives us proper firewall rules, real DHCP/DNS separation, and a stable platform for adding services like a future WireGuard road-warrior endpoint.
DHCP β€” Dnsmasq

OPNsense uses Dnsmasq for DHCP, not Kea or ISC dhcpd. (Kea was upgraded 2.x β†’ 3.0.2 during the firmware update but isn’t enabled.)

Pool 192.168.8.150 – 192.168.8.242
Lease time Default (24h)
Static reservations Mac Studio at .180 (MAC 1c:1d:d3:e1:a1:ec, en0 10GbE)
DNS handed out 192.168.8.1 (OPNsense Unbound) β€” clients ask Unbound, which forwards edmd.me to Pi-hole
Config file /usr/local/etc/dnsmasq.conf (auto-generated; port=0 so dnsmasq doesn’t serve DNS)

Why almost nothing has a static reservation

Mac Studio is the only host with a DHCP reservation. We deliberately did NOT add reservations for hpve, the CTs (Pi-hole, Caddy, docker-host, Immich, vaultwarden), or any other LAN device. Reasoning:

  • CTs use static IPs configured at the Proxmox level, not via DHCP. CT100 = 192.168.8.100, CT101 = .103, CT102 = .53, CT103 = .54, CT105 = .105 (Roon). (CT104 was retired May 2026.) These are baked into the LXC config (/etc/pve/lxc/<vmid>.conf), so they survive across reboots and don’t depend on the DHCP server at all. Adding a reservation for them would be redundant and only adds a place where the IP could be defined inconsistently.
  • hpve uses a static IP configured in /etc/network/interfaces (192.168.8.221). Same reasoning β€” the host doesn’t ask DHCP for an address.
  • Eero mesh nodes are in bridge mode and get whatever DHCP gives them. They’ve never moved off their initial leases (.123, .140, .169, .203, .212), so they’re effectively stable. If they ever do roam, no service depends on a specific eero IP.
  • Smart-home devices (Sonos, WiiM Ultra, YouTube TV, Brother printers, Homey, Weather Station) all keep stable leases through normal DHCP renewal. Most have hostnames in mDNS/Bonjour anyway, so we use names not IPs to reach them.

Mac Studio is the exception because it has services bound to a specific IP (Hugo Hub, Paperless-NGX, LM Studio, Life Archive API/MCP) that other hosts and the Bee Hub site link to by IP. Drifting onto a different IP would break those references.

When to revisit: any time we add a service on a non-CT host that other systems reference by IP, the host should get a reservation.

DNS β€” Unbound

OPNsense’s Unbound is the DNS resolver on port 53. Dnsmasq does DHCP only.

Critical config: Unbound has a <privateaddress> filter that blocks RFC1918 answers from public DNS (default behavior). Cloudflare’s wildcard *.edmd.me β†’ 192.168.8.54 would normally be filtered out and clients would get NXDOMAIN.

The fix is a forward override added Apr 29 2026:

edmd.me β†’ 192.168.8.53:53 (Pi-hole)

Configured via the OPNsense Unbound model (<unboundplus><dots><dot type="forward">). UUID 27b78f0f-57b1-40f7-9169-69cfd6d1d467. Pi-hole then returns the right answer for *.edmd.me.

Without this, Mac/iOS clients that route edmd.me queries to OPNsense (because of the search-domain hint) would fail to resolve β€” even though Pi-hole has the correct entry.

Firmware updates
ssh root@192.168.8.1
configctl firmware poll       # Check for available
configctl firmware update     # Apply all pending updates
# NOTE: NOT 'firmware upgrade' β€” that's for major version bumps with named target

The Apr 29 firmware run applied 135 packages including:

  • Kea: 2.6.3 β†’ 3.0.2 (DHCP β€” not used; we run Dnsmasq)
  • acme.sh: 3.1.1 β†’ 3.1.2
  • openssl: β†’ 3.0.18
  • bind-tools, krb5, glib, dpinger, filterlog

Reboots automatically when needed. The system regenerates SSH host keys on upgrade β€” clear ~/.ssh/known_hosts for 192.168.8.1 after upgrades. Authorized keys for root are wiped and must be re-added via web UI: System β†’ Access β†’ Users β†’ root β†’ Authorized keys.

Config backups

Pre-update XML config backup is saved on the Mac Studio at:

~/homelab-backups/opnsense-orchard-preupdate-YYYYMMDD-HHMMSS.xml

Take a backup before every firmware update:

ssh root@192.168.8.1 'cp /conf/config.xml /tmp/opnsense-config.xml'
scp root@192.168.8.1:/tmp/opnsense-config.xml ~/homelab-backups/opnsense-orchard-$(date +%Y%m%d-%H%M%S).xml

To restore, boot the OPNsense installer ISO and use “Import config” β€” point it at the XML.

Known upstream limitations

The home internet has a double-NAT at the ISP level β€” OPNsense’s WAN IP is 192.168.254.65 (RFC1918), behind an ISP-managed gateway at 192.168.254.254 that we don’t control.

Consequences:

  • No IPv6 β€” ISP gateway doesn’t pass it through to OPNsense.
  • No inbound port forwarding to the home β€” UDP 51820 (NetBird WireGuard direct P2P) cannot be opened from the public internet.
  • No outbound ICMP from OPNsense to the upstream gateway β€” ping 192.168.254.254 and even ping 1.1.1.1 from OPNsense itself fails. TCP works fine. Diagnostic-only annoyance.

These would be solved by a different ISP service tier with a real public IP, not by anything OPNsense can do.

Pending OPNsense work
  • Rotate root password β€” lloovies was passed in chat during setup
  • Lock admin services to NetBird subnet only β€” SSH (22), web UI (443), should not accept connections from arbitrary LAN clients. Add WAN/LAN firewall rules limiting source to 100.123.0.0/16.
  • Lock hpve admin too β€” Proxmox 8006 and Cockpit 9090 should also be NetBird-only. UFW on hpve.
  • Install vespo92/OPNSenseMCP β€” for managing OPNsense from Claude Desktop (API-only, no SSH).
If OPNsense becomes unreachable

If web UI and SSH stop responding but routing still works (we hit this once on cutover day):

  1. Plug a monitor and keyboard into the OPNsense box.
  2. Console menu appears at boot β€” login with root / lloovies.
  3. Option 11) Reload all services usually fixes it.
  4. Option 6) Reboot as a stronger reset.
  5. Option 8) Shell to investigate (tail -f /var/log/system/latest.log).

If the device won’t boot or has a bad config, restore from XML backup via the installer ISO’s “Import config” prompt.

Pangolin (Retired)

Pangolin was retired on April 19, 2026 and replaced by NetBird Mesh. All WireGuard tunnels, Pangolin Newt services, and the VPS dashboard at pangolin.troglodyteconsulting.com have been decommissioned.

See NetBird Mesh for the current remote access setup, and Caddy Reverse Proxy for how services are exposed via *.edmd.me.

Pi-hole DNS

Installed April 19, 2026 on CT102 at 192.168.8.53. Serves DNS + ad blocking to the entire LAN and all NetBird peers.

Summary
Container CT102 pihole
IP 192.168.8.53
Version Pi-hole v6.4.1 (FTL 6.6, Web 6.5)
Upstream DNS Unbound on CT100 (192.168.8.100:5335, primary recursive resolver) + Cloudflare (1.1.1.1) + Quad9 (9.9.9.9) as fallbacks
Blocklists StevenBlack unified (87,771 domains)
Admin UI https://pihole.edmd.me (alias: dns.edmd.me)
Raw admin http://192.168.8.53/admin/
Container root password /root/pihole-root-password.txt on Proxmox
Web admin password /root/pihole-admin-password.txt on Proxmox
Who uses Pi-hole
  • All LAN devices β€” router’s DHCP hands out 192.168.8.53 as primary DNS
  • All NetBird peers β€” NetBird web UI β†’ Networks β†’ Nameservers β†’ 192.168.8.53 registered

Any device connected to home wifi OR to the NetBird mesh gets ad blocking + short names automatically. No per-device config.

Local DNS records (short names)

Pi-hole resolves friendly short names for every major LAN service. Adds both bare and .home variants.

Infrastructure: hpve, proxmox, ct100, docker-host, ct101, immich, studio, mac-studio, pihole, vps, edge01

Services (all on CT100 at 192.168.8.100): portainer, plex, calibre, lidarr, sonarr, radarr, prowlarr, bookshelf, audiobookshelf, navidrome, n8n, kuma, uptime-kuma, gotify, freshrss, homepage, convertx, readarr, shelfmark, paperless, paperless-ai

Farm network: farm-pve (192.168.0.191), ha / home-assistant (192.168.0.10), slzb (192.168.0.16)

Bare names (no suffix) work from NetBird peers because they append .netbird.cloud as a search domain. For LAN devices where the router drops bare names, use .home suffix β€” e.g. http://portainer.home:9443. For real HTTPS with clean URLs, use .edmd.me via Caddy on CT103 β€” e.g. https://portainer.edmd.me.

DNS Architecture

Resolution chain: Client β†’ Router (192.168.8.1) β†’ Pi-hole (CT102, 192.168.8.53) β†’ Unbound (CT100, 192.168.8.100:5335) β†’ root servers

Unbound is a recursive resolver running as a Docker container on CT100 (mvance/unbound). It resolves queries directly from the DNS root servers rather than forwarding to Cloudflare or Quad9 β€” this means no single upstream provider sees all your DNS queries. Pi-hole uses Unbound as its primary upstream with Cloudflare (1.1.1.1) and Quad9 (9.9.9.9) configured as fallbacks.

Config: Custom unbound.conf mounted at /opt/unbound/conf/unbound.conf (overrides the image’s auto-generated config). Key settings: msg-cache-size: 50m, rrset-cache-size: 100m, num-threads: 2, log-servfail: yes, verbosity: 1. DNSSEC validation enabled.

May 10 2026 fix: The mvance/unbound Docker image auto-calculates cache sizes from the host’s total RAM. Running inside a memory-limited LXC container (CT100), it was allocating ~33GB for DNS caching, causing periodic OOM and SERVFAIL responses. Pi-hole cached those SERVFAILs instead of falling back to working upstreams. The old health check (drill @127.0.0.1 google.com) was useless because SERVFAIL counted as a valid response. Fixed by:

  1. Mounting a custom unbound.conf with sane cache sizes
  2. Updating the Docker health check to verify NOERROR status: drill @127.0.0.1 google.com | grep -q 'NOERROR'
  3. Enabling log-servfail: yes and verbosity: 1 for future debugging
  4. Flushing Pi-hole’s cached SERVFAILs via systemctl restart pihole-FTL
Managing Pi-hole

Add a new short-name DNS record β€” use the web UI (Settings β†’ Local DNS β†’ DNS Records) or the API:

# From Proxmox host
ADMIN_PW=$(cat /root/pihole-admin-password.txt)
SID=$(curl -sk -X POST http://192.168.8.53/api/auth \
  -H "Content-Type: application/json" \
  -d "{\"password\":\"$ADMIN_PW\"}" | grep -oE '"sid":"[^"]+"' | cut -d'"' -f4)

curl -sk -X PUT "http://192.168.8.53/api/config/dns/hosts/192.168.8.100%20newservice?sid=$SID"

Update blocklists β€” web UI β†’ Gravity β†’ “Update”
Or CLI from within the container:

pct exec 102 -- pihole -g

View query log β€” web UI β†’ Query Log
Or from container: tail -f /var/log/pihole/pihole.log

Reload after config changes β€” pct exec 102 -- pihole reloaddns

Gotchas
  • Pi-hole v6 uses embedded FTL as web server (NOT lighttpd β€” unlike v5). Port 80 and 443 handled by pihole-FTL.
  • v6 migrated from setupVars.conf to /etc/pihole/pihole.toml.
  • Custom DNS records live at /etc/pihole/hosts/custom.list but Pi-hole v6 regenerates this file on some config changes. Use the admin UI or API to add records β€” do not edit the file directly.
  • Loading custom dnsmasq directives β€” set misc.etc_dnsmasq_d = true in pihole.toml, then drop config into /etc/dnsmasq.d/. The *.edmd.me wildcard (/etc/dnsmasq.d/03-edmd-wildcard.conf β†’ address=/edmd.me/192.168.8.54) is loaded this way. NOTE: when etc_dnsmasq_d=false (the default), files in /etc/dnsmasq.d/ are silently ignored. We’ve changed our default to true after the Apr 29 cutover.
  • The bogusPriv=true setting in pihole.toml rejects RFC1918 answers from upstream β€” but does NOT affect locally-configured wildcards in /etc/dnsmasq.d/. So the wildcard works even with bogusPriv on.
  • .netbird.cloud names are NOT resolved by Pi-hole β€” NetBird client intercepts those on each peer locally. Don’t try to forward .netbird.cloud from Pi-hole (CT102 can’t reach NetBird’s internal DNS at 100.123.31.199 because CT102 isn’t a NetBird peer).
Proxmox VE

Proxmox VE 9.1.1 β€” Intel i9-13900H (20 threads) β€” 128 GB RAM β€” Kernel 6.17.2-1-pve

TODO: Lock SSH (22), Proxmox web UI (8006), and Cockpit (9090) to NetBird-only access. UFW rule: allow from 100.64.0.0/10 to any port 22/8006/9090, then deny those ports from public. Apply same pattern to VPS (edge01). This makes every admin surface unreachable from the public internet β€” only accessible via the NetBird mesh.

Hardware & Drives
Drive Size Type ZFS Pool Purpose
nvme0n1 1.8 TB NVMe β€” Boot (PVE root + LVM-thin)
nvme1n1 3.6 TB NVMe nvmepool (stripe) VMs, containers, sync, music, movies, books, photos, video
nvme2n1 3.6 TB NVMe nvmepool (stripe) β€”
nvme3n1 3.6 TB NVMe nvmepool (stripe) β€”
sda 465.8 GB NVMe (USB) backups Vzdump backups, ISOs (Crucial P5 500GB in Sabrent USB enclosure, installed Apr 19 2026)
β€” 2Γ— 18.2 TB HDD (TB3) Biggest mirror-0 Archive/backup mirror (ORICO 9858T3 Thunderbolt 3 enclosure)
β€” 3Γ— 4 TB HDD (TB3) β€” 3 free bays in ORICO 9858T3 Thunderbolt 3 enclosure (Birch pool retired Apr 2026)

Retired (Apr 2026): BIGGIE (Seagate 5TB USB), Big (932GB SSD), Birch (3Γ—4TB RAIDZ1 β€” pool destroyed, seedbox sync moved to nvmepool/ingest). Nextcloud removed.

ZFS Pools & Datasets
Pool Size Used Health Key Datasets
nvmepool 10.9 TB ~6.4 TB (59%) ONLINE sync, music, movies, books, photos, video, audiobookshelf, bookshelf, tv, ingest, container-data, vms
Biggest 18.2 TB ~16.2 TB (89%) ONLINE Maple (Amigo, Monte, Ichabod β€” archive data), nvmepool-backup (nightly rsync of nvmepool), Kiwix
Birch β€” β€” β€” RETIRED Apr 2026 β€” pool destroyed. Seedbox sync moved to nvmepool/ingest. 3 free drive bays available in ORICO enclosure.
backups 464 GB β€” ONLINE dump, isos β€” Crucial P5 500GB (CT500P5SSD8, serial 21022FE3A911) in Sabrent USB enclosure (Realtek bridge 0bda:9210). Replaced failed Samsung 980 1TB on Apr 19 2026 (original Samsung lasted 6 days).
offsite 18.2 TB ~10.8 TB (59%) ONLINE maple (Biggest/Maple mirror), nvmepool-data (nvmepool backup copy), ct100-backups, seedbox

Dataset breakdown (nvmepool):

Dataset Used Mount Purpose
nvmepool/sync 1.87 TB /nvmepool/sync Mac Studio SYNC mirror
nvmepool/music 2.35 TB /nvmepool/music Music library (Navidrome + Plex)
nvmepool/movies 1.83 TB /nvmepool/movies Movie library (Plex)
nvmepool/audiobookshelf 24.7 GB /nvmepool/audiobookshelf Audiobook library
nvmepool/bookshelf 6.24 GB /nvmepool/bookshelf Readarr app data
nvmepool/books 33.2 GB /nvmepool/books Calibre-Web library
nvmepool/photos 1.40 TB /nvmepool/photos Photo library (Plex + Immich external library)
nvmepool/video 27.9 GB /nvmepool/video Video library (Plex)
nvmepool/tv 187 GB /nvmepool/tv TV library (Plex + Sonarr)
nvmepool/ingest varies /nvmepool/ingest Seedbox download landing zone (replaces retired Birch pool)
nvmepool/container-data 38.0 GB /nvmepool/container-data Large container configs (Lidarr, Plex, CWA, Sonarr, Immich DB + uploads) β€” moved off CT100 rootfs Apr 2026
nvmepool/vms 95.4 GB /nvmepool/vms VM/CT disk images

Dataset breakdown (Biggest):

Dataset Used Contents
Biggest/Maple 10.1 TB Amigo (Cell Photos, ISO, TV, Video), Ichabod (Movies, Music, Databases, Podcasts), Monte (Dropbox, Mystuff, PDF, Photos)
Biggest/nvmepool-backup 5.81 TB Nightly rsync mirror of all nvmepool datasets
Biggest/Kiwix 99 GB Offline reference content (Wikipedia, Stack Exchange, Gutenberg) β€” zstd compressed
Biggest/media-staging empty General staging area on mirrored drives

Speedy, TimeMachineOne, Ichabod/Sort, Amigo/delgross, Amigo/Youtube, Possible Delete β€” all deleted (Apr 7 and Apr 16 2026). Special vdev (Optane 110GB) and cache SSD (465GB) removed from pool.

Dataset breakdown (offsite):

Dataset Used Contents
offsite/maple 6.23 TB Mirror of Biggest/Maple β€” irreplaceable archive data
offsite/nvmepool-data 4.55 TB Backup copy of nvmepool media
offsite/ct100-backups empty CT100 vzdump backup destination
offsite/seedbox empty Seedbox data backup destination

The offsite pool is a single 18.2 TB drive that travels intermittently to the farm for geographic redundancy. Manual sync before each departure.

Containers & VMs

CT 100 β€” docker-host (primary media/apps container)

Setting Value
OS Debian 12 (LXC)
Cores 4
RAM 16 GB
Swap 4 GB
Root disk 48 GB on nvme-data (expanded from 32 GB Apr 2026)
IP 192.168.8.100
Features Nesting, keyctl, privileged (unprivileged: 0) β€” required for stable Docker networking
Autostart Yes

Bind mounts into CT 100:

Host path Container mount Purpose
/nvmepool/ingest /mnt/seedbox Seedbox downloads landing (Music + Books)
/nvmepool/books /mnt/books Calibre-Web library
/nvmepool/music /mnt/music Music library
/nvmepool/audiobookshelf /mnt/audiobookshelf Audiobookshelf data
/nvmepool/bookshelf /mnt/bookshelf Readarr app data
/nvmepool/movies /mnt/movies Movie library
/nvmepool/photos /mnt/photos Photo library
/nvmepool/video /mnt/video Video library
/nvmepool/tv /mnt/tv TV library
/nvmepool/container-data /mnt/container-data Large container configs (Lidarr, Plex, CWA)
/Biggest/Kiwix /mnt/kiwix Kiwix ZIM file storage (offline Wikipedia, etc.)

CT 101 β€” immich (dedicated Immich photo management host, created Apr 18 2026)

Setting Value
OS Debian 12 (LXC)
Cores 8 (bumped from 4 for faster initial ML scan)
RAM 8 GB
Swap 2 GB
Root disk 32 GB on nvme-data
IP 192.168.8.103 (originally .101, changed Apr 18 due to IP conflict with office-2.lan)
Features Nesting, keyctl
Autostart Yes
MAC BC:24:11:D5:67:E8

Bind mounts into CT 101:

Host path Container mount Purpose
/nvmepool/photos /mnt/photos Immich external library (read-only, 1.4 TB)
/nvmepool/container-data/immich /mnt/immich-data Immich uploads, postgres DB, thumbs, model cache

Docker-specific notes: IPv6 disabled in /etc/docker/daemon.json (required β€” ghcr.io was causing connection resets because CT101 has no IPv6 default route). DNS set to 8.8.8.8 + 1.1.1.1.


CT 105 β€” roon (Roon Server, created May 2026)

Setting Value
OS Debian 12 (LXC)
Cores 4
RAM 8 GB
Root disk 16 GB on nvme-ct
IP 192.168.8.105
Features Nesting, privileged
Autostart Yes
DNS 192.168.8.53 (Pi-hole)
NetBird Peer roon (100.123.169.114) in BeeDifferent group

Bind mounts into CT 105:

Host path Container mount Purpose
/nvmepool/music /mnt/music Music library (shared with CT100 Plex/Navidrome)

Services: Roon Server (/opt/RoonServer/start.sh) managed by systemd roonserver.service. Roon clients connect via mDNS discovery on LAN or via NetBird (Roon ARC for remote).

Docker Services (inside CT 100)
Service Image Port URL Status
Plex linuxserver/plex 32400 http://192.168.8.100:32400/web Up
Calibre-Web (CWA) calibre-web-automated 8083 http://192.168.8.100:8083 Up
Portainer portainer-ce:lts 9443 https://192.168.8.100:9443 Up
Uptime Kuma uptime-kuma:1 3001 http://192.168.8.100:3001 Up
Gotify gotify/server 8070 http://192.168.8.100:8070 Up
Gotify-Telegram Bridge custom (Python) β€” β€” Up
N8N n8n:latest 5678 http://192.168.8.100:5678 Up
Audiobookshelf audiobookshelf:latest 13378 http://192.168.8.100:13378 Up
Navidrome navidrome:latest 4533 http://192.168.8.100:4533 Up
Lidarr lidarr:nightly 8686 lidarr.edmd.me Up
Bookshelf bookshelf:hardcover 8787 http://192.168.8.100:8787 Up
Shelfmark shelfmark 8084 http://192.168.8.100:8084 Up
Radarr linuxserver/radarr 7878 radarr.edmd.me Up
Sonarr linuxserver/sonarr 8989 sonarr.edmd.me Up
Prowlarr prowlarr 9696 prowlarr.edmd.me Up
FreshRSS freshrss 8180 http://192.168.8.100:8180 Up
Kiwix ghcr.io/kiwix/kiwix-serve 8380 http://192.168.8.100:8380 Up
Wallabag wallabag/wallabag 8480 http://192.168.8.100:8480 Up
Wallabag DB mariadb:11 β€” internal Up
Wallabag Redis redis:7-alpine β€” internal Up
ConvertX ghcr.io/c4illin/convertx 3100 http://192.168.8.100:3100 Up
Aurral ghcr.io/lklynet/aurral 3002 http://192.168.8.100:3002 Up
Recyclarr ghcr.io/recyclarr/recyclarr β€” headless Up
Dozzle amir20/dozzle 9999 http://192.168.8.100:9999 Up
Homepage gethomepage.dev 3000 http://192.168.8.100:3000 Up
FlareSolverr flaresolverr 8191 http://192.168.8.100:8191 Up
Watchtower containrrr/watchtower β€” headless Up
Prometheus prom/prometheus 9090 http://192.168.8.100:9090 Up
Grafana grafana/grafana 3200 grafana.edmd.me Up
node-exporter prom/node-exporter β€” internal Up
cAdvisor gcr.io/cadvisor β€” internal Up
weather-exporter custom (Python) 9102 internal Up
Alertmanager prom/alertmanager β€” internal Up
Alertmanager-Gotify Bridge python:3.12-alpine β€” internal Up
Docker Services (inside CT 101)
Service Image Port URL Status
Immich Server ghcr.io/immich-app/immich-server:release 2283 http://192.168.8.103:2283 Up
Immich ML ghcr.io/immich-app/immich-machine-learning:release β€” internal Up
Immich Postgres ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 β€” internal Up
Immich Redis redis:6.2-alpine β€” internal Up

Immich is a self-hosted photo and video management platform (Google Photos alternative). Deployed as a 4-container stack on CT 101 via Docker Compose at /opt/immich/. External library points at /nvmepool/photos (1.4 TB, ~134K files) in read-only mode so originals are never modified. Immich’s own data (uploads, thumbnails, transcoded video, Postgres DB, ML model cache) lives in /nvmepool/container-data/immich/. Admin account created on first web access. DB password stored in /opt/immich/.env. Image tag locked to :release.

Docker Services (inside CT 100)

Plex serves movies, music, photos, video, and audiobooks from nvmepool. Plexamp (iOS/Mac client) connects to it for music. Uses network_mode: host.

Radarr manages the movie library at /mnt/movies (nvmepool/movies). Searches via Prowlarr indexers, downloads via seedbox, auto-renames and organizes movies for Plex. API key: b117993eb50f465ea485654bc0118861. Compose at /opt/radarr/docker-compose.yml.

Filebot (v5.2.1) is installed as a system package on CT100 (/bin/filebot) for ad-hoc movie/media renaming. Not containerized.

Calibre-Web Automated (CWA) serves the book library from /mnt/books (nvmepool/books). Auto-ingests books dropped into /mnt/books/ingest, auto-converts 28 formats to epub, fetches metadata, detects duplicates. Calibre bundled. Default login: admin / admin123. Image: crocodilestick/calibre-web-automated:latest.

Kiwix serves offline reference content (Wikipedia, Stack Exchange, Project Gutenberg, etc.) from /mnt/kiwix (Biggest/Kiwix β€” zstd compressed, 5.6TB available). ZIM files are downloaded manually from library.kiwix.org. A cron-based watcher (/usr/local/bin/kiwix-watcher.sh, every 5 min) detects new/changed ZIMs via MD5 hash of the file list and restarts the container to pick them up. Compose at /opt/kiwix/docker-compose.yml. Starter ZIM: wikipedia_en_simple_all_nopic_2026-02.zim (922 MB).

Wallabag is a self-hosted read-it-later service (alternative to Pocket/Instapaper). Stack: Wallabag app + MariaDB 11 (wallabag-db) + Redis 7 (wallabag-redis), all on dedicated wallabag-net bridge network. Compose at /opt/wallabag/docker-compose.yml. Secrets (DB password, Symfony secret) saved in /opt/wallabag/credentials.txt (root-only, chmod 600). Data persisted in named Docker volumes (wallabag-db, wallabag-redis, wallabag-images). Default admin account needs to be created on first visit. Browser extensions for Firefox/Chrome and mobile apps (iOS/Android) support direct capture.

ConvertX is a self-hosted file converter supporting 1000+ formats via FFmpeg, Pandoc, LibreOffice, GraphicsMagick, Inkscape, and more. Compose at /opt/convertx/docker-compose.yml. Data persisted in named volume convertx-data. Account registration disabled after first account creation (ACCOUNT_REGISTRATION=false). Converted files auto-delete after 24 hours (AUTO_DELETE_EVERY_N_HOURS=24). HTTP_ALLOWED=true set for local HTTP access.

SMB Shares
Share Path Access Purpose
Review /Biggest/Maple read/write, user: bee Archive data on mirrored drives (Amigo, Ichabod, Monte)
Sync /nvmepool/sync read-only, user: bee Mac Studio SYNC mirror
Music /nvmepool/music read/write, user: bee Music library (33,654 tracks)
Books /nvmepool/books read/write, user: bee Book library
Movies /nvmepool/movies read/write, user: bee Movie library
Video /nvmepool/video read/write, user: bee Video library
Seedbox β€” β€” β€”
Media Staging /Biggest/media-staging read/write, user: bee Staging area on mirrored drives
backups /backuppool read-only, user: bee Proxmox dumps/ISOs
nvmepool-backup /Biggest/nvmepool-backup read-only, user: bee Nightly nvmepool backup

All shares configured in /etc/samba/smb.conf (no registry shares). valid users = bee, ownership standardized to bee:bee across all datasets. Apple vfs objects = fruit streams_xattr for macOS compatibility.

Mac Finder access: smb://192.168.8.221/<share_name> or via Network β†’ PVE (Avahi/mDNS advertised).

Seedbox Pipeline

The seedbox is a remote Usenet server at ismene.usbx.me (IP 46.232.210.50). NZBGet runs on the seedbox and downloads to categorized folders. Two SSH tunnels on Proxmox expose the seedbox UIs locally, and cron scripts pull completed files down.

Data flow:

  1. Sonarr/Radarr/Lidarr request content β†’ send to NZBGet (Usenet) or Transmission (torrents) on seedbox
  2. NZBGet/Transmission download to completed/ directories
  3. seedbox-sync.sh (every 15 min) pulls completed downloads to /nvmepool/ingest/
  4. *arr apps detect, rename, and move files to final libraries (movies, tv, music, books)
  5. Plex library scan triggered automatically via *arr notification on import
  6. Plex/Navidrome serve from nvmepool

Download clients on seedbox (ismene.usbx.me):

Client Protocol Local Tunnel Seedbox Port Auth
NZBGet Usenet 192.168.8.221:16789 13036 β€”
NZBHydra2 Usenet meta 192.168.8.221:15076 13033 delgross
Transmission Torrent 192.168.8.221:13010 13010 delgross

Indexers (Prowlarr):

Indexer Protocol Type RSS Auto-search
NZBgeek Usenet Private On On
NZBFinder Usenet Private On On
altHUB Usenet Private Off Off (interactive only β€” API abuse prevention)
The Pirate Bay Torrent Public On On
Sync & Backup Scripts

Mac Studio Sync:

Script Schedule Source Destination Notes
sync-mac.sh DISABLED (Apr 13, 2026) bee@192.168.8.180:/Users/bee/SYNC/ /nvmepool/sync/ Was failing with rsync protocol error (exit 12). Syncthing may cover this path.

Backups:

Job Schedule Scope Compression Retention Storage
vzdump-daily 2:00 AM All VMs/CTs zstd 3 copies backup-hdd (/backups/dump/dump/)
Docker prune Sundays 4:00 AM CT100 β€” β€” Cleans dangling containers, networks, images
Radarr start Midnight CT100 β€” β€” Starts Radarr for nightly indexer hits
Radarr stop 5:00 AM CT100 β€” β€” Stops Radarr to limit downloads to off-hours
CWA processed cleanup 5:00 AM CT100 β€” β€” Clears calibre-web/processed_books
Kiwix ZIM watcher Every 5 min CT100 β€” β€” Restarts kiwix-serve when ZIM file list changes (MD5 hash check)

Offsite Backup:

A 20TB Seagate Exos (ST20000NM002C, serial ZXA0FLHC) in an ASMT105x USB 3.2 enclosure serves as the offsite backup drive. Formatted as ZFS pool offsite with zstd compression, atime=off, xattr=sa, ashift=12. Negotiates USB 3.2 Gen 2 (10 Gbps SuperSpeed Plus) on Bus 6 Port 1 β€” critical to plug into the correct USB-A port: the other USB-A ports on the Minisforum Venus are USB 2.0 and will bottleneck transfers to ~42 MB/s. On the USB 3 port, rsync hits ~200 MB/s sustained (bottlenecked by spinning disk sequential write).

Dataset Source Contents
offsite/nvmepool-data /Biggest/nvmepool-backup/ Mirror of nvmepool (music, movies, books, sync, etc.)
offsite/maple /Biggest/Maple/ Unique archive data (Amigo, Ichabod, Monte)
offsite/seedbox β€” Seedbox downloads (placeholder β€” seedbox now on nvmepool/ingest)
offsite/ct100-backups /backups/dump/ Vzdump CT100 backups

Script: /usr/local/bin/offsite-backup.sh β€” rsync with --delete for incremental updates. Workflow: connect drive β†’ zpool import offsite β†’ offsite-backup.sh β†’ zpool export offsite β†’ disconnect and take offsite.

Health Monitoring (v2, updated Apr 18 2026):

Script: /usr/local/bin/system-health-check.sh β€” runs every 15 min via /etc/cron.d/system-health-check. Pushes alerts to Gotify. Checks: root disk space, all 4 active ZFS pools (nvmepool, Biggest, backups, offsite β€” health + suspended + capacity + removed/faulted vdevs), backup age/location, USB hub errors and pool suspension events, snapshot counts, key services (pveproxy, pvedaemon, smbd). Daily summary at 7 AM.

ZFS Maintenance:

Task Schedule Pool
Auto-snapshot Every 15 min (keep 4 frequent, 24 hourly, 31 daily, 8 weekly, 12 monthly) All
Scrub Biggest 1st of month, 3 AM Biggest
Scrub nvmepool 8th of month, 3 AM nvmepool
Scrub backups 22nd of month, 3 AM backups
Security
Service Config
UFW Active β€” default DROP on INPUT. Allowed: SSH (22), Proxmox (8006), SMB (445, 139), VNC (5900-5999), Spice (3128), node-exporter (9100 from 192.168.8.0/24 β€” added Apr 21 for Prometheus)
Fail2Ban Active β€” jails: proxmox, sshd
SSH Key-based auth to seedbox (id_ed25519) and Mac Studio (id_rsa)
Monitoring & Notifications

Uptime Kuma (kuma.edmd.me) β€” 60 monitors covering:

Category Monitors Check Interval
Internet connectivity Google, Cloudflare, DNS 8.8.8.8 60s
Network infrastructure Router, CT100 ping 60-120s
CT100 Docker services Plex, Navidrome, CWA, Portainer, Gotify, FreshRSS, N8N, Audiobookshelf, Lidarr, Bookshelf, Shelfmark, Prowlarr, Radarr, Sonarr, Dozzle, FlareSolverr, Homepage, Prometheus, Grafana, Wallabag, Kiwix, ConvertX 120s
CT101 Docker services Immich 60s
Proxmox host Web UI, Cockpit, SMB, Syncthing, NZBGet tunnel, NZBHydra2 tunnel 120-300s
Mac Studio Ping, SSH, Life Archive API, Paperless-NGX, Syncthing, LM Studio, Embed Server, Hugo Bee Hub 120-300s
VPS Ping, Bee Hub (VPS) 120-300s
SSL certificates ha.edmd.me 3600s
Keyword health checks Plex API, Navidrome API, Portainer API 300s
Farm Home Assistant, Caddy, Pi-hole, Portainer, Uptime Kuma, Gotify (all via Caddy f-prefix URLs) 120s
Seedbox SSH 300s
Weather Davis VP2 (192.168.8.245/realtime.txt) 300s

Notification chain: Uptime Kuma β†’ Gotify (pri 8) β†’ Telegram bridge β†’ @beenetworkbot

Notification priority tiers:

Priority Tier Telegram? Sources
8-9 πŸ”΄ Critical Yes Uptime Kuma down alerts, ZFS errors, Prometheus critical alerts (disk >90%, OOM, freeze), *arr health failures
5-7 🟑 Warning Yes Prometheus warnings (disk >80%, high CPU/RAM), frost warning, cron errors
2-3 πŸ“’ Info Gotify only Grabs, downloads, daily health reports, Watchtower, books ingest, reboot notices
0 Silent No Watchtower container updates

All *arr apps (Lidarr, Sonarr, Radarr, Bookshelf) have split Gotify notifications: “Gotify (Info)” at priority 2 for grabs/downloads, “Gotify (Alert)” at priority 8 for health issues and failures.

Cron errors on hpve and CT100 are captured by cron-gotify-wrapper.sh and pushed to Gotify at priority 5 (warning β†’ Telegram). MAILTO="" is set in both crontabs.

Gotify-Telegram Bridge (Docker, /opt/gotify-telegram/): Polls Gotify every 10 seconds via client token. Three-tier priority filtering (Apr 21 2026): only messages with priority β‰₯5 are forwarded to Telegram (πŸ”΄ β‰₯8 critical, 🟑 5-7 warning). Messages with priority <5 (grabs, routine reports, Watchtower) stay in Gotify only. This prevents notification floods from burying real alerts.

Prometheus + Alertmanager (Docker, monitoring_monitoring network): 19 alert rules across 4 severity groups: critical (host down, disk >90%, OOM, predictive fill), warning (disk >80%, high CPU/RAM/IO, container resource spikes), info (reboots, failed systemd units). Alertmanager routes to a custom Gotify webhook bridge (alertmanager-gotify container) that maps severity β†’ Gotify priority β†’ Telegram filtering. Prometheus scrapes 6 targets: itself, node-ct100, node-proxmox, cadvisor, weather-exporter, alertmanager. Retention: 5 years (1825d). Weather alerts: FrostWarning (<36Β°F), FreezeAlert (<32Β°F), HighWindAlert (>40mph gust), HeavyRain (>1in/hr).

Setting Value
Telegram Bot @beenetworkbot
Telegram Chat ID 5289824155
Gotify App Token ARCkVc0wf001L.e
Gotify Client Token COXHgqAwb_mZdz0

Health Check Script (/usr/local/bin/system-health-check.sh): Runs every 15 min via cron (wrapped with cron-gotify-wrapper.sh for stderr capture). Monitors root disk space, all 4 ZFS pools (nvmepool, Biggest, backups, offsite β€” health/suspended/capacity/vdevs), backup age, USB hub errors, snapshot counts, key services. Daily summary at 7 AM. Alerts via Gotify β†’ Telegram. Updated Apr 18 2026.

Access Reference
Method Command / URL
Web UI https://192.168.8.221:8006
Cockpit https://192.168.8.221:9090
SSH ssh root@192.168.8.221
SMB (Music) smb://192.168.8.221/Music (user: bee)
SMB (Movies) smb://192.168.8.221/Movies (user: bee)
SMB (Books) smb://192.168.8.221/Books (user: bee)
SMB (TV) smb://192.168.8.221/TV (user: bee)
SMB (Review) smb://192.168.8.221/Review (user: bee)
NZBGet UI http://192.168.8.221:16789 (tunneled from seedbox)
NZBHydra2 UI http://192.168.8.221:15076 (tunneled from seedbox)
Plex http://192.168.8.100:32400/web
Calibre-Web http://192.168.8.100:8083
Remote Access
How it works

NetBird is a WireGuard-based mesh VPN. Every device you want to access the home or farm LAN from joins a peer-to-peer mesh. Once connected:

  • Clean URLs: https://immich.edmd.me, https://portainer.edmd.me, etc.
  • Real HTTPS (wildcard *.edmd.me cert from Let’s Encrypt)
  • Works from anywhere β€” home wifi, LTE, hotel, anywhere
  • No split-tunnel issues: the mesh only routes mesh-specific traffic
Laptop (anywhere) β†’ WireGuard β†’ [P2P direct OR relayed]
    ↓
  hpve peer (Proxmox at 192.168.8.221)
    ↓ (LAN)
  CT103 Caddy (192.168.8.54)
    ↓ (wildcard HTTPS + reverse proxy)
  Services on CT100 (192.168.8.100), CT101, etc.

The magic: DNS for *.edmd.me resolves to 192.168.8.54 (CT103). Your device reaches 192.168.8.54 through the NetBird mesh. Caddy on CT103 looks at the Host header and proxies to the right backend.

Peers

8 peers as of May 2026 (verified against netbird.list_peers):

Peer NetBird IP LAN IP Routes Role
hpve 100.123.31.199 192.168.8.221 192.168.8.0/24 Home Proxmox β€” subnet router for home LAN
fpve 100.123.49.175 192.168.0.191 192.168.0.0/24 Farm Proxmox β€” subnet router for farm LAN (currently unreachable)
vps 100.123.69.155 172.93.50.184 β€” Public VPS (edge01) β€” Bee Hub mirror, SMB share at files.edmd.me
studio 100.123.217.253 192.168.8.180 β€” Mac Studio
macbook 100.123.191.145 192.168.8.218 β€” MacBook (parity sync with Studio)
iphone varies β€” β€” iPhone, always-on NetBird
ipad varies β€” β€” iPad
roon 100.123.169.114 192.168.8.105 β€” CT105 Roon Server β€” Roon ARC remote streaming

Because hpve and fpve are subnet routers, any peer with the right route enabled reaches both LANs through them β€” you don’t need a peer on each LAN, just the mesh.

Add more peers in the NetBird dashboard. For details on per-peer setup, group membership, and routing, see NetBird Reference.

Connecting a new device
  1. Install the NetBird client β€” netbird.io/download for Mac/Windows/Linux, App Store for iOS/Android
  2. Sign in with the same account that owns the existing peers
  3. On the laptop: netbird up (Mac/Linux) or click Connect in the tray app
  4. Verify: curl -s https://hub.edmd.me/ | head β€” should return Bee Hub HTML

That’s it. DNS, routing, and certs are all automatic β€” Pi-hole serves the right addresses, Caddy serves the right certs.

Services directory

Once connected, use any of the 41 HTTPS URLs at *.edmd.me:

Media & Arr: plex Β· calibre Β· lidarr Β· sonarr Β· radarr Β· prowlarr Β· bookshelf Β· audiobookshelf Β· navidrome Β· kiwix

Automation & Monitoring: n8n Β· kuma Β· gotify Β· grafana Β· prometheus Β· dozzle

Reading & Content: freshrss Β· wallabag Β· immich Β· shelfmark Β· aurral

Utilities: homepage Β· convertx Β· flaresolverr

Infrastructure Admin: portainer Β· proxmox Β· cockpit Β· pihole

This site: hub

See the Caddy page for the full catalog with aliases.

On your phone / iPad
  1. Install the NetBird app from App Store or Play Store
  2. Sign in with your NetBird account
  3. Tap Connect

Same URLs work from mobile. For Navidrome in Subsonic-compatible music apps (Amperfy, play:Sub, iSub):

Field Value
Server URL https://navidrome.edmd.me
Username your Navidrome login
Password your Navidrome password
At home vs. away

NetBird advertises LAN routes (192.168.8.0/24) to peers. Unlike Pangolin, this works transparently whether you’re at home or not β€” packets to the LAN stay on the LAN when possible, and tunnel through when remote.

You can leave NetBird connected all the time on your laptop, phone, iPad. It won’t interfere with local networking.

Latency expectations
Scenario Expected latency
At home on LAN, direct <5ms
At home via NetBird (going through mesh) 1-3ms added
Off-LAN, P2P over IPv4 ~50-100ms
Off-LAN, P2P over IPv6 ~40-80ms (better)
Off-LAN, relayed ~80-150ms

To force P2P (avoid relays): enable IPv6 on both ends, or port forward UDP 51820 on the home router to Proxmox.

Troubleshooting
Problem Fix
URL returns “Not configured” 404 That subdomain isn’t in Caddy’s Caddyfile. Check @matcher entries on CT103
URL returns Cloudflare error DNS record might be proxied (orange cloud). Should be DNS-only (gray cloud) since 192.168.8.54 is private
Connection timeout NetBird client not connected. Check tray/menu status
Slow interactive (SSH lag) Connection is relayed. Enable IPv6 on routers or port forward WireGuard to force P2P
Cert error in browser Should never happen β€” Let’s Encrypt wildcard covers every *.edmd.me name
Services Directory

Master directory of all web-accessible services across Home, Mac Studio, Proxmox, VPS, Seedbox, and Farm. Updated May 22, 2026.

Docker β€” CT 100 (192.168.8.100)

Managed via Portainer. Running on Proxmox LXC Container 100 (Ubuntu 24.04, 4 cores, 16 GB RAM).

Service Port URL Description
Portainer 9443 https://192.168.8.100:9443 Container management UI
Gotify 8070 http://192.168.8.100:8070 Push notification server β€” forwards all alerts to Telegram via bridge
Gotify-Telegram Bridge β€” β€” Polls Gotify, forwards to Telegram @beenetworkbot (chat ID: 5289824155). Retry logic (3 attempts), writes health.json for external monitoring. Only advances message pointer on successful send. iMessage fallback via Mac Studio com.edmd.check-telegram-bridge launchd agent (every 30 min)
Uptime Kuma 3001 http://192.168.8.100:3001 60+ monitors β€” services, infrastructure, SSL certs, keyword health checks. Includes WG tunnel exit-IP check, OPNsense Web UI, Pi-hole edmd.me wildcard health, OPNsense Unbound DNS health. (Vaultwarden alive check removed May 2026 with the service.) ha-mcp farm monitor pending farm-LAN return. Alerts via Gotify β†’ Telegram
Audiobookshelf 13378 http://192.168.8.100:13378 Audiobook & podcast server
Navidrome 4533 http://192.168.8.100:4533 Music streaming (Subsonic-compatible)
Lidarr 8686 http://192.168.8.100:8686 Music collection manager
Bookshelf 8787 http://192.168.8.100:8787 Book tracking (Hardcover)
Shelfmark 8084 http://192.168.8.100:8084 Book & audiobook search β€” outbound via WireGuard tunnel to UltraCC NL (Apr 29 2026). Hardcover + Anna’s Archive lookups. Public URL: shelfmark.edmd.me
Prowlarr 9696 http://192.168.8.100:9696 Indexer aggregator β€” outbound via WireGuard tunnel to UltraCC NL. Feeds Sonarr/Radarr/Lidarr/Shelfmark. altHUB enabled
FreshRSS 8180 http://192.168.8.100:8180 RSS feed reader
Plex 32400 http://192.168.8.100:32400/web Media server β€” movies, music, photos, video, audiobooks. Plexamp for music on iOS/Mac
Calibre-Web (CWA) 8083 http://192.168.8.100:8083 Book library β€” auto-ingest, auto-convert, duplicate detection, metadata fetch. Filebot also installed on CT100 for movie renaming
PostgreSQL 17 5432 β€” farmdb, bookdb, moviedb, nocodb_meta. User: farmuser. Used by Farm MCP, Directus, NocoDB
Directus 8055 https://directus.edmd.me Headless CMS over farmdb β€” admin/form-shaped UI for 34 farm tables. All collections registered with icons + display templates (Apr 30 2026)
NocoDB 8080 https://nocodb.edmd.me Spreadsheet UI over farmdb (alongside Directus, comparing UX). Metadata in separate nocodb_meta DB. Requires NC_ALLOW_LOCAL_EXTERNAL_DBS=true for private-IP DB connections
MakeMKV 5800 https://makemkv.edmd.me BluRay/DVD ripper (jlesage/makemkv). VNC web UI. Reads from /mnt/seedbox/movies (read-only), outputs to /mnt/container-data/makemkv/output. Requires MAKEMKV_KEY="" to skip beta-key fetch
Kiwix 8186 https://kiwix.edmd.me Offline Wikipedia + Project Gutenberg + Wikibooks + Wikiversity + Wikisource + Wikivoyage + ifixit + urban-prepper. ~222 GB of ZIM files on /Biggest/Kiwix
Unbound 5335 β€” Recursive DNS resolver (mvance/unbound). Primary upstream for Pi-hole β€” resolves directly from root servers for DNS privacy (no single upstream sees all queries). Custom config at /opt/unbound/conf/unbound.conf with sane cache sizes (50m msg / 100m rrset), logging enabled, DNSSEC validation. Health check verifies NOERROR (not just response). Fixed May 10 2026: image auto-calculated 33GB cache from host RAM causing OOM
Farm Species Browser 8420 https://farm.edmd.me Web UI for browsing the farm species catalog β€” search, filter by category/native status, species detail pages with growing conditions, pollinator info, and collection membership. FastAPI + asyncpg
Sonarr 8989 http://192.168.8.100:8989 TV series management β€” monitors, searches, downloads via Prowlarr + Transmission. Root folder /mnt/tv
Radarr 7878 http://192.168.8.100:7878 Movie collection management β€” monitors, searches, downloads via Prowlarr + Transmission. Root folder /mnt/movies
Recyclarr β€” β€” Auto-syncs TRaSH Guides quality profiles to Sonarr + Radarr
FlareSolverr 8191 β€” Cloudflare challenge bypass proxy for Prowlarr indexers
Wallabag 8081 https://wallabag.edmd.me Read-later / article archive (+ wallabag-db PostgreSQL + wallabag-redis)
Readability 3333 β€” JS-based article text extractor β€” used by Wallabag for clean content parsing
Authentik 9100 https://auth.edmd.me SSO identity provider β€” forward-domain auth for all *.edmd.me services via Caddy. Embedded outpost, OAuth2/OIDC. Cookie domain edmd.me means one login covers everything. See Authentik page
Grafana 3200 https://grafana.edmd.me Dashboards and visualization β€” Prometheus + Loki datasources
Prometheus 9090 β€” Metrics collection β€” scrapes node-exporter, cAdvisor, weather-exporter
Loki 3100 β€” Log aggregation β€” receives journald + container logs via Alloy from hpve + all CTs + Mac Studio
Alertmanager 9093 β€” Prometheus alert routing β€” forwards to Gotify via alertmanager-gotify bridge
cAdvisor 8180 β€” Container resource metrics for Prometheus
Node Exporter 9100 β€” Host-level metrics (CPU, RAM, disk, network) for Prometheus
Weather Exporter β€” β€” Custom Prometheus exporter for local weather data
ConvertX 3000 https://convertx.edmd.me File format converter (documents, images, media)
Aurral 8095 β€” Music discovery companion for Lidarr β€” finds new releases and recommendations
Watchtower β€” β€” Auto-updates Docker container images on a schedule
Dozzle 9999 https://dozzle.edmd.me Real-time Docker container log viewer
Homepage 3000 https://home.edmd.me Homelab dashboard β€” service status, bookmarks, widgets
Roon Server β€” CT 105 (192.168.8.105)

Dedicated LXC container on hpve, Debian 12, 4 cores, 8 GB RAM, 16 GB rootfs on nvme-ct. Runs Roon Server with music library access via bind mount from nvmepool.

Service Port URL Description
Roon Server 9100-9200 β€” Music server β€” manages Tidal integration + local library. Roon clients (iOS, Mac, web) connect via discovery or NetBird. Systemd: roonserver.service

Music storage: /mnt/music (bind mount from /nvmepool/music on hpve)

NetBird: Connected as peer roon (100.123.169.114) in BeeDifferent group. Roon ARC connects remotely via NetBird mesh.

CT 104 β€” RETIRED
CT104 hosted Vaultwarden (192.168.8.55) until May 2026, when both the LXC and the service were decommissioned. Credentials moved to ~/Sync/ED/SECRETS.md + per-service secrets.env files. The 192.168.8.55 address is unused. See Vaultwarden tombstone.
Biggest Pool β€” ZFS Mirror (2x 18TB Seagate)

Two Seagate 20TB drives (ST20000NE000) in ZFS mirror via USB ASMT enclosures. ORICO 9858T3 Thunderbolt 3 enclosure on order to replace USB connection. Special vdev (Optane) and cache SSD removed Apr 7 2026. Pool is now a clean 2-drive mirror.

Dataset Size Contents
Biggest/Maple/Amigo 3.0 TB Biggie, Cell Photos, ISO, TV, Video
Biggest/Maple/Ichabod 2.7 TB Movies (source copy), Music, Databases, Podcasts, Remote Backup
Biggest/Maple/Monte 2.7 TB Dropbox backups, Mystuff, PDF, Photos

Deleted Apr 7: Speedy, TimeMachineOne (4.3TB), Ichabod/Sort (232GB), Amigo/delgross (4TB), Amigo/Youtube (148GB). Pool went from 90% β†’ 41%.

Proxmox Host (192.168.8.221)
Service Port URL Description
Proxmox VE 8006 https://192.168.8.221:8006 Hypervisor web UI
Cockpit 9090 https://192.168.8.221:9090 System admin panel
Syncthing 8384 http://192.168.8.221:8384 File sync hub β€” always-on relay for Mac Studio and MacBook
NetBird β€” β€” Mesh VPN daemon (netbird.service); advertises 192.168.8.0/24 to mesh
Transmission RPC (tunneled) 13010 β€” SSH tunnel β†’ seedbox Transmission. Used by *arr apps for download management
SMB Shares 445 smb://192.168.8.221/<share> Network file shares: nvmepool (all NVMe media), Seedbox (downloads), Biggest (archive), Sync (read-only), Big. Avahi/mDNS advertised as “PVE”
Mac Studio (192.168.8.180)
Service Port URL Description
Hugo Hub 1313 http://192.168.8.180:1313 BeeDifferent documentation site
SyncThing 8384 http://192.168.8.180:8384 File sync between devices
Paperless-NGX 8100 http://192.168.8.180:8100 Document management system
Life Archive API 8900 http://192.168.8.180:8900 Life Archive RAG search API
Life Archive MCP 8901 http://192.168.8.180:8901/mcp MCP server for remote Claude clients
Embed Server 1235 http://localhost:1235 gte-Qwen2-7B on MPS (local only)
SSH 22 ssh bee@192.168.8.180 Remote shell
Screen Sharing 5900 vnc://192.168.8.180 macOS VNC
VPS β€” edge01 (SSDNodes)
Service Port URL Description
Caddy (public) 443 troglodyteconsulting.com Public web server for troglodyteconsulting.com (HTTP-01 cert)
Cockpit 9090 https://<vps-ip>:9090 System admin panel
NetBird β€” β€” Mesh peer β€” NetBird IP 100.123.69.155
SSH 22 ssh admin@<vps-ip> Remote shell

Pangolin retired April 19, 2026. The VPS no longer hosts a Pangolin dashboard. Remote access is via NetBird mesh; public web hosting moved to Caddy directly.

Seedbox β€” ismene.usbx.me

Services on the seedbox are accessed via SSH tunnels through Proxmox (hpve, 192.168.8.221) and CT100 (192.168.8.100).

Service Local Tunnel Description
Transmission RPC hpve :13010 BitTorrent download management β€” transmission-tunnel.service on hpve
Transmission RPC CT100 :13010 Same, via autossh-transmission.service on CT100
NZBGet CT100 :16789 Usenet downloader β€” autossh-nzbget.service on CT100
Farm β€” Brownsville (192.168.0.x β€” NetBird Mesh)

Farm runs on the 192.168.0.x subnet (separate from home’s 192.168.8.x). Connected via NetBird mesh β€” fpve peer at 192.168.0.191 advertises 192.168.0.0/24 to the mesh. Farm services use f-prefixed hostnames (fpve.edmd.me, fpihole.edmd.me, etc.).

Service Port URL Description
Home Assistant 8123 ha.edmd.me Smart home automation, irrigation, cameras
Farm Proxmox 8006 fpve.edmd.me Farm hypervisor, NetBird peer (fpve)
Farm Portainer 9443 fportainer.edmd.me Farm container management
Farm Pi-hole β€” fpihole.edmd.me Farm DNS + ad blocking
Farm Uptime Kuma 3001 fkuma.edmd.me Farm uptime monitoring
Farm Gotify 8070 fgotify.edmd.me Farm push notifications
Omada Controller β€” omada.edmd.me TP-Link Omada controller
Network Infrastructure
Device IP URL Description
OPNsense Router 192.168.8.1 https://192.168.8.1 Home router, hostname orchard.edmd.me. Replaced GL.iNet on Apr 29 2026
Homey 192.168.8.224 β€” Smart home hub
Weather Station 192.168.8.245 β€” Orchard-Weather
Shelfmark

Shelfmark is a self-hosted book and audiobook search aggregator. It searches multiple sources simultaneously and returns results in a unified interface. All outbound traffic exits via the seedbox in the Netherlands through a persistent SOCKS5 tunnel.

Configuration
Setting Value
URL http://192.168.8.100:8084
Host CT 100 (192.168.8.100)
Image ghcr.io/calibrain/shelfmark:latest
Version v1.2.0 (build 2026-03-07)
Port 8084
Compose file /opt/shelfmark/docker-compose.yml (on CT 100)
Proxy mode SOCKS5 via 172.25.0.1:1080 (Docker bridge to CT 100 host)
Proxy exit ismene.usbx.me (Netherlands, UltraSeedbox)
Remote access Caddy private resource only β€” not publicly accessible
Metadata provider Hardcover

Docker volumes:

Container path Host path (CT 100) ZFS dataset Purpose
/books /mnt/seedbox/Books nvmepool/ingest Downloaded books β€” shared with Readarr (Bookshelf)
/config /opt/shelfmark/config CT 100 rootfs Settings, users.db, cover cache

Previously /books pointed to /opt/shelfmark/books (isolated from Readarr). Changed 2026-03-31 to /mnt/seedbox/Books so Shelfmark downloads land directly in the seedbox Books folder where Readarr can see them.

Docker environment:

PROXY_MODE=socks5
SOCKS5_PROXY=socks5://172.25.0.1:1080
NO_PROXY=localhost,127.0.0.1,192.168.8.*,172.25.0.*
TZ=America/New_York
PUID=0
PGID=0

PUID/PGID set to 0 (root): The seedbox Books folder receives files via rsync with UID 1040, which Shelfmark’s default appuser (UID 1000) cannot write to. Running as root prevents “destination not writable” errors during post-processing. Changed 2026-04-02.

SOCKS5 Proxy Architecture

Shelfmark routes all search and download traffic through the seedbox to avoid region-based restrictions. The chain is:

Shelfmark (CT 100 Docker)
    ↓ SOCKS5 to 172.25.0.1:1080 (Docker bridge β†’ CT 100 host)
seedbox-socks.service (autossh, CT 100 systemd)
    ↓ SSH tunnel to delgross@46.232.210.50
ismene.usbx.me (Netherlands exit, UltraSeedbox)
    ↓
Book search sources (Anna's Archive, Libgen, etc.)

Check proxy tunnel status:

# SSH into CT 100 first
ssh root@192.168.8.221
pct exec 100 -- bash
# Then:
systemctl status seedbox-socks.service

Restart proxy tunnel:

systemctl restart seedbox-socks.service

If Shelfmark searches fail or time out: The SOCKS5 tunnel is almost always the culprit. Check the service status above. The tunnel reconnects automatically via autossh, but occasionally needs a manual restart after seedbox connectivity issues.

Prowlarr Integration

Prowlarr runs on CT100 as an indexer aggregator, providing Usenet-based book search as an additional release source alongside the direct download sources (Anna’s Archive, Libgen, etc.). Prowlarr routes all traffic through the same SOCKS5 seedbox tunnel as Shelfmark.

Setting Value
URL http://192.168.8.100:9696
Image lscr.io/linuxserver/prowlarr:latest
Compose file /opt/prowlarr/docker-compose.yml (on CT 100)
API Key 2adb6f9d248840bcadc0ab93222b78fd
Auth Forms (user: bee), disabled for local addresses
SOCKS5 proxy 172.26.0.1:1080 (Prowlarr Docker bridge gateway β†’ CT 100 host tunnel)
Bypass 192.168.8.*,localhost,127.0.0.1,172.26.0.*

Indexers configured:

Indexer Type API Key Book categories
altHUB Newznab (Usenet) f0d9327bc1db3011025b40176ec6955a 7000 (Books), 107020 (Ebook), 107030 (Comics), 107010 (Mags)

Shelfmark connection: Enabled in Shelfmark Settings β†’ Prowlarr with auto-expand search on. When Shelfmark searches for a book, it queries both direct_download and prowlarr sources simultaneously. Prowlarr found 115 additional books that direct download sources couldn’t locate.

Note: Prowlarr’s Docker network (prowlarr_default) uses gateway 172.26.0.1, which is different from Shelfmark’s network (shelfmark_default, gateway 172.25.0.1). Both reach the same SOCKS tunnel on 0.0.0.0:1080 on the CT100 host, just via different Docker bridge IPs.

Kindle Library Import

A Python script at ~/Sync/ED/homelab/book_library/kindle_to_shelfmark.py automates bulk importing from the Kindle library into Shelfmark. It reads the Kindle NZB results JSON (1,773 books with titles, authors, and ASINs) and for each book: searches Shelfmark’s Hardcover metadata provider, finds downloadable releases, and queues the best epub for download.

First full run (2026-03-31):

Metric Count
Total processed 1,773
Metadata found 1,732 (97.7%)
Metadata not found 41
Releases found 1,273 (73.5% of metadata matches)
Releases not found 459
Queued for download 1,349 (1,234 first run + 115 Prowlarr retry)
Queue failures 39 (mostly duplicates already in queue)

Usage:

cd ~/Sync/ED/homelab/book_library

# Dry run β€” search only, don't download
python3 kindle_to_shelfmark.py --dry-run --skip-existing

# Full run β€” queue all downloads
python3 kindle_to_shelfmark.py --skip-existing --delay 5

# Resume after interruption
python3 kindle_to_shelfmark.py --skip-existing --resume

# Process in batches
python3 kindle_to_shelfmark.py --skip-existing --limit 50

Files:

File Purpose
kindle_to_shelfmark.py Import script
kindle_nzb_results.json Source data β€” 1,773 Kindle books with ASIN, title, author
shelfmark_state.json Resume state β€” tracks which ASINs have been processed
shelfmark_import_*.log Timestamped log files for each run

How the script works:

  1. For each Kindle book, search Shelfmark metadata API (/api/metadata/search) using title + author
  2. Find best match by title word overlap (β‰₯40% threshold)
  3. Search for downloadable releases (/api/releases) using the matched provider/book_id
  4. Score releases β€” prefer epub format, reasonable file size (0.5–100 MB)
  5. Queue the best release via /api/releases/download
  6. Save state after every 20 books for resume capability
Shelfmark API

Shelfmark has no authentication enabled (auth_mode: none). All API endpoints are accessible without credentials.

Endpoint Method Purpose
/api/health GET Health check
/api/metadata/search?query=...&limit=N GET Search book metadata (Hardcover)
/api/releases?provider=...&book_id=... GET Search downloadable releases for a book
/api/releases/download POST Queue a release for download
/api/localdownload GET List locally downloaded books
/api/downloads/active GET Active download queue
/api/config GET Current configuration
Download Sources

Shelfmark searches multiple sources in priority order until a download succeeds.

Fast sources (tried first):

Source Status Notes
AA Fast Downloads βœ… Active Requires donator key. Dedicated fast servers, typically 2-4 MB/s.
Library Genesis βœ… Active Default mirrors: libgen.gl, .li, .bz, .la, .vg

Slow sources (fallback):

Source Status Notes
AA Slow (No Waitlist) βœ… Active Partner servers, no countdown
AA Slow (Waitlist) βœ… Active Partner servers with countdown timer
Welib βœ… Active Alternative mirror, requires Cloudflare bypass
Zlib βœ… Active Z-Library mirror, requires Cloudflare bypass

Additional sources:

Source Status Notes
Prowlarr (altHUB) βœ… Active Usenet indexer, finds books not in direct download sources

Anna’s Archive donator key is configured in Settings β†’ Download Sources. This unlocks the AA Fast Downloads tier with dedicated servers instead of the free mirrors that crawl at 3-10 KB/s.

DNS-over-HTTPS (DoH) is disabled in Shelfmark’s network config (/opt/shelfmark/config/plugins/network.json, USE_DOH: false). DoH via Quad9 was returning 400 errors for several domains when routed through the SOCKS tunnel, causing all post-processing to fail. System DNS resolution works correctly as a fallback.

Remote Access

Shelfmark is configured as a Caddy private resource β€” it’s reachable remotely without opening any public ports. Connect via the Caddy VPN client and access it at http://192.168.8.100:8084 as if you’re on the home network.

It does not have a public subdomain β€” intentionally kept private since it’s a search aggregation tool.

Spotify MCP

Custom MCP server at ~/.mcp-servers/spotify-mcp/server.py exposing 28 tools backed by the Spotify Web API. Built to back playlist-cleanup workflows (“dedupe”, “cap to 60”, “one per artist”, “atomic rewrite from a curated list”) plus general daily Spotify operation β€” listening history, top tracks, library management, basic playback control. Authenticated via Authorization Code + PKCE with a refresh-token cache on disk.

Why this exists

The Cowork-managed Spotify connector is intentionally read/discover/create-only β€” it can search the catalog, create playlists, and report what’s playing, but it cannot read existing playlist contents, remove tracks, or reorder. That’s a Spotify partner-integration policy, not a Claude limitation.

The four community Spotify MCPs on GitHub (josuemj, Carrieukie, marcelmarais, PeterAkande) all wrap the Web API directly with a self-managed OAuth app, which is where the destructive playlist ops live. Rather than depend on a third-party fork, this MCP follows the same pattern Ed uses for every other custom MCP (farm-data, netbird, memory, inaturalist) β€” local Python + FastMCP under ~/.mcp-servers/, secrets in the Claude Desktop config env block.

Tool surface (28 tools)
Category Tools
Reads list_my_playlists, get_playlist, get_playlist_tracks, search, get_track, get_currently_playing, get_recently_played, get_top_tracks, get_top_artists, get_saved_tracks, check_library_contains, get_my_profile
Playlist writes create_playlist, update_playlist_details, add_tracks_to_playlist, remove_tracks_from_playlist, reorder_playlist_tracks, replace_playlist_tracks
Library writes save_tracks, remove_saved_tracks, save_to_library, remove_from_library
Playback pause, resume, skip_next, skip_previous, set_volume, add_to_queue

All write tools are explicit β€” no “delete by criteria” footguns. replace_playlist_tracks is the canonical “rewrite this playlist with my curated list” operation: atomic 100-track PUT followed by 100-track POSTs for anything beyond. remove_tracks_from_playlist optionally takes a snapshot_id for concurrent-edit detection. Auto-pagination is built into add/remove/replace and the library writes (Spotify caps at 100 or 50 per call depending on endpoint).

OAuth setup (one-time, ~5 minutes)
  1. Create a Spotify Developer app at developer.spotify.com/dashboard:

    • App name: any (e.g. Ed's MCP)
    • App description: any
    • Redirect URI: http://127.0.0.1:8888/callback β€” must match exactly, including the trailing /callback
    • APIs used: Web API (the only one needed)
    • Accept the developer terms
  2. Copy the Client ID from the app’s settings page. (Client Secret is not used β€” PKCE doesn’t need it.)

  3. Patch claude_desktop_config.json β€” the spotify-mcp block has a placeholder; replace REPLACE_WITH_CLIENT_ID_FROM_DEVELOPER_DASHBOARD with the real Client ID. The config lives at ~/Sync/ED/config/claude_desktop_config.json and Syncthing replicates it to both Macs.

  4. Run the login helper once to mint a refresh token:

    cd ~/.mcp-servers/spotify-mcp
    SPOTIFY_CLIENT_ID=<your-client-id> .venv/bin/python3 login.py
    

    It opens your default browser to Spotify’s consent screen, captures the redirect on 127.0.0.1:8888, exchanges the code for tokens, and writes them to .token-cache.json (chmod 600).

  5. Restart Claude Desktop fully (Cmd-Q, then relaunch) so the new MCP loads.

The login helper has to run on each machine that runs Claude Desktop (Studio + MacBook). The token cache file is per-machine β€” simpler than syncing OAuth state across hosts. Pre-shared Syncthing exclusion: .token-cache.json is in .stignore via the per-folder .gitignore-style pattern.

OAuth scopes requested

The login helper requests all 14 scopes the MCP needs so future tool additions don’t require re-authentication:

Scope Purpose
playlist-read-private list + read private playlists
playlist-read-collaborative read collaborative playlists
playlist-modify-private create/edit private playlists
playlist-modify-public edit your public playlists
user-library-read read Liked Songs + saved albums
user-library-modify add/remove Liked Songs
user-read-currently-playing what’s playing
user-read-playback-state device + queue state
user-modify-playback-state pause/resume/skip/volume
user-read-recently-played listening history (last 50 only β€” Spotify limit)
user-top-read top tracks + artists
user-read-private profile read (get_my_profile)
user-follow-read check whether artists/playlists are followed (check_library_contains)
user-follow-modify follow/unfollow artists + playlists via save_to_library / remove_from_library
Claude Desktop config block
"spotify-mcp": {
  "command": "/Users/bee/.mcp-servers/spotify-mcp/.venv/bin/python3",
  "args": ["/Users/bee/.mcp-servers/spotify-mcp/server.py"],
  "env": {
    "SPOTIFY_CLIENT_ID": "<your-client-id>",
    "SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
  }
}

SPOTIFY_TOKEN_CACHE defaults to ~/.mcp-servers/spotify-mcp/.token-cache.json β€” override only if you want the cache somewhere else.

Files on disk
Path What
~/.mcp-servers/spotify-mcp/server.py The MCP server itself (28 tools, ~700 lines)
~/.mcp-servers/spotify-mcp/login.py One-time PKCE OAuth helper
~/.mcp-servers/spotify-mcp/requirements.txt mcp>=1.0.0, httpx>=0.27, pydantic>=2
~/.mcp-servers/spotify-mcp/.venv/ Python 3.14 venv with deps
~/.mcp-servers/spotify-mcp/.token-cache.json Refresh token + cached access token (chmod 600, gitignored, per-machine)
~/.mcp-servers/spotify-mcp/.gitignore excludes .venv/, __pycache__/, .token-cache.json
Example: playlist cleanup workflow

The original use case. Open a chat and say something like “Help me clean up my ‘Drive Music’ playlist β€” dedupe and cap to 90 minutes.” Claude can now:

  1. list_my_playlists β†’ find the playlist id
  2. get_playlist_tracks(id, limit=100) (page until exhausted) β†’ assemble the full track list with duration_ms + popularity + added_at
  3. Reason over the list in chat: identify duplicates, sort by your rule, trim to target length
  4. Confirm the cut list with you
  5. replace_playlist_tracks(id, curated_uris) β€” atomic rewrite

Or for a sibling-playlist workflow (keep the original as backup):

  1. create_playlist("Drive Music β€” cleaned") β†’ new id
  2. add_tracks_to_playlist(new_id, curated_uris) β†’ done
Health + observability

The MCP writes to ~/Library/Logs/Claude/mcp-server-spotify-mcp.log like every other server. check-mcp-health.py (runs 6Γ—/day via com.bee.mcp-health-check) will pick up failures automatically.

Two known failure modes that don’t auto-recover:

  • Refresh token revoked. If you click “remove app” at spotify.com/account/apps the refresh token dies and every call returns HTTP 400 invalid_grant. Fix: re-run login.py.
  • Premium-only endpoints on a Free account. set_volume, pause/resume, queue ops require Premium. Tools return HTTP 403 Premium required. Not a bug.
February 2026 endpoint migration

On 2026-02-06 Spotify announced a reduced Web API surface for Development Mode apps; the changes hit existing integrations on 2026-03-09. Several legacy endpoints were silently removed β€” they now return a bare HTTP 403 Forbidden with no body detail, which is genuinely misleading because it looks identical to a scope error. The MCP was hit by this on 2026-05-26 when every write tool started 403’ing despite a fully scoped + allowlisted token.

What changed:

Removed Replacement
POST /users/{user_id}/playlists POST /me/playlists
PUT /me/tracks (save Liked Songs) PUT /me/library with {"uris": [...]}
DELETE /me/tracks DELETE /me/library
POST /playlists/{id}/tracks POST /playlists/{id}/items
GET /playlists/{id}/tracks GET /playlists/{id}/items
PUT /playlists/{id}/tracks PUT /playlists/{id}/items
DELETE /playlists/{id}/tracks (body key tracks) DELETE /playlists/{id}/items (body key items)
GET /audio-features, GET /audio-analysis (none β€” removed for new dev apps)
GET /search limit=50 GET /search limit=10 max

The response shape also changed: a playlist’s tracks.items.track is now items.items.item, and popularity / available_markets / linked_from are gone from track responses. User profile drops country, email, explicit_content, followers, product.

The server.py rewrite swapped every endpoint, renamed the response field handling, and dropped get_audio_features. Three new tools were added by piggy-backing on the new unified /me/library endpoint that accepts any URI type:

  • save_to_library(uris) β€” saves tracks and albums, episodes, shows, audiobooks, plus follows artists/playlists, in one mixed-URI call
  • remove_from_library(uris) β€” mirror of the save
  • check_library_contains(uris) β€” unified “is this saved/followed?” check

The login.py scope list grew from 12 to 14, adding user-follow-read and user-follow-modify for artist/playlist follow operations via the new generic library endpoints. Existing tokens must be re-minted (re-run login.py) to pick up the new scopes; the migration itself is otherwise transparent.

Backups of the pre-migration code: ~/.mcp-servers/spotify-mcp/server.py.bak-feb2026-* and login.py.bak-feb2026-*.

References:

Syncthing

Peer-to-peer file synchronization across Mac Studio, MacBook, and Proxmox β€” hub-and-spoke topology with Proxmox as the always-on relay. Installed March 27, 2026.

Overview & Architecture

Syncthing provides real-time, encrypted, peer-to-peer file synchronization without relying on cloud services. It replaces iCloud Drive sync for app configuration files (Typinator, BetterTouchTool, etc.) which proved unreliable β€” iCloud aggressively evicts files, struggles with frequently-updated small configs, and silently creates conflict copies instead of merging.

Topology: Hub-and-spoke

Node Role IP Syncthing Port Web UI
Proxmox (pve) Hub β€” always-on relay 192.168.8.221 22000 http://192.168.8.221:8384
Mac Studio Spoke 192.168.8.180 22000 http://127.0.0.1:8384
MacBook Spoke 192.168.8.160 22000 http://127.0.0.1:8384

Both Macs sync exclusively to Proxmox. They do not connect to each other directly. This means changes sync even when one Mac is asleep or powered off β€” Proxmox holds the canonical copy and relays changes when the other Mac comes online.

Data flow:

Mac Studio  ←→  Proxmox (always-on hub)  ←→  MacBook
                     ↓
              /nvmepool/sync/SyncConfigs
              (canonical copy on ZFS)
Device Configuration

Device IDs:

Device ID Addresses
Proxmox (pve) FXMOTJR-XYM6RAO-NIY7KE6-4RPX2M4-NCMYYSG-KZX577Y-6QYSLHH-WL3HVAD tcp://192.168.8.221:22000
Mac Studio UXJMRP2-N2ZX2B7-KWI6OO2-IT7W5LC-GESRIJC-JV2DGTD-FEND4MO-F46LPAS tcp://192.168.8.180:22000
MacBook VLIWDBL-5VD3VSC-XQTOXYS-EGU3NSB-BCHQOML-ACRYBI3-VFT2SPR-WDNBEAF tcp://192.168.8.160:22000

All devices have autoAcceptFolders: true to simplify adding new shared folders.

Shared Folders
Folder ID Label Purpose Shared With
app-configs App Configs Typinator, BetterTouchTool, and other app configuration sync All three devices
claude-config Claude Config Claude Code settings, CLAUDE.md, MCP config (~/.claude/) Mac Studio ↔ MacBook
claude-ed Claude ED All of ~/Sync/ED/ β€” context bundle, TASKS.md, SECRETS.md, skills, memory, homelab docs, scripts Mac Studio ↔ MacBook
mcp-servers MCP Servers Custom MCP server source code (~/.mcp-servers/), excludes .venv/ Mac Studio ↔ MacBook
farm-data-sync Farm Data Emlid exports, voice memos, farm scripts (~/Sync/farm/) Mac Studio ↔ MacBook

Folder paths per device:

Device app-configs claude-config claude-ed mcp-servers
Proxmox /nvmepool/sync/SyncConfigs β€” β€” β€”
Mac Studio ~/Sync/SyncConfigs ~/.claude/ ~/Sync/ED/ ~/.mcp-servers/
MacBook ~/Sync/SyncConfigs ~/.claude/ ~/Sync/ED/ ~/.mcp-servers/

app-configs syncs through all three devices via Proxmox hub. The Claude folders sync directly between the two Macs (bidirectional, no Proxmox relay) β€” Mac Studio is the source of truth.

All folders use Send & Receive mode.

.stignore for mcp-servers:

.venv
__pycache__
node_modules
.DS_Store
*.pyc

Venvs are excluded because they contain platform-specific compiled binaries. A launchd agent on the MacBook (com.bee.rebuild-mcp-venvs) watches ~/.mcp-servers/ and automatically rebuilds venvs when new code arrives via Syncthing.

History: The original setup (2026-05-12) used three separate subfolder syncs (claude-memory, claude-skills, claude-memory) under ~/Sync/ED/. This was replaced on 2026-05-19 with a single parent claude-ed sync covering all of ~/Sync/ED/ to ensure critical files like .claude-context.md, TASKS.md, and SECRETS.md also sync. The mcp-servers folder was added at the same time.

.stignore for claude-ed (~/Sync/ED/.stignore, auto-managed) β€” ~/Sync/ED/ lives inside several huge dirs that would otherwise dominate the sync footprint. The ignore patterns:

**/.venv
**/.venv-*
**/.venv_*
**/__pycache__
**/*.pyc
**/node_modules
**/.DS_Store
homelab/paperless-ngx       # entire app dir, ~126 GB
homelab/antigravity         # research data, ~12 GB
life_archive/data           # LanceDB + everything under it, ~345 GB
life_archive/embeddings     # ~62 GB
life_archive/EmailAttachments  # ~3.2 GB
beedifferent/plant_database # ~40 GB
beedifferent/farm           # ~2.5 GB

Without these exclusions the folder is ~603 GB; with them it’s ~50 GB of context files, skills, scripts, memory, dictation, and Bee Hub source. Studio Syncthing rescans automatically when .stignore changes; MacBook follows on reconnect. The exclusions were expanded from a smaller surgical list on 2026-05-25 after the folder kept failing to converge.

Installation & Service Details

Proxmox (Debian):

Setting Value
Version 1.29.5
Install method apt install syncthing
Service syncthing@root.service (systemd)
Config location /root/.local/state/syncthing/config.xml
API key RzHyGwQhmkvb9A4burcfWGHThGcoThqM
Web UI http://0.0.0.0:8384 (LAN-accessible)

Start/stop/restart:

systemctl start syncthing@root
systemctl stop syncthing@root
systemctl restart syncthing@root
systemctl status syncthing@root

Mac Studio (macOS):

Setting Value
Version 2.0.15
Install method brew install syncthing
Service Homebrew launchd (homebrew.mxcl.syncthing)
Config location ~/Library/Application Support/Syncthing/config.xml
API key CCuJcwA9wTsfDecNXtymtZwfpQvYWAU7
Web UI http://127.0.0.1:8384 (localhost only)

Start/stop/restart:

brew services start syncthing
brew services stop syncthing
brew services restart syncthing
brew services list | grep syncthing

MacBook (macOS):

Setting Value
Install method brew install syncthing
Service Homebrew launchd (homebrew.mxcl.syncthing)
Web UI http://127.0.0.1:8384 (localhost only)

Same brew services commands as Mac Studio.

Firewall & Network

Syncthing uses three ports. All were opened on Proxmox via UFW:

Port Protocol Purpose UFW Rule
8384 TCP Web UI ufw allow 8384/tcp comment 'Syncthing Web UI'
22000 TCP Sync protocol (file transfer) ufw allow 22000/tcp comment 'Syncthing sync'
21027 UDP Local discovery (LAN device detection) ufw allow 21027/udp comment 'Syncthing discovery'

On the Macs, no firewall changes are needed β€” macOS will prompt on first run and Syncthing only listens on localhost for the web UI.

Global discovery and relaying are enabled by default but not needed on the LAN. All devices are configured with explicit tcp://IP:22000 addresses for direct LAN connections. Global discovery and relaying serve as fallback if a device connects from outside the home network.

App Configuration Sync Setup

The primary use case is syncing app configs between both Macs, replacing unreliable iCloud sync.

Typinator:

  1. Quit Typinator on both Macs
  2. Open Typinator Preferences β†’ Advanced β†’ Data Folder
  3. Point it at ~/Sync/SyncConfigs/Typinator/
  4. Do the same on the other Mac
  5. Relaunch β€” configs now sync via Syncthing

BetterTouchTool:

  1. Open BTT Preferences β†’ Sync
  2. Set the sync folder to ~/Sync/SyncConfigs/BTT/
  3. Repeat on the other Mac

Adding new apps:

For apps with a built-in “data folder” or “sync folder” setting, point it at a subfolder inside ~/Sync/SyncConfigs/. For apps without a custom path setting, use a symbolic link:

# Quit the app first, then:
mv ~/Library/Application\ Support/AppName ~/Sync/SyncConfigs/AppName
ln -s ~/Sync/SyncConfigs/AppName ~/Library/Application\ Support/AppName

Note: Sandboxed App Store apps may not follow symlinks. Check that the app works after symlinking before relying on it.

Claude Config Sync (Mac Studio ↔ MacBook)

Added 2026-05-12, overhauled 2026-05-19. Syncthing keeps the full Claude environment in sync between both Macs: Claude Code config, the entire ~/Sync/ED/ working directory (context bundle, tasks, secrets, skills, memory, docs), and MCP server source code. See the dedicated Claude Multi-Machine Setup page for the complete architecture.

Why not route through Proxmox? These folders contain machine-specific paths and configs that only make sense on macOS. Proxmox has no use for them, so the two Macs sync directly.

What syncs automatically vs. what needs manual steps:

Component Sync Method Automatic?
~/.claude/ (settings, plugins) Syncthing claude-config Yes
~/Sync/ED/ (context, tasks, skills, memory, docs) Syncthing claude-ed Yes
~/.mcp-servers/ (MCP source code) Syncthing mcp-servers Yes
MCP Python venvs launchd agent com.bee.rebuild-mcp-venvs Yes (auto-rebuild on change)
claude_desktop_config.json Symlinked into ~/Sync/ED/config/ Yes
Cowork skills snapshot sync-cowork-snapshot.sh (includes MacBook push) Manual (run after skill edits)

.stignore for claude-config

The ~/.claude/ directory contains a mix of persistent config and ephemeral/large session data. The .stignore file excludes 19 patterns to keep sync fast and avoid conflicts:

sessions
session-env
todos
statsig
telemetry
debug
downloads
backups
file-history
cache
ide
.config.json
policy-limits.json
stats-cache.json
mcp-needs-auth-cache.json
history.jsonl
*.bak
.DS_Store
plugins/data

The claude-skills and claude-memory folders sync without any .stignore β€” their entire contents are relevant on both machines.

Syncthing launchd agent

A dedicated launchd agent runs Syncthing on the Mac Studio (separate from the Homebrew-managed service used for app-configs):

Setting Value
Plist ~/Library/LaunchAgents/com.beedifferent.syncthing.plist
Program /Applications/Syncthing.app/Contents/MacOS/Syncthing
KeepAlive Yes
RunAtLoad Yes
# Check status
launchctl list | grep beedifferent

# Restart
launchctl kickstart -k gui/$(id -u)/com.beedifferent.syncthing
Monitoring

Syncthing is included in the Proxmox health monitoring system. The system-health-check.sh script (runs every 15 minutes, pushes to Gotify) monitors:

  • ZFS pool health for nvmepool (where SyncConfigs lives)
  • Disk space on all pools
  • Proxmox service availability

The Syncthing web UIs on each device also show connection status, sync progress, and any file conflicts.

Gotify alert configuration:

Setting Value
Gotify URL http://192.168.8.100:8070
App name System Alerts
Token ARCkVc0wf001L.e
Troubleshooting

Device shows “Disconnected”:

  1. Check if Syncthing is running on the remote device (brew services list | grep syncthing or systemctl status syncthing@root)
  2. Verify the device address is set to tcp://IP:22000 (not just dynamic)
  3. Check UFW on Proxmox: ufw status | grep -E '22000|21027'
  4. Restart Syncthing: brew services restart syncthing or systemctl restart syncthing@root

Files not syncing:

  1. Check the Syncthing web UI for errors or conflicts
  2. Verify the folder path exists on both devices
  3. Check folder type is “Send & Receive” on all devices
  4. Look for .stignore files that might be filtering content

Conflict files:

Syncthing creates .sync-conflict-YYYYMMDD-HHMMSS files when the same file is modified on multiple devices simultaneously. Resolve by keeping the correct version and deleting the conflict copy.

Reset a device’s Syncthing config:

# Mac (will regenerate on next start)
brew services stop syncthing
rm -rf ~/Library/Application\ Support/Syncthing/
brew services start syncthing

# Proxmox
systemctl stop syncthing@root
rm -rf /root/.local/state/syncthing/
systemctl start syncthing@root

After resetting, you’ll need to re-add devices and shared folders.

Background & Decision Log

Problem: Typinator and BetterTouchTool configuration files were not syncing reliably between Mac Studio and MacBook despite using their built-in iCloud sync. iCloud Drive was identified as the root cause β€” it aggressively evicts files to free local storage, handles frequently-updated small files poorly, and creates silent conflict copies.

Solution: Syncthing was deployed with Proxmox as the always-on hub. This provides: real-time LAN sync without cloud dependency, no file eviction, proper conflict detection with visible conflict files, and ZFS-backed storage on the hub with automatic snapshots every 15 minutes.

Why not direct Mac-to-Mac sync: Both Macs would need to be powered on simultaneously for sync to occur. With Proxmox as the hub, one Mac can be off β€” changes queue on Proxmox and sync when the other Mac comes online.

Installed: March 27, 2026.

Vaultwarden (Retired)

Vaultwarden was retired in May 2026 and CT104 was destroyed. The instance previously at vault.edmd.me no longer exists; the LXC, Docker container, all backups, RSA signing keys, and admin token have been removed from infrastructure.

The decision to retire was operational: the additional credential-management surface area wasn’t pulling its weight against the simpler pattern of per-service secrets.env files + a master inventory at ~/Sync/ED/SECRETS.md (chmod 600, not in git).

Current credential pattern

  • Master inventory + rotation runbook: ~/Sync/ED/SECRETS.md β€” every known credential, consumer map, rotation steps.
  • Per-service secrets on CT100: /opt/<service>/secrets.env, owner root, mode 0600, referenced from docker-compose.yml via env_file:. Not committed (.gitignore blocks it in homelab-config).
  • *API keys for arr apps: extracted at runtime from each container’s /config/config.xml β€” never hardcoded. See ~/scripts/arr-briefing-data.py for the canonical extraction.
  • Mac-local credentials in ~/.config/: anthropic-api-key, gotify-token, openai-api-key, etc. β€” chmod 600.

If you came here looking for…

  • A password manager β†’ use macOS Keychain, the Bitwarden cloud service, or 1Password depending on the surface.
  • A specific credential previously stored in Vaultwarden β†’ check ~/Sync/ED/SECRETS.md.
  • How to rotate something β†’ ~/Sync/ED/SECRETS.md has per-credential steps. SKILL: secrets-vault.

See also: Secrets vault SKILL (internal ~/Sync/ED/skills/secrets-vault/SKILL.md).

WireGuard Tunnel β€” CT100 β†’ UltraCC

Deployed April 29, 2026 on CT100. Replaces the per-app SOCKS5 proxy approach for outbound privacy on torrent indexers, Hardcover lookups, etc.

What this does and why

Before: Shelfmark and the *arr stack used a SOCKS5 proxy (autossh tunnel to seedbox at port 1080) for outbound traffic that needed to look non-residential. Each app had to be individually configured.

After: A WireGuard interface on CT100 (wg0) routes all outbound from every container through UltraCC’s NL endpoint. Apps don’t need any per-service proxy config; they just talk to the internet normally and the tunnel handles the rest.

Tunnel endpoint UltraCC NL β€” 45.86.221.26:13012
Server pubkey bOBo86jrNKBikLqOFdksL41bTnQHWWHynM3/v3yq2Vg=
Client (CT100) IP 10.13.13.2/32
Listen port 51820 (CT100 side)
AllowedIPs 0.0.0.0/0 (full tunnel)
MTU 1420
Public IP from CT100 45.86.221.26 (NL exit)

Private key + PSK for this tunnel were passed through chat β€” rotation pending. Replace the peer in the UltraCC panel and re-import the config to rotate.

Topology
Container (e.g., sonarr)
    β”‚ HTTPS to indexer.com
    β–Ό
Docker bridge 172.16.x.0/24
    β”‚ (MASQUERADE in nat POSTROUTING)
    β–Ό
CT100 routing table
    β”‚ default via wg0 (table 51820, fwmark 0xca6c)
    β–Ό
wg0 β€” WireGuard interface
    β”‚ encrypted UDP to 45.86.221.26:13012
    β–Ό
CT100 eth0 β†’ OPNsense β†’ ISP β†’ UltraCC NL
    β”‚
    β–Ό
Public internet (source IP appears as 45.86.221.26)

LAN traffic (192.168.8.0/24, 172.16.0.0/12, 100.123.0.0/16) bypasses the tunnel. Only outbound to the public internet goes through wg0.

Kill-switch

The pre-existing CT100 firewall acts as the kill-switch β€” if wg0 drops, the OUTPUT chain has no rules permitting public-internet egress, so all packets are dropped (with explicit LOG+DROP rule at the end). No traffic leaks to the ISP if the tunnel fails.

Permitted egress (whitelist in /etc/iptables/rules.v4):

  • 192.168.8.0/24 β€” LAN
  • 172.16.0.0/12 β€” Docker bridges
  • 100.123.0.0/16 β€” NetBird overlay
  • DNS to 192.168.8.53 β€” Pi-hole
  • SSH to 46.232.210.50:22 β€” seedbox (for autossh tunnels still in use)
  • UDP to 45.86.221.26:13012 β€” WireGuard handshake to UltraCC
  • Anything via wg0 (the tunnel itself)

Everything else: LOG + DROP.

MSS clamping (critical)

WireGuard’s MTU (1420) is smaller than Docker bridges (1500). Without MSS clamping, TCP from containers fails on TLS handshake β€” packets get fragmented or silently dropped, connections hang. Symptoms: ping works (small packets) but curl https://... times out.

Fix is in the mangle table:

iptables -t mangle -A FORWARD -o wg0 -p tcp --tcp-flags SYN,RST SYN \
    -j TCPMSS --clamp-mss-to-pmtu
iptables -t mangle -A POSTROUTING -o wg0 -p tcp --tcp-flags SYN,RST SYN \
    -j TCPMSS --clamp-mss-to-pmtu

Both rules are persisted in /etc/iptables/rules.v4.

Systemd & boot persistence
# Enabled on CT100:
systemctl is-enabled wg-quick@wg0    # β†’ enabled
systemctl status wg-quick@wg0        # β†’ active

/etc/wireguard/wg0.conf holds the peer config (chmod 600, root-only). On boot, wg-quick@wg0.service brings up the interface, applies the iptables rules in /etc/iptables/rules.v4 via iptables-restore, and the kill-switch + MSS clamp + tunnel are all live.

Health checks & diagnostics

Verify tunnel from CT100:

pct exec 100 -- wg show wg0
# Expect: handshake within last 2-3 minutes, transfer counters incrementing.

pct exec 100 -- curl -s https://api.ipify.org
# Expect: 45.86.221.26 (NOT your home WAN IP).

Verify from a container (e.g., Sonarr):

pct exec 100 -- docker exec sonarr sh -c \
    'curl -sS -o /dev/null -w "%{http_code} via %{remote_ip}" https://api.ipify.org'
# Expect: 200 via <some-ipify-IP>; the response body shows 45.86.221.26

Uptime Kuma monitor (id 76, “WireGuard Exit IP (UltraCC)”) does this exact check every 5 minutes β€” keyword 45.86.221.26 against https://api.ipify.org. Alerts via Gotify if the tunnel drops or routing changes.

If TCP fails but ping works: MSS clamp rules are missing (see MSS clamping).

If the whole tunnel is dead: check for handshake errors. Often the cure is systemctl restart wg-quick@wg0. If UltraCC re-keyed, you’ll need a fresh config from their panel.

Containers using the tunnel

All of them β€” every CT100 container’s outbound traffic exits via 45.86.221.26. This is by design.

The WireGuard tunnel replaced the SOCKS5 proxy (autossh-socks / seedbox-socks) for outbound privacy routing β€” apps no longer need per-service proxy configuration. However, the SSH RPC tunnels are NOT redundant and remain actively required:

Tunnel Purpose Status
transmission-tunnel.service (hpve) SSH tunnel :13010 β†’ seedbox Transmission RPC. Used by Sonarr/Radarr/Lidarr for download management Active β€” required
autossh-transmission.service (CT100) AutoSSH tunnel :13010 β†’ seedbox Transmission RPC Active β€” required
autossh-nzbget.service (CT100) AutoSSH tunnel :16789 β†’ seedbox NZBGet Active β€” required
autossh-socks.service (CT100) SOCKS5 :1080 β†’ seedbox NL exit Active β€” legacy, replaced by WireGuard for most apps

WireGuard handles privacy routing (making outbound traffic appear from NL); it does not replace the RPC control tunnels that let the *arr apps communicate with download clients on the seedbox.

Backup of installation state

When the tunnel was deployed, a snapshot was taken at:

/root/wg-deploy-backup-20260429-150327/
  rules.v4.before    # iptables before WG
  iptables.live.before

To roll back: iptables-restore < /root/wg-deploy-backup-*/rules.v4.before && systemctl stop wg-quick@wg0 && systemctl disable wg-quick@wg0.