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
| Service | Image | Ports |
|---|---|---|
hub | ghcr.io/pokemaplx/magnemite/hub:latest | 3001, internal |
web | ghcr.io/pokemaplx/magnemite/web:latest | 3000, internal |
caddy | ghcr.io/pokemaplx/magnemite/caddy:latest | EDGE_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 upEach 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:
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:
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_SECRETRunning it
| Target | Runs |
|---|---|
make pull | pull (fetch what the last push to main published) |
make up | up -d (pulls only images that are not on the machine yet) |
make build | build (build locally instead — a fork, or Postgres) |
make down | down (stops and removes containers) |
make logs | logs -f hub web |
make migrate | pnpm --filter @magnemite/db run deploy inside hub |
make seed | optional 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 --buildWhat 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.zipWindows: ./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 rebootIn 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 migrateRebuilding 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