Operators
Self-hosting the coordinator
T73 (DAN-279) · opens M8 (enterprise). This guide shows how to run the NeuronPool coordinator on your own infrastructure — via Docker, Kubernetes (Helm), or your own Cloudflare account — including a fully air-gapped mode with no outbound calls to external SaaS.
The coordinator is a Cloudflare Worker (Hono) backed by D1 (SQLite), KV, and Durable Objects. Off Cloudflare it runs on the same runtime via Miniflare/workerd (wrangler dev --local), with D1/KV/DO state persisted to a local volume. No source changes are required to self-host — the packaging in deploy/ points the same workers/src entrypoint at a self-host Wrangler config.
Contents
- Modes at a glance
- Quick start: Docker Compose
- Kubernetes (Helm)
- Your own Cloudflare account
- Air-gapped mode
- Configuration reference
- Migrations & upgrades
- Backup & restore
- Limitations
- Licensing
Modes at a glance
| Deployment | Runtime | State | Best for |
|---|---|---|---|
| Docker Compose | wrangler dev --local (workerd) | SQLite/KV/DO on a Docker volume | single-node, on-prem, air-gapped |
| Kubernetes (Helm) | wrangler dev --local (workerd) | SQLite/KV/DO on a PVC | clustered on-prem, air-gapped |
| Cloudflare (self-account) | Cloudflare Workers | D1 / KV / Durable Objects | your own CF tenant |
All three share one flag — AIR_GAPPED — that short-circuits outbound calls to external SaaS (Stripe, OpenRouter, Rekor/Sigstore, Hugging Face). It is additive and defaults off on the hosted control plane; the self-host packaging defaults it on.
Quick start: Docker Compose
Prerequisites: Docker with Compose v2. Run from the repository root:
docker compose -f deploy/docker/docker-compose.yml up --build
This builds deploy/docker/Dockerfile.selfhost, applies the D1 migrations from migrations/ to a local SQLite store, and starts the coordinator on port 8787 with state persisted to the neuronpool-data volume. Verify:
curl http://localhost:8787/health
# {"ok":true,"service":"neuronpool","name":"NeuronPool (self-hosted)"}
curl http://localhost:8787/.well-known/neuronpool.json
The container defaults to air-gapped (AIR_GAPPED=1). To run a connected self-host that keeps billing/distribution online, set AIR_GAPPED=0 and provide the relevant secrets (see Configuration reference) via an env file or your orchestrator's secret store — never commit real values.
How it runs
The image installs the pinned wrangler plus the platform workerd binary at build time, so nothing is downloaded at runtime. On start, deploy/docker/entrypoint.sh:
wrangler d1 migrations apply neuronpool-db --local --persist-to /data/.wrangler-statewrangler dev --local --ip 0.0.0.0 --port 8787 --persist-to /data/.wrangler-state
using deploy/wrangler/wrangler.selfhost.jsonc.
Kubernetes (Helm)
The chart lives at deploy/helm/neuronpool/. It deploys a single-replica coordinator with a ConfigMap (non-secret env), an optional Secret (connected mode), a PersistentVolumeClaim for D1/KV/DO state, a Service, and an optional Ingress.
Build and push the image first, then install:
docker build -f deploy/docker/Dockerfile.selfhost -t <registry>/neuronpool-coordinator:selfhost .
docker push <registry>/neuronpool-coordinator:selfhost
helm upgrade --install neuronpool deploy/helm/neuronpool \
--namespace neuronpool --create-namespace \
--set image.repository=<registry>/neuronpool-coordinator \
--set image.tag=selfhost \
--set config.appUrl=https://neuronpool.internal.example.com
Render and inspect before applying:
helm lint deploy/helm/neuronpool
helm template neuronpool deploy/helm/neuronpool
Expose it with an Ingress:
helm upgrade --install neuronpool deploy/helm/neuronpool \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set ingress.hosts[0].host=neuronpool.internal.example.com \
--set ingress.hosts[0].paths[0].path=/ \
--set ingress.hosts[0].paths[0].pathType=Prefix
Connected mode (billing/distribution online) — provide secrets inline or via a pre-created Secret:
# Inline (rendered into a chart-managed Secret):
helm upgrade --install neuronpool deploy/helm/neuronpool \
--set airGapped=false \
--set secrets.STRIPE_SECRET_KEY=rk_test_... \
--set secrets.STRIPE_WEBHOOK_SECRET=whsec_...
# Or reference an existing Secret with the same keys:
kubectl -n neuronpool create secret generic neuronpool-extra \
--from-literal=STRIPE_SECRET_KEY=rk_test_...
helm upgrade --install neuronpool deploy/helm/neuronpool \
--set airGapped=false --set existingSecret=neuronpool-extra
The coordinator keeps local D1/KV/DO state on a single
ReadWriteOncevolume, soreplicaCountis fixed at 1 and the Deployment uses theRecreatestrategy. Do not scale it horizontally — see Limitations.
Your own Cloudflare account
To self-host on your own Cloudflare tenant (managed D1/KV/DO instead of local SQLite), use the same template deploy/wrangler/wrangler.selfhost.jsonc and fill in your account and resource ids:
# 1. Create resources on your account
wrangler d1 create neuronpool-db
wrangler kv namespace create STATS_KV
wrangler kv namespace create CANARY_KV
# 2. Paste the returned ids + your account_id into
# deploy/wrangler/wrangler.selfhost.jsonc (uncomment account_id).
# 3. Apply migrations remotely and deploy
wrangler d1 migrations apply neuronpool-db --remote -c deploy/wrangler/wrangler.selfhost.jsonc
wrangler deploy -c deploy/wrangler/wrangler.selfhost.jsonc
# 4. Set secrets (connected mode only)
wrangler secret put STRIPE_SECRET_KEY -c deploy/wrangler/wrangler.selfhost.jsonc
Leave AIR_GAPPED=1 in the template for a locked-down deployment, or set 0 for a connected one.
This is not the hosted NeuronPool control plane. Do not run
./deploy.sh— that targets the primary hosted Worker. Never use--remoteagainst resources you do not own.
Air-gapped mode
Air-gapped mode is for deployments with no route to the public internet. It is controlled by the additive AIR_GAPPED variable (workers/src/types.ts → Bindings.AIR_GAPPED).
- Accepted "on" values (case/space-insensitive):
1,true,yes,on. - Anything else (including unset) is off — this preserves the hosted control plane's existing behaviour exactly.
When on, the guard in workers/src/env.ts reports the following external SaaS hosts as blocked so callers short-circuit them:
| Service | Hosts | What it gates |
|---|---|---|
stripe | api.stripe.com, files.stripe.com | Checkout, Connect payouts, Invoicing |
openrouter | openrouter.ai, api.openrouter.ai | OpenRouter distribution surface |
rekor | rekor.sigstore.dev, search.sigstore.dev | Blessed-build transparency-log re-verification |
huggingface | huggingface.co, api-inference.huggingface.co | Hugging Face model/provider fetches |
Internal Durable Object fetches (https://job-bridge/…, https://account-lock/…) and the coordinator's own origin are always allowed — only the external providers above are short-circuited.
The guard exposes three helpers callers use before an outbound request:
import { isAirGapped, isEgressAllowed, assertEgressAllowed } from "./env";
if (isAirGapped(env)) { /* skip the optional external feature */ }
if (isEgressAllowed(env, url)) { await fetch(url); } // branch
assertEgressAllowed(env, url); // or fail closed
Core inference — host register/heartbeat/lease, job routing, metering against local credits, pools, the OpenAI-compatible gateway, and the discovery document — has no external dependency and works unchanged in air-gapped mode.
Air-gapped operational notes
- Billing: with
AIR_GAPPED=1, do not configure Stripe. Credits are managed locally (seed/administer via the account/ledger paths). KeepPLATFORM_FEE_BPS=0. - Blessed builds: import release manifests from a file rather than fetching Rekor. The signature still verifies against the pinned
RELEASE_PUBKEY; the Rekor transparency-log re-check is what's skipped offline. - Model artifacts: stage GGUF/MLX weights on the hosts out-of-band; the coordinator only brokers jobs and never downloads model files itself.
- Egress policy: for defense in depth, also enforce the block at the network layer (Kubernetes
NetworkPolicy/ egress firewall) so the guard is not the only line of defense. - Host telemetry:
POST /v1/telemetryis accepted but not stored whenAIR_GAPPED=1. Hosts withNEURONPOOL_AIR_GAPPED=1never send. Seedocs/provider/telemetry.md.
Configuration reference
Non-secret vars (Docker env / Helm config.* / Wrangler vars):
| Var | Default (self-host) | Purpose |
|---|---|---|
AIR_GAPPED | 1 | Air-gapped mode flag (see above). |
APP_NAME | NeuronPool (self-hosted) | Display name in /health and discovery. |
APP_URL | http://localhost:8787 | Public URL for discovery + redirects. |
STRIPE_MODE | test | Stripe mode when connected. |
PLATFORM_FEE_BPS | 0 | Platform fee in basis points. |
PERSIST_TO | /data/.wrangler-state | Local D1/KV/DO state dir (Docker/K8s). |
Secrets (connected mode only; set via wrangler secret put, a K8s Secret, or a Docker env file — never commit):
| Secret | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe Checkout + Connect. Prefer a restricted rk_… key. |
STRIPE_WEBHOOK_SECRET | Verifies Stripe webhooks. |
RELEASE_PUBKEY | Ed25519 pubkey pinning blessed-build ingestion. |
OPS_KEY | Bearer secret gating the ops channel report. |
POE_ACCESS_KEY | Poe server-bot access (distribution). |
Migrations & upgrades
Migrations in migrations/ are additive and forward-only.
- Docker/K8s (local store): the entrypoint runs
wrangler d1 migrations apply neuronpool-db --local --persist-to <PERSIST_TO>on every start, so a newer image applies any new migrations against the same persisted volume automatically. Upgrade = pull the new image and restart (docker compose pull && up -d, orhelm upgradewith the newimage.tag). - Cloudflare self-host: run
wrangler d1 migrations apply neuronpool-db --remote -c deploy/wrangler/wrangler.selfhost.jsoncbeforewrangler deploy.
Roll back by redeploying the previous image tag. Because migrations are additive, an older coordinator tolerates newer columns; still, snapshot the state volume (below) before a major upgrade.
Backup & restore
State lives entirely under PERSIST_TO (/data/.wrangler-state): the D1 SQLite database, KV, and Durable Object storage.
# Docker: snapshot the named volume
docker run --rm -v neuronpool-data:/data -v "$PWD:/backup" busybox \
tar czf /backup/neuronpool-state-$(date +%F).tgz -C /data .
# Restore into a fresh volume
docker run --rm -v neuronpool-data:/data -v "$PWD:/backup" busybox \
sh -c "cd /data && tar xzf /backup/neuronpool-state-YYYY-MM-DD.tgz"
On Kubernetes, snapshot the PVC (VolumeSnapshot if your CSI driver supports it, or tar from a debug pod mounting the claim). For a Cloudflare self-host, use D1 export (wrangler d1 export) and KV listing/backup on your account.
Limitations
- Single node. Local D1/KV/DO state is a single SQLite/
workerdstore on one RWO volume, so the Docker/Helm coordinator runs one replica. For HA/scale, self-host on your own Cloudflare account (managed D1/KV/DO) instead. wrangler devruntime. The off-Cloudflare packaging useswrangler dev --local(Miniflare/workerd) as the documented self-host runtime. A dedicated Node/Bun build with SQLite/Postgres store adapters is tracked separately (T73.1 store adapters) and is out of scope for this change.- Native rate-limiting bindings run under Miniflare in local mode; behaviour can differ slightly from Cloudflare's production limiter.
- Air-gapped disables external features (Stripe billing, OpenRouter/HF distribution, Rekor re-verification). Core inference is unaffected.
- Scheduled (cron) triggers are not fired automatically by
wrangler dev; invoke the maintenance tick manually if needed (curl http://localhost:8787/cdn-cgi/local/scheduled) or run an external scheduler in air-gapped deployments.
Licensing
The coordinator (workers/) is source-available under BSL 1.1, converting to Apache-2.0 after four years (see workers/LICENSE and the README licensing section). Production self-hosting is subject to those BSL terms. The host agent, shared library, protocol, and SDKs are Apache-2.0.