Architecture

sentinel — Pi forge — OptiPlex synapse — Threadripper
Hardware Raspberry Pi, microSD i7-7700T (35W), 16GB, NVMe 9960X, 2× RTX 5070 Ti 16GB, 32GB
Job Alerting, front door All state, metrics Inference, agent
Failure mode SD card death OOM, disk fill GPUs on another job
Trusted with Saying something is wrong Anything that must survive a reboot Work that can be skipped

Why the work is split this way

forge holds anything that must survive a reboot. Databases, the queue broker, bookkeeping, metrics history and the camera archive. It is the box with the real availability guarantee and fast local storage, so it is the only node trusted with state.

sentinel exists to watch the others. ~5W, separate power, separate storage, separate failure modes — so when forge falls over, the thing that says so is not part of the outage (ADR-0001). The service dashboard lives here too: a front door that dies with the box it points at is not useful.

synapse does the GPU work, and nothing critical waits on it. It serves the agent's model and enriches other pipelines, but it is treated as optional: it gets torn down for benchmarking, and its GPUs get lent to other projects. Every consumer is built so that synapse being down costs a feature, never an alert.

What is running

Containerized, each node with its own Portainer, every service addressed by Tailscale name:

Node Role Component
sentinelUptime monitoring and alertingUptime Kuma
sentinelService dashboard (sees all nodes)Homepage
forgeMetrics and dashboardsPrometheus, Grafana, node-exporter, cAdvisor
forgeRelational database, queue brokerPostgreSQL with pgAdmin, Redis
forgeDouble-entry bookkeepingManager.io
forgeCamera ingest, archive, alertsblink-watch + detector (custom)
forgeBoot-time self-repairdocker-port-healer (systemd)
synapseLocal inferencevLLM — Qwen3.6-35B-A3B, NVFP4, 2-GPU
synapseAI agent (Discord)Hermes Agent
synapseAgent egress allowlist, searchSquid, SearXNG
forge, synapseRead-only Docker API for the dashboarddocker-socket-proxy
Homepage dashboard with tiles for every service across sentinel, forge and synapse, each with a live status pill, plus Grafana and Prometheus summary widgets.
Homepage · sentinel One screen for all three nodes, reading each one's containers through a read-only socket proxy. Shown as it was, warts included: the vLLM tile's pill can't find its container by name, and Prometheus has two targets down.
Uptime Kuma dashboard: 22 monitors up, 0 down, with a list of monitors and an event log of recent up and down events.
Uptime Kuma · sentinel 22 monitors, all up, watching forge and synapse from the Pi. The event log shows forge's services dropping and coming back on the evening of 20 October. One error message is blurred because it contained a tailnet address.

Local AI — synapse

A fully local agent on a fully local model, reachable over Discord, with web search and no paid APIs in the steady state.

synapse with the side panel off: a Corsair case with a dual-fan Noctua air cooler on a Gigabyte TRX50 AERO D board, and two Gigabyte GeForce RTX 5070 Ti cards stacked below it.
synapse, side panel off Threadripper 9960X under a Noctua air cooler, two RTX 5070 Tis stacked below it.
  • Inference: vLLM serving Qwen3.6-35B-A3B at NVFP4, tensor-parallel across both 16GB cards, 262K context, vision enabled. Chosen over a dense 27B after a benchmark tied them on reasoning and tool use at 238 vs ~102 tok/s and 262K vs ~88K context.
  • Tuned from failures, not defaults. The speculative-decoding draft head requested unprofiled memory and OOM-crashed vLLM twice, so it is off. Automatic KV-cache sizing left ~76MB free per card, so the cache is a fixed size with real headroom. Prefix caching caused long-prompt hangs on this hybrid architecture, so it is off.
  • Agent: Hermes Agent in a container — non-root, no Docker socket, no-new-privileges, 4GB and 512-PID limits. Only allowlisted Discord users can command it, and memory or skill writes need explicit approval before they persist.
