Working spec for malmo's control plane — the brain that orchestrates apps, routing, identity, storage, and the mesh. Companion to
SPEC.md.Topical specs that used to live here:
- App lifecycle (install, run, update, uninstall, reconciler, slug allocation, Caddy timing) →
APP_LIFECYCLE.md- Brain ↔ UI API protocol (sync, jobs, SSE, errors, auth, versioning) →
BRAIN_UI_PROTOCOL.md- Brain ↔ host-agent protocol →
BRAIN_HOST_PROTOCOL.md- Web UI codebase, stack, deploy model →
WEB_UI.mdThis doc stays at the architectural-overview level: what the control plane is, its layered shape, and the brain's deployment-time decisions.
Environment profiles. The control plane described here is Layer 1 — identical across the
applianceandhostedprofiles. Only Layer 2 (the base image andhost-agent) diverges; the hosted profile ships a build-tagged slim cloudhost-agentand leaves brain / Caddy /malmo-ui/ socket-proxy unchanged. SeeENVIRONMENT.md# Two layers, treated differently.
- App lifecycle — install, start, stop, update, uninstall. Talks to Docker. Full spec in
APP_LIFECYCLE.md. - Routing — dynamically configures the reverse proxy as apps come and go (
photos.local→ container). - mDNS publishing — registers each app's hostname with Avahi (via
host-agent). - API + UI — HTTP + SSE + (future) WebSocket API for the web UI; ships the UI. Wire spec in
BRAIN_UI_PROTOCOL.md; client-side stack inWEB_UI.md. - Identity — user accounts, sessions, admin/root account, sharing/ACLs. See
AUTH.md. - Managed services — runs shared Postgres / Redis / etc., provisions databases on app install, handles backups + upgrades. See
SERVICE_PROVISIONING.md. - Storage — manages the data drive (mergerfs union, ext4+LUKS+TPM unlock), bind mounts for
/home/and/var/lib/malmo/, app data directories. SeeSTORAGE.md. - Mesh — integrates with Headscale, registers the box, manages pairing tokens, surfaces people/devices UI. See
MALMO_NETWORK.md. - Backups — orchestrates app backup hooks, schedules, restores.
- Auto-updates — apps, the OS itself, and the control plane. See
UPDATES.md.
┌─────────────────────────────────────────────────────────┐
│ Host (Debian) │
│ │
│ ┌─────────────────┐ │
│ │ host-agent │ ← tiny native binary, systemd │
│ │ (Go, static) │ • mounts disks / mergerfs pool │
│ └────────┬────────┘ • bootstraps the brain │
│ │ • OS-level updates │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Docker (containerd under) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ malmo-brain │ │ Caddy (reverse proxy)│ │ │
│ │ │ (Go daemon) │◄─┤ config via admin API │ │ │
│ │ │ + SQLite │ └──────────────────────┘ │ │
│ │ │ │ ┌──────────────────────┐ │ │
│ │ │ │ │ managed Postgres │ │ │
│ │ │ │◄─┤ (per major version) │ │ │
│ │ │ │ └──────────────────────┘ │ │
│ │ │ │ ┌──────────────────────┐ │ │
│ │ │ │ │ managed Redis │ │ │
│ │ │ │◄─┤ │ │ │
│ │ │ │ └──────────────────────┘ │ │
│ │ │ │ ┌──────────────────────┐ │ │
│ │ │ │ │ user apps │ │ │
│ │ │ │◄─┤ (Photos, Grocery…) │ │ │
│ │ │ │ └──────────────────────┘ │ │
│ │ └──────┬───────┘ │ │
│ │ │ Docker API (via socket proxy) │ │
│ └─────────┼────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Avahi │ │ Headscale │ │
│ │ (mDNS host) │ │ client │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
A small native Go binary running as a systemd service. Does as little as possible.
- Bootstrap the system at boot — unlock and mount the data drive(s), assemble the mergerfs union, check disks, start Docker.
- Pull and start the
malmo-brain. - Apply OS-level updates (today:
apt; tomorrow: A/B image swaps). - Recover the brain if it crashes.
When we eventually move to A/B immutable updates, this is the only piece that's part of the immutable OS image. It's small, changes rarely. This positioning makes the future immutable migration painless.
For v1, host-agent could be a few hundred lines of Go. Deliberately boring.
The brain ↔ host-agent wire-level contract is specified in BRAIN_HOST_PROTOCOL.md (HTTP/JSON over UNIX socket, two API patterns, SSE for streams, lockstep versioning). Anything that crosses this boundary — mDNS publish/unpublish, OS updates, Tier-2 systemd/config ops — runs on that protocol.
A single Go process holding all orchestration logic. Internal packages:
- App manager — install / lifecycle / updates (spec:
APP_LIFECYCLE.md) - Proxy manager — talks to Caddy admin API
- Mesh manager — talks to Headscale
- Service manager — provisions DBs in managed Postgres / Redis
- Storage manager — mergerfs, tiers, app data dirs
- Identity — accounts, sessions, ACLs
- API server — serves the dashboard API (spec:
BRAIN_UI_PROTOCOL.md) - Backup orchestrator
- Health manager — owns the typed set of active health issues, consults them to gate write/app/user operations, surfaces them via the API + SSE channel. The brain runs in degraded mode when any issue is active. Spec:
HEALTH.md.
Persists its own state in SQLite (single file, no separate DB process for malmo's own data; managed Postgres is for apps, SQLite is for malmo).
Why Go: every adjacent tool is in Go (Docker, containerd, Headscale, Caddy, Traefik, Avahi bindings). Static binary, mature concurrency model, the lingua franca of the ecosystem.
Why one binary, not microservices: single-node home appliance, microservices are pure overhead. Internal modularity via clean Go packages is enough.
- Caddy as the reverse proxy. Brain calls its admin API to add/remove routes when apps install. Built for dynamic config; native Let's Encrypt.
malmo-ui— the dashboard's static file server (caddy:alpine+ the built UI bundle baked in). A brain-launched control-plane container; Caddy routes all non-API traffic to it. Stack and deploy model inWEB_UI.md.- Managed Postgres — one shared instance per major version users depend on. Brain is the only DB creator. Apps get scoped credentials.
- Managed Redis similarly.
- User apps — each its own compose stack, brain manages lifecycle.
The brain runs next to these and orchestrates them — not inside them.
- The
malmo-brainships as a Docker image, run by Docker, supervised byhost-agent. - Why: atomic production updates (pull new tag, recreate container, trivial rollback by reverting tag). No partial-install failure modes the way
apt-based deploys produce. Same image runs in dev, staging, prod. Future migration to A/B immutable OS updates becomes a host-agent change, not a brain change — the brain doesn't move. - Cost we accept: marginally slower dev loop than
go runon the host. Mitigated with bind-mount +air(or equivalent) hot-reload during development. Production wins are worth the dev tax. - Performance is a non-factor — container overhead for a long-running daemon is negligible.
- The brain does not mount
/var/run/docker.sockdirectly. It talks to atecnativa/docker-socket-proxycontainer attcp://docker-proxy:2375. Brain config: one env var (DOCKER_HOST=tcp://docker-proxy:2375). - The proxy is configured with an allowlist of Docker API endpoint families the brain actually needs (
CONTAINERS=1,IMAGES=1,NETWORKS=1, etc.). Dangerous endpoints (EXEC, arbitrary host mounts onPOST /containers/create) are denied. - Why: the brain has the largest attack surface (HTTP API exposed to the LAN, third-party app manifests we evaluate). If it's ever compromised, the proxy prevents trivial host takeover via Docker. Cheap defense in depth — one extra container, one env var, ~5MB RAM, negligible ongoing burden.
- Operational rule: when the brain needs a new Docker API endpoint family, that's an explicit config change to the proxy. Forces conscious thought about what privileges the brain holds.
- host-agent seeds the proxy (M1b, #165). The proxy is the brain's only path to Docker, and a process cannot bring up its own sole transport — so the proxy is not in the brain's control-plane compose. host-agent, which holds the raw socket, creates the
malmo-ingressnetwork and launches thedocker-socket-proxy(raw socket bind-mounted read-only, the one place it is exposed to a container) before launching the brain, then sets the brain'sDOCKER_HOST. The proxy is brain transport infrastructure, seeded by host-agent; Caddy +malmo-uiare services the brain owns and reconciles (lifecycle.EnsureControlPlane). The brain reaches the proxy by thedocker-proxynetwork alias regardless of the container's own (malmo--prefixed) name. Seesocket-proxy-compose-validation.mdandDECISIONS.md2026-06-14. EXECstays denied; managed-DB provisioning runs in a one-shot container instead. Managed databases (internal/lifecycle/services.go) provision per-app roles/databases by running the engine's client (psql/mysql/valkey-cli) in a throwawaydocker run --rmcontainer joined to the service network, connecting over TCP — not bydocker exec'ing the long-running service container (DECISIONS.md2026-06-15). This was the re-architecture the 2026-06-14 call gated managed-DB-in-production on: it keepsEXECdenied (thedocker runfamily usesPOST /containers/create+/start+/attach, all under the allowedCONTAINERS/POSTfamilies) and the brain off the service network (only the ephemeral container joins it). Readiness polls the service's compose healthcheck viadocker inspect(CONTAINERS), not an exec'd probe. Managed DB therefore works under the proxy in production; dev (native raw socket) was never affected.
- Unit type
Type=notify, notsimple. host-agent callssd_notify(READY=1)only after its UNIX socket is bound and accepting connections. Anything orderedAfter=host-agent.service(brain container, downstream services) sees a ready socket on first try — no startup races. Restart=always,RestartSec=2s,StartLimitBurst=5/StartLimitIntervalSec=60s. Crash → restart fast, but a sustained crashloop (5 in 60s) stops and surfaces failure. On stop,OnFailure=malmo-recovery.targetroutes the box into recovery boot — host-agent itself broken means the brain can't run, so the dashboard isn't reachable and a separate rescue page is the only option (seeBOOT.md# Failure → recovery target — the narrow cases).- Watchdog enabled.
WatchdogSec=30s; host-agent pingssd_notify(WATCHDOG=1)every ~10s from a dedicated goroutine. Converts a hung process into a restart. Conservative interval avoids false positives during long legitimate operations (backup tarball walks, large image pulls). - Ordering:
After=malmo-storage-ready.target docker.service network-online.target,Wants=network-online.target malmo-storage-ready.target,Requires=docker.service. host-agent starts even if storage assembly partially failed — it reads/run/malmo/health/storage.jsonand forwards findings to the brain, which raises health issues perHEALTH.md. The brain is the single source of truth for "is it safe to write right now"; systemd ordering is best-effort, not strict-gate. Full boot-chain context inBOOT.md. - Socket activation is deliberately deferred — extra complexity with no win on an always-on appliance. Tracked in
NEXT.mdif we ever revisit.
host-agent is root, but its filesystem and kernel reach is constrained by systemd:
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/malmo /etc/malmo /run/malmo
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
ProtectHome=true is load-bearing: host-agent has no general filesystem-read API over /home. Any operation that touches a user's home directory is a narrow, named operation in BRAIN_HOST_PROTOCOL.md, not a generic file-read primitive. This is a deliberate constraint, not an oversight.
Capability dropping (CapabilityBoundingSet=) is not used — host-agent's job is "do root things on the box" (mount, cryptsetup, useradd, systemctl). The filesystem and kernel constraints above are where the meaningful blast-radius reduction happens.
- During its own startup, after Docker is ready, host-agent pulls (if needed) and starts the brain container with Docker restart policy
unless-stopped. After that, Docker keeps brain alive across host-agent restarts; host-agent does not actively supervise brain during steady-state operation. - One chain of custody. host-agent owns every container on the box (apps, managed services, and brain). The reconciler pattern from
APP_LIFECYCLE.mdextends to brain naturally. - Lockstep version check happens at launch. host-agent refuses to start a brain whose major protocol version it doesn't speak (per
BRAIN_HOST_PROTOCOL.md). One actor owns both endpoints' lifecycles, so the check is a function call, not an out-of-band reconciliation. Concretely (#164): the brain image declares its wire-protocol major in themalmo.protocol.majorOCI label, and host-agent reads it (docker image inspect) and compares against its ownprotocol.Majorbeforedocker run— a mismatch, or a missing label, is a refused launch with a clear error, not a first-request failure. - The alternative — a separate
malmo-brain.servicesystemd unit — was rejected because it splits the update flow (host-agent already owns the brain+UI update stream perUPDATES.md) and moves the lockstep version check out-of-band into first-request failure.
- Caddy is not a Tier-2 OS integration. It needs no NET_ADMIN, no external auth flow, has no user-facing settings page; it's malmo's own machinery, in the same bucket as the brain itself.
- Runs as a container, started by the brain alongside other malmo-managed containers. Joins the malmo Docker network and publishes host ports 80 and 443.
- Configured via Caddy's admin API on
:2019, reached by the brain over the Docker network — no Caddyfile on disk, nocaddy reloadshell-out. Atomic reloads, no file I/O. The admin endpoint binds0.0.0.0:2019(notlocalhost): the brain is a separate container, so it can't reach Caddy's loopback — the listener must be on the container's network interface. The host never publishes 2019 (only 80/443 are exposed), so it's reachable only from inside the Docker network. Network-isolation invariant: Caddy's admin API has no auth — its only protection is that nothing untrusted shares its network. So app containers must never be attached to the network that carries the admin port. Caddy reaches app upstreams by joining each app's own per-app network (Caddy connects outward); the control-plane network (brain, Caddy,malmo-ui, the socket proxy) stays trusted-only. An app on the same network as:2019could rewrite the entire route table. - Catch-all 404 invariant. Caddy's
malmoserver always has a final route at the end ofroutes[]with@id=malmo-catchalland no matcher, returning HTML 404 ("No app at this hostname"). The brain inserts dynamic per-app routes at index 0 (POST /config/apps/http/servers/malmo/routes/0) so the catch-all stays last. On startup the brain callsEnsureCatchAllwhich re-installs the catch-all if missing — survives Caddy state loss, hand-edits, or config drift. Returning 200 empty for unmatched routes is a UX failure and breaks tests that can't distinguish "routed" from "no-match". - Updates ride the brain+UI stream, not the Debian base stream — image tag in the release manifest, pulled and reconciled by host-agent + brain. Decouples Caddy version from Debian's release cadence.
- Performance is a non-issue for the household workload. Docker bridge networking adds microseconds per connection — invisible at household scale. The heaviest "big file" path (SMB transfers of media to/from laptops) bypasses Caddy entirely: SMB is a Tier-2 host service on port 445. DLNA likewise. Caddy carries HTTP app traffic only (Jellyfin/Plex/Immich streaming, dashboards, app UIs); even a household of 4K streamers is well below containerized Caddy's ceiling.
- Memory overhead from containerization is negligible. Containers are not VMs — same process, same RSS, no extra kernel or libc.
- Escape hatch if needed:
--network=hostrecovers host-level networking at the cost of internal-DNS service-name routing. We don't expect to need it.
- The dashboard ships as its own
malmo-uicontainer (caddy:alpine+ the built UI bundle baked in — full deploy model inWEB_UI.md). It is launched by the brain, alongside Caddy and the docker-socket-proxy, as part of bringing up the control-plane stack — not by host-agent. host-agent's chain of custody stops at the brain (# Locked: host-agent launches the brain container); the brain owns every container downstream of itself, the UI included. - Why the brain and not host-agent: the UI version is bound to the brain's API version (the bundle declares the API minor it requires;
WEB_UI.md# deploy model), and brain+UI update as one stream (UPDATES.md). Keeping both launches under the brain means the version pairing is enforced by the actor that already owns it, not split across the host-agent boundary. - The LAN-facing Caddy routes
/api/v1/*(and the SSE/log streams) tomalmo-brainand everything else tomalmo-ui(WEB_UI.md# deploy model). Both are reconciled by the brain on startup the same way app containers are.
- Language: Go. Single binary. Static.
- Internal structure: clean packages (app manager, proxy manager, mesh manager, service manager, storage manager, identity, API server, backup orchestrator). Single process — no microservices.
- State: SQLite, single file in a persistent volume, for malmo's own state. Managed Postgres is for apps; SQLite is for malmo.
- Reverse proxy: Caddy, controlled via its admin API.
host-agentis and stays minimal — bootstrap, brain supervision, OS-level updates. Anything that changes frequently lives in the brain, not here.
Tracked centrally in NEXT.md. Resolutions land back here (or in the relevant topical doc) plus DECISIONS.md if they flip a position.