How this magnet works
Four processes, two domains, and the reasons each one is separate.
Magnemite is four processes and a database.
Three of them run on your local server; the fourth is a ~6 MB static binary on each box.
All the pieces
| Piece | What it is | What it does |
|---|---|---|
| agent | Go binary, installed as a Magisk module | Dials out to the hub over WSS, downloads bundles, drives pm |
| hub | Fastify, port 3001 | Holds the device sockets, schedules jobs, polls both release sources, caches each .apkm once |
| web | Next.js, port 3000 | The dashboard. Reads the database directly; anything touching a live socket goes through the hub's internal API |
| edge | Caddy | Fronts the hub and streams cached bundles straight off the shared volume |
| database | MySQL 8, MariaDB 11 or Postgres 17 | Devices, versions, rollouts, jobs, accounts |
Magnemite ships no database service
It connects to one you already run, over DATABASE_URL. DB_PROVIDER (mysql, which also covers
MariaDB, or postgresql) must match that URL, and changing it needs a rebuild, the Prisma
client is generated per provider.
┌──────────┐
you ───────────────────► web │──────► database
dashboard domain └────┬─────┘ ▲
│ /internal/* │
┌────▼─────┐ │
box ──── WSS ─────────►│ hub ├───────────┘
agents domain └────┬─────┘
│ writes artifacts
┌────▼─────┐
box ──── /files/* ────►│ edge │ (reads the same volume, read-only)
└──────────┘Why there is an edge at all?
The hub can serve /files/* itself (SERVE_ARTIFACTS=true), and in local
development it does. In production it does not, because a fleet-wide rollout is
~35 GB of .apkm bytes and there is no reason for Node to touch them.
SERVE_ARTIFACTS is false in both deployments.Caddy mounts the artifacts volume read-only, answers Range requests natively —
so a box that drops its uplink at 80% resumes instead of starting over — and
asks the hub whether the requesting device's token is good via forward_auth
against /internal/authz.
Two URLs, two audiences
Two different things talk to Magnemite, and it matters which URL each one gets.
| Audience | What it needs | Env var |
|---|---|---|
| You, in a browser | The dashboard: login, fleet, rollouts | MAGNEMITE_DASHBOARD_URL |
| The boxes | The hub: /ws/device, /api/enroll, /files/* | MAGNEMITE_PUBLIC_URL |
Everywhere a box is configured, the value is the hub URL
SERVER= when building the module, serverUrl in config.json, -server on the agent: all of
them are what MAGNEMITE_PUBLIC_URL is set to, never the dashboard's domain. The agent builds its
artifact download URLs from it, and a box pointed at the dashboard fails the handshake on
/ws/device.
A. With docker compose both live behind one virtual host on your proxy, so
the two URLs are identical and compose fills the dashboard one in for you. Set
MAGNEMITE_PUBLIC_URL and you are done.
B. With Coolify they are split across two domains — the dashboard on
magnemite.example.com, the boxes on agents.magnemite.example.com.
What the boxes can reach
A box asks for exactly four paths, and the edge routes exactly those plus a
little for its own deployment. Nothing routes /internal/*, which is what keeps
the hub's internal API unreachable from outside — the protection is the absence
of a route, not a rule.
| Path | Handled by | What asks for it |
|---|---|---|
/ws/device | hub | The persistent WebSocket every device opens and keeps |
/api/enroll | hub | First boot, trading an enrollment token for a device token |
/files/* | edge | Cached .apkm downloads, gated by forward_auth against the hub |
/api/logs/* | hub | The log bundle a box uploads when the dashboard asks for one |
/api/screenshots/* | hub | The screen capture a box uploads, too big for a socket frame |
The two edge configs are not the same file
deploy/edge/Caddyfile fronts one virtual host, so after those five it falls through to the
dashboard on web:3000 — nothing 404s there, and it also routes /api/events* to the hub.
deploy/edge/Caddyfile.coolify fronts the agents' domain only: it adds /healthz on the hub
and answers everything else with a flat 404, because the dashboard lives on its own domain. So
/healthz reaches the hub under Coolify and the dashboard under compose, and a probe written
against one deployment measures something else in the other.
The hub's internal API
Reachable only from the web container over the compose network, behind
HUB_INTERNAL_SECRET. It is the whole set of operations that need a live
socket or the scheduler.
# the hub itself
GET /internal/status GET /internal/events (SSE)
POST /internal/health POST /internal/nudge
POST /internal/settings POST /internal/tracked-packages/refresh
# rollouts and their jobs
POST /internal/rollouts POST /internal/rollouts/:id/cancel
POST /internal/rollouts/:id/resume POST /internal/rollouts/:id/retry-failed
POST /internal/jobs/:id/retry POST /internal/jobs/:id/cancel
# one device, over its live socket
POST /internal/devices/:id/reboot POST /internal/devices/:id/agent-update
POST /internal/devices/:id/exec POST /internal/devices/:id/logs
GET /internal/devices/:id/logs/:bundleId GET /internal/devices/:id/logs/live
POST /internal/devices/:id/rotom/:action POST /internal/devices/:id/rotom/workers
POST /internal/rotom/sync
# builds
POST /internal/uploads POST /internal/uploads/probe
POST /internal/uploads/from-url POST /internal/versions/:id/cache
POST /internal/versions/prune POST /internal/sources/poll
# monitoring and notifications
POST /internal/monitor/run POST /internal/notify/test
# called by the edge, not the dashboard
ALL /internal/authz (forward_auth, and the one route the
shared-secret hook lets through)There is no per-device install route. Installing one app on one box is a
rollout with one device in it, which is why POST /internal/rollouts is the
only way in: the canary, the concurrency caps, the retries and the job history
are all properties of a rollout, and a second path to installing would be a
second place for them to be forgotten.