synapse
$ nvidia-smi
Thu Oct 22 02:41:34 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 595.91.07              Driver Version: 595.91.07      CUDA Version: 13.2     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 5070 Ti     Off |   00000000:81:00.0 Off |                  N/A |
|  0%   31C    P8              9W /  300W |   15612MiB /  16303MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  NVIDIA GeForce RTX 5070 Ti     Off |   00000000:C1:00.0 Off |                  N/A |
|  0%   30C    P8              8W /  300W |   15612MiB /  16303MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|    0   N/A  N/A          192195      C   VLLM::Worker_TP0                      15602MiB |
|    1   N/A  N/A          192196      C   VLLM::Worker_TP1                      15602MiB |
+-----------------------------------------------------------------------------------------+
Both cards, captured between requests: vLLM's two tensor-parallel workers hold 15.6 of 16.3GB each, which is the 95% memory budget it is configured for, reserved up front rather than grown on demand. Idle, each card draws 8–9W.

Containment is the network, not the tooling

Prompt injection steering a tool call cannot be filtered out of web content reliably, so the design limits what an injected agent can do rather than what it can read. The agent sits on a Docker network created --internal: no gateway, no NAT, no route off the box. Squid and SearXNG are the only containers on both that network and an uplink, so they are the only exits, and Squid allows HTTPS to Discord and five exact GitHub hosts — nothing else. Package indexes are deliberately blocked. Dropping a proxy setting costs the agent its egress instead of granting it free egress.

The read-only Docker API the dashboard uses is deliberately off the agent's network; reaching it directly, through the proxy, and by container name was each verified to fail.

Residual risks, written down rather than implied away: the Discord token is readable inside the agent's container (a credential-injecting proxy would fix it and is not built); the shell tool can write anywhere in the container, so the container is the sandbox; fetched URLs and search queries are small exfiltration channels; and the model API is open to every tailnet device without a key, the price of letting the watchdog health-check it.

Next phase, not running yet: Hermes builds, Claude reviews.

Hermes takes a scoped task in Discord, works on a branch and opens a pull request — never pushing to main. Claude Code reviews it; the review goes back to Hermes or the PR is merged by hand. Waiting on a fine-grained token limited to selected repositories, contents and pull requests only, with a short expiry.

Operational practice

Conventions applied across every compose file in the private config repo:

  • Every container has a mem_limit. A 16GB host with unbounded containers is an OOM waiting to happen. The camera detector is additionally capped at two CPU threads so a burst of clips cannot starve the databases.
  • Versions are pinned where a rebuild depends on them — the inference server, the metrics stack, the egress proxy, the camera library. Several web front-ends and the agent image still track latest; that is a known gap, not a policy.
  • Config is bind-mounted, state lives in named volumes. Config is in git; state deliberately is not. The repo plus a Tailscale login is the rebuild path.
  • Reachability is a firewall property, not a bind-address property. Docker continues to listen on all interfaces so containers can reach one another and the host; the packet filter is what decides who gets through.
  • Services address each other by Tailscale MagicDNS name, not IP. Moving a service between nodes becomes a volume copy rather than a config rewrite. Names are set explicitly at join time — Tailscale otherwise derives them from the OS hostname, and they silently stop matching the configs. That has cost an hour once.
  • Secrets never reach the repository. .env is gitignored, .env.example documents every variable with placeholder values, and gitleaks runs against staged changes before every push.
  • Agents propose, a human executes. AI-assisted changes land as reviewable commits and scripts; destructive commands and deployments stay human-gated.

Network security model

The design goal is that nothing is reachable without authenticating first, and that being on the same physical network grants nothing at all.

  • Tailscale is the only transport between nodes. A WireGuard-based mesh carries all inter-node traffic. There is no port forwarding on the router, no reverse proxy exposed to the internet, and no public ingress of any kind. The camera pipeline is outbound-only and publishes nothing.
  • Access is authenticated per device, not per network. A machine reaches services because it has authenticated onto the tailnet — not because it happens to sit on the right LAN. Revoking a device removes its access everywhere at once.
  • The firewall is the enforcement boundary. External traffic is filtered in the DOCKER-USER chain — evaluated ahead of Docker's own accept rules — so only packets arriving over the Tailscale interface reach a published port. Host SSH is restricted to the same interface via UFW.
  • The dashboard never gets a raw Docker socket. It reads container state from each node through a read-only socket proxy, because a docker.sock over the network is root on the node holding all the state.
  • The agent is contained by topology. An internal-only network with a single allowlisting proxy as its exit — see Local AI above.

