Magnemite
Deployment

Docker, behind a proxy

One virtual host, TLS from your own reverse proxy, three containers and a db you already run.

deploy/docker-compose.yml is the self-hosted deployment: our own Caddy publishes one plain-HTTP port, serves the dashboard and the artifacts, and proxies what belongs to the hub. Nothing binds 80 or 443, the reverse proxy already fronting your server terminates TLS and forwards to EDGE_PORT.

What runs

ServiceImagePorts
hubghcr.io/pokemaplx/magnemite/hub:latest3001, internal
webghcr.io/pokemaplx/magnemite/web:latest3000, internal
caddyghcr.io/pokemaplx/magnemite/caddy:latestEDGE_PORT (8090) → 8090, HTTP

No database service: it connects to one you already run, MariaDB/MySQL or Postgres, over DATABASE_URL.

Images

CI builds one image per deployable on every push to main and publishes it to GHCR, so a deploy is a pull and a restart rather than a Go cross-compile and a Next build on the machine holding the fleet's sockets:

make pull && make up

Each service's image is a variable, so pinning a release is naming its tag in .env — the numbers are the VERSION file's, one per deployable — and rolling back is naming the previous one:

.env
HUB_IMAGE=ghcr.io/pokemaplx/magnemite/hub:1.0.4
#WEB_IMAGE=
#CADDY_IMAGE=

The published hub and web carry a MariaDB/MySQL Prisma Client, which is what DB_PROVIDER defaults to. The client is generated into the image, so on Postgres these are the wrong half of that build: leave the variables unset and deploy with make build && make up, which tags your local build under the same name. The -mysql suffix on the version tags (hub:1.0.4-mysql) is the same image, named so that this cannot be a guess.

Building locally always wins over a published image, and is also the way out on a machine that cannot reach ghcr.io — where make up fails on the pull rather than quietly building what it could not fetch.

Configuration

Everything comes from .env at the repo root. Copy .env.example:

.env
EDGE_PORT=8090
MAGNEMITE_PUBLIC_URL=https://magnemite.example.com
DB_PROVIDER=mysql
DATABASE_URL=mysql://<user>:<password>@<host>:3306/<database>

Full list: environment reference.

openssl rand -base64 32   # AUTH_SECRET
openssl rand -base64 32   # HUB_INTERNAL_SECRET

Running it

TargetRuns
make pullpull (fetch what the last push to main published)
make upup -d (pulls only images that are not on the machine yet)
make buildbuild (build locally instead — a fork, or Postgres)
make downdown (stops and removes containers)
make logslogs -f hub web
make migratepnpm --filter @magnemite/db run deploy inside hub
make seedoptional starter data and a first enrollment token

SERVICE=web narrows pull, build and up to one service — which is how a dashboard deploy leaves the hub, and the device sockets it holds, untouched.

Without make, both flags are required, the compose file lives under deploy/, .env at the repo root, and Compose would otherwise start with every variable blank:

docker compose --env-file .env -f deploy/docker-compose.yml pull
docker compose --env-file .env -f deploy/docker-compose.yml up -d
# or, building this checkout instead of pulling:
docker compose --env-file .env -f deploy/docker-compose.yml up -d --build

What your proxy must do?

One virtual host, four requirements.

Point a virtual host at the edge

http://127.0.0.1:8090, TLS terminated by you. Caddy fans the requests out by path: /files/* off the artifacts volume behind forward_auth, /ws/device, /api/enroll, /api/logs/*, /api/screenshots/* and /healthz to the hub, everything else to the dashboard.

Forward WebSocket upgrades on /ws/device

The Upgrade and Connection headers have to survive. Without them the handshake is rejected and every box retries forever with backoff, so the fleet shows as offline with nothing in the hub's log to explain it.

Never buffer /api/events

Server-sent events, and an idle stream that must never time out. A buffering proxy holds the messages until it has enough of them, which reads as a dashboard that no longer updates by itself.

Let /files/*, /api/logs/* and /api/screenshots/* through whole

Downloads are ~170 MB and boxes resume them with Range.

A log bundle and a screen capture a box uploads go the other way on the same host. Buffering to disk, a body-size cap, or a short read timeout each turn into failed installs — and a capture is on this list for the same reason it is not sent over the socket: a 1080p PNG is regularly past the 1 MB frame cap.

nginx

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 443 ssl;
  server_name magnemite.example.com;

  location / {
    proxy_pass http://127.0.0.1:8090;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_buffering off;          # SSE, and 170 MB bodies
    proxy_request_buffering off;  # log bundles the boxes upload
    client_max_body_size 0;
    proxy_read_timeout 3600s;     # idle SSE and slow downloads
  }
}

Caddy

magnemite.example.com {
  reverse_proxy 127.0.0.1:8090 {
    flush_interval -1
  }
}

Caddy upgrades WebSockets and passes Range through on its own; flush_interval -1 is what stops it buffering the SSE stream.

Sign in

Open your domain and log in with ADMIN_EMAIL / ADMIN_PASSWORD. The admin account is created when the hub boots. Optionally, run make seed to create a starter app target, two version sources, a group and a first enrollment token, printed once. Skip it and create the same things from Settings.

Build the Magisk module

SERVER is the hub URL, the same value as MAGNEMITE_PUBLIC_URL:

make module SERVER=https://magnemite.example.com TOKEN=<enrollment token>
# → dist/magnemite-agent-1.0.0.zip

Windows: ./scripts/build-agent.ps1 -Server https://… -Token <token>.

Flash a box

adb push dist/magnemite-agent-1.0.0.zip /data/local/tmp/
adb shell "su -c 'magisk --install-module /data/local/tmp/magnemite-agent-1.0.0.zip'"
adb reboot

In bulk over the LAN: ./scripts/enroll.sh dist/magnemite-agent-1.0.0.zip -f hosts.txt.

The box enrolls itself on first boot and appears in the fleet table. That is the only time it needs hands-on work, yes! Every later update, the agent's own included, rides the socket it opened. Details in the agent on a box.

Upgrading

git pull
make build
make up
make migrate

Rebuilding hub drops every device socket; agents reconnect with backoff and queued jobs resume. To leave them alone, rebuild the dashboard by itself:

make build up SERVICE=web

On this page