Closing a real gap: Docker's firewall bypass

The tailnet-only property was originally enforced by convention — every published port was expected to carry an explicit bind address. Auditing it showed that convention was not holding, and published container ports were reachable from the LAN.

The cause is a well-known and easily-missed Docker behaviour: publishing a port inserts iptables rules that are evaluated before the chain UFW manages. A deny incoming default policy therefore does not block a published container port. UFW reports the host as locked down while Docker has already accepted the traffic.

That is the worst class of security bug in a homelab — not an open port, but a system that reports itself as closed while it is open, so everything built on top of it inherits a false assumption. The fix was to stop treating bind addresses as a security control and put enforcement in the packet filter instead: rules in the DOCKER-USER chain admit only traffic arriving over the Tailscale interface, and host SSH is locked to that same interface with UFW.

The tradeoff is deliberate and worth stating: enforcement is now a single control rather than layered. Because the listening sockets remain open on all interfaces, the firewall rules are load-bearing, which makes their persistence across reboots and Docker restarts a first-class operational concern rather than a detail.

Recorded as ADR-0002, with a runbook covering verification — which has to be done from a LAN host that is not on the tailnet, since checking from the host itself passes trivially and proves nothing.

Planned hardening: single sign-on in front of the web interfaces, and tailnet ACLs restricting which device may reach which service — which would also close the keyless model API.

Architecture decisions

Decisions are recorded as ADRs at the time they are made, and amended or superseded in place when they stop being true — rather than quietly rewritten. The configs show what runs; the ADRs record why, including the ones that turned out wrong.

ADR-0001 — alerting must not share a failure domain with what it watches. Uptime Kuma started on forge, which made forge a single point of failure for knowing whether forge was up. It moved to the Pi. Prometheus and Grafana briefly followed it there and then moved back: they answer "what was memory doing before the crash", which is read afterwards from a box that is back up. The argument was always about notification, not diagnosis, and the ADR stands as written.

ADR-0002 — reachability is enforced in the firewall. See the featured panel above.

ADR-0003 — contain an agent at the network layer, not just in config. Written for an earlier runtime on forge and now superseded, but its reasoning — internal-only network, proxy as sole exit, fail closed — is exactly what synapse runs today.

ADR-0004 — use the agent's built-in memory, not a vector database. The plan picked a vector store on the premise that built-in memory was keyword-only. Installing it showed the premise was wrong on every point: the built-in engine already did hybrid keyword-and-vector search over plain markdown files, and the vector store held zero rows for its entire life while the built-in system quietly did the work. The ADR exists so the reversal is recorded rather than rediscovered.

zero rows. the whole time.

ADR-0005 — split runtimes by trust zone, then collapse back to one. A research runtime and an execution runtime, separated by a human-moved file. Once the local agent proved it could do both jobs, the second runtime was removed as redundant and the ADR amended — including the parts of its security section that were never actually built.

Building synapse was not justified by the spend rule, and that is written down. Hardware here must fix a fault that exists now, remove a ceiling actually being hit, or pay back within about six months. The GPU node was built on other grounds. That is a legitimate decision — but the rule is not allowed to be quietly treated as though it passed.

yes, I know.

The camera pipeline puts ingest on forge and AI on synapse. Clips are state, and state lives on forge's NVMe, not the Pi's microSD. Descriptions are enrichment, so they live on the node allowed to disappear.

Migrations and incidents

  • The drive fell asleep and took the filesystem with it. At 06:27 the forge monitors started going red: cAdvisor, Redis, the Docker proxy, Portainer, pgAdmin and Prometheus inside five minutes. The NVMe drive's power saving had let it drop into its deepest sleep state, and it came back read-only, so any process that touched the disk, to read or to write, failed. It was not all at once: services kept running until they next needed the disk, so Grafana held out until 10:23 and Manager.io until 14:14. One monitor stayed green all day, watching a process that ran entirely from memory with no disk I/O. A power cycle at 21:18 brought the drive back, every monitor was green by 21:21, and a custom rule now keeps the drive out of that sleep state. About fifteen hours, start to finish. Two lessons: a green check proves only the path it exercised, and the one drive holding all of forge's state is one more argument for the backups that do not exist yet.
    Uptime Kuma event log for 20 October: forge services going down from 06:27 with timeouts and refused connections, Grafana at 10:23, Manager.io at 14:14, then all coming back up between 21:19 and 21:21.
    The Uptime Kuma event log from that day, newest first. Tailnet addresses blurred.
    the survivor never touched the disk
  • A power surge broke every published port — the services were fine. After an unclean shutdown, every container on forge restored with correct port config but no live port mappings: every monitor red, every service healthy. Docker's network state is not crash-safe, and neither restarting containers nor the daemon rebuilds it — only recreating the container does. Confirmed by elimination on the box, then fixed with a boot-time healer that recreates only containers whose bindings are configured but not live. A no-op on a clean boot. It found one more problem on the way: a container started by hand, with no compose metadata, could not be recreated at all — so it became a compose stack too.every light red, every service fine
  • Built the GPU node and moved inference onto it. A dual-GPU Threadripper now serves the agent's model locally, on a node that is always on by design, with hosted APIs no longer part of the steady state.
  • Agent runtime, three reversals. One runtime chosen, reversed for another, split into two by trust zone, and finally collapsed to one local agent on synapse. The removed runtime's config was deleted from the repo rather than left as files for a stack that will not exist.
  • Uptime Kuma and Homepage → Pi. Done, per ADR-0001. Because every monitor targets a MagicDNS name rather than an IP, the move was a volume copy rather than a rewrite.
  • Docker firewall lockdown. Audited the tailnet-only invariant, found published container ports reachable from the LAN, and moved enforcement into DOCKER-USER. See ADR-0002.
  • Removed a container that did nothing. A browser worker ran tail -f /dev/null against a directory that never existed in git — Docker created it empty on every deploy. Deleted rather than propped up.
  • OptiPlex board replacement and 8→16GB RAM upgrade. Fixed a real fault and lifted a ceiling that was actually being hit, which is what justified it.
  • Monitoring rollout. Prometheus recording rules pre-compute the values the dashboard displays, because the widget passes PromQL in a URL query string and long expressions become an encoding problem.

Build status

Built in public. This is a live project, so the page separates what runs today from what is next — nothing below is listed as running unless it is.

Running now

Three-node split with workloads placed by node reliability. Tailscale-only mesh enforced in the firewall (ADR-0002). Independent alerting and dashboard on the Pi. Stateful stack, metrics and bookkeeping on forge, every container under a memory limit, with boot-time port self-repair. Local 35B inference and a network-contained agent on synapse. Blink camera alerts with AI descriptions. ADR practice and secret scanning on every push.

In progress

Choosing a cloud-side motion trigger for the camera pipeline from the 48-hour probe data. Agent-opens-PR, Claude-reviews workflow, pending a scoped token. Pinning the images that still track latest.

Next

Data backups — the largest unaddressed risk: databases, documents, monitoring history and the camera archive are single-copy today. Designed as peer-to-peer nightly snapshots across nodes, with quarterly restore tests. Then DNS filtering, single sign-on and tailnet ACLs, a fully local Blink v2 on a Pi Zero 2 W, people-only camera alerts, and a NAS once peer storage runs out.

Note on scope

This is the public view. The configuration itself, runbooks, network addressing, port bindings and firewall rules live in a separate private repository.