Self-hosting
Run the scheduler and SSP yourself, single node or distributed, with the environment variables each needs.
Deploying Sp00ky requires running the Sp00ky Sidecar (SSP) alongside your SurrealDB instance. For production deployments, the Scheduler coordinates multiple SSP instances.
Architecture
- SurrealDB: Your persistent data store.
- Sp00ky SSP (Sidecar): A backend service that monitors SurrealDB for changes and maintains materialized views.
- Scheduler (optional): Central coordinator for distributed deployments with multiple SSPs. Maintains a RocksDB snapshot replica and WAL.
- Client: Connects directly to SurrealDB.
Single Node (Development)
The simplest setup for development and testing. No scheduler needed.
Distributed (Production)
For production with high availability and horizontal scaling, use a Scheduler with multiple SSP instances.
The scheduler listens on two ports and they are not equivalent. Port 9667
is the ingest plane, and every route on it is unauthenticated by design because
it is meant to be reachable only from inside your private network. That
includes POST /proxy/query, which runs arbitrary SurrealQL against the
replica for any caller that can open a socket to it. Publishing 9667 hands
your database to the internet.
Port 9668 is the admin plane. It is authenticated, and it is the one you
publish, behind TLS. The example above binds it to 127.0.0.1 so you can put a
reverse proxy in front of it rather than exposing it directly.
Operator dashboard
The scheduler image bundles a dashboard and serves it on the admin port at
/admin. It shows the health of the scheduler and every SSP, the end-to-end
sync latency, backend health, live logs, and workflow runs, and it can restart
SSPs, cancel and retry workflow runs, pause schedules and take backups.
Point a browser at http://your-scheduler:9668/admin and sign in one of two
ways:
- An admin account.
spky admin add <user>puts an existing app user on the_00_adminroster; they then sign in with their normal app credentials. The roster starts empty, so until you add someone nobody can sign in this way. - The break-glass password. Set
SPKY_ADMIN_PASSWORDon the scheduler and sign in with that alone. It bypasses the roster, which is what makes it useful when the database itself is the thing that is down, so the dashboard shows a persistent banner while a session is using it.
The same port also serves an MCP server at /admin/api/mcp, so an AI
assistant can read the cluster and run the same actions. Tokens are minted from
the dashboard’s Access page.
Everything works self-hosted except the actions that only Sp00ky Cloud can
perform, because they act on containers rather than on processes: pulling newer
images, wiping the scheduler volume, bouncing SurrealDB, and the backup
catalog, schedule, retention and delete. Those answer 409 with Not linked to Sp00ky Cloud, and the dashboard shows them greyed out with that reason rather
than hiding them. Backups themselves still work: configure the S3_* variables
below and the dashboard lists the bucket directly.
Sessions are signed with SPKY_AUTH_SECRET when you set one, which means they
survive a scheduler restart. Without it they live in memory and every restart
signs you out. See Admin dashboard for the
full surface and MCP server for the agent side.
Environment Variables
SSP (Sidecar)
All SPKY_* vars below are read directly from the process environment.
| Variable | Description | Default |
|---|---|---|
SPKY_SSP_LISTEN_ADDR | Address and port for the SSP HTTP server | 0.0.0.0:8667 |
SPKY_AUTH_SECRET | Bearer token for authenticating SSP API requests. When unset the auth middleware accepts any bearer (dev only). | (empty) |
SPKY_DB_URL | SurrealDB URL (HTTP engine; ws:// values are accepted and normalized) | http://127.0.0.1:8000 |
SPKY_DB_WS | Legacy fallback for SPKY_DB_URL (any scheme accepted) | (unset) |
SPKY_DB_USER | SurrealDB username | root |
SPKY_DB_PASS | SurrealDB password | root |
SPKY_DB_NS | SurrealDB namespace | test |
SPKY_DB_NAME | SurrealDB database | test |
SPKY_SCHEDULER_URL | Scheduler URL (enables distributed mode) | (unset) |
SPKY_SSP_ID | Unique SSP identifier | ssp-<uuid> |
SPKY_SSP_ADVERTISE_ADDR | Externally reachable host:port for this SSP | (unset) |
HEARTBEAT_INTERVAL_MS | Heartbeat interval in ms | 5000 |
TTL_CLEANUP_INTERVAL_SECS | View TTL cleanup interval | 60 |
SPKY_SCHEMA_POLL_SECS | How often (seconds) the SSP probes upstream’s schema and applies added, changed and removed tables in place, with no restart or rebuild. 0 turns the poll off; a view registration naming a table the SSP does not know yet still refreshes. | 15 |
SPKY_INGEST_TRANSPORT | Standalone mode only (no SPKY_SCHEDULER_URL): changefeed makes the SSP tail SurrealDB’s CHANGEFEED itself instead of receiving /ingest posts from the DB events. Takes the same SPKY_CHANGEFEED_* knobs as the scheduler. In cluster mode the scheduler tails. | http |
SPKY_SSP_REF_MODE | _00_list_ref storage layout: dedicated (per-user tables; works around the SurrealDB v3 LIVE permission gap) or single (legacy shared table). Mirrors refMode in sp00ky.yml. | dedicated |
SPKY_SSP_QUERY_UPDATE_THROTTLE_MS | Window (ms) over which the SSP coalesces query edge-update writes to _00_list_ref into one batched transaction, so a burst of view updates lands as a few batched LIVE deliveries instead of one transaction per record. 0 disables batching (flush each update immediately). See Architecture. | 100 |
SPKY_JOB_CONFIG | JSON-encoded { "job_tables": { … } } describing backends the SSP can dispatch to. | (unset) |
SPKY_CRDT_CACHE_SIZE | In-memory CRDT cache capacity | 10000 |
SPKY_CRDT_FIELDS | JSON override for @crdt field detection, e.g. {"thread":["content"]} | (auto) |
SPKY_SSP_BOOTSTRAP_PAGE_SIZE | Rows pulled per /proxy/query page | 500 |
SPKY_SSP_REGISTER_MAX_WAIT_SECS | How long to keep retrying scheduler registration before exiting for a restart | 180 |
SPKY_SSP_ANON_LIVE_QUERIES | Allow live queries from unauthenticated clients (1/true to enable) | false |
SPKY_SSP_QUERY_ALLOWLIST | off, warn or enforce. Compare every /view/register shape against the _00_query_allowlist rows the CLI publishes; warn logs and admits a miss, enforce refuses it with 403 not_allowlisted. Mirrors sync.queryAllowlist in sp00ky.yml. See the Query allowlist guide. | off |
SPKY_SSP_VIEW_METRICS_FLUSH_MS | How often view metrics are flushed to the database | 2000 |
SPKY_SSP_MEMORY_LIMIT_MB | Memory ceiling this SSP reports itself against. The heartbeat sends usage as a fraction of it, which is what the scheduler’s LeastLoad strategy ranks on, so an unset or wrong value skews balancing. | 1024 |
OTEL_EXPORTER_OTLP_ENDPOINT | OpenTelemetry collector endpoint for SSP metrics | (unset) |
OTEL_SERVICE_NAME | OTEL service identifier | ssp |
Circuit persistence
Without a snapshot directory every restart is a cold rebuild: the SSP re-pages the whole database while the scheduler holds sync frozen. Point these at a volume and a restart restores the snapshot and catches up only what it missed. Both are caches of SurrealDB, never the source of truth, so deleting them is always safe. See State persistence.
| Variable | Description | Default |
|---|---|---|
SPKY_SSP_SNAPSHOT_DIR | Directory for the circuit snapshot (snapshot.json). Enables snapshots when set and writable; the SSP probes it and falls back to memory-only rather than failing startup. | (unset) |
SPKY_SSP_CHECKPOINT_INTERVAL_SECS | How often the snapshot is rewritten while running. Only read when SPKY_SSP_SNAPSHOT_DIR is set, which arms the timer. | 300 |
SPKY_SSP_ARENA_DIR | Directory for the row arena, a sparse-file cache of row bytes. Probed for writability like the snapshot dir; falls back to the heap. | (unset) |
SPKY_SSP_ARENA_SEGMENT_MB | Arena segment size in MB | 64 |
Scheduler
The scheduler reads most of its config from sp00ky.yml, and the SPKY_* vars
in the first table below override the corresponding YAML field. The fields
ingest_host, ingest_port, load_balance, replica_db_path, wal_path,
ssp_poll_interval_ms, max_buffer_per_ssp and job_tables are YAML only.
The tables after it are env only and have no YAML equivalent: the admin plane, backup storage, the dead-man heartbeat, and the tuning knobs.
Core
| Variable | Description | Default |
|---|---|---|
SPKY_SCHEDULER_ID | Unique scheduler identifier | scheduler-<uuid> |
SPKY_DB_URL | SurrealDB URL (HTTP engine; ws:// values are accepted and normalized) | http://localhost:8000 |
SPKY_DB_WS | Legacy fallback for SPKY_DB_URL (any scheme accepted) | (unset) |
SPKY_DB_NS | SurrealDB namespace | sp00ky |
SPKY_DB_NAME | SurrealDB database | sp00ky |
SPKY_DB_USER | SurrealDB username | root |
SPKY_DB_PASS | SurrealDB password | root |
SPKY_SNAPSHOT_UPDATE_INTERVAL_SECS | Snapshot update interval (seconds) | 300 |
SPKY_DRIFT_CHECK | Compare the replica’s row counts against upstream at startup and after each snapshot drain | true |
SPKY_DRIFT_AUTO_RECLONE | Act when the check confirms a mismatch: repair the table in place by sending the rows that differ through the ingest pipeline, and re-clone the replica (re-bootstrapping every SSP) only when a repair cannot fix it (false = report only) | true |
SPKY_DRIFT_CONFIRM_TICKS | Consecutive checks a non-zero count mismatch must persist before acting (an empty table upstream has rows for is acted on immediately) | 2 |
SPKY_DRIFT_RECLONE_COOLDOWN_SECS | Minimum seconds between automatic re-clones | 3600 |
SPKY_DRIFT_REPAIR_MAX_ROWS | Most rows one in-place repair of a table the replica already held may send; a bigger difference falls back to a re-clone. A table new to the replica (added upstream, or with @nosync removed) is backfilled with no cap, in the background, and never re-cloned for | 2000 |
SPKY_DRIFT_REPAIR_TIMEOUT_SECS | Deadline for one table repair, and the longest any repair (a backfill included) may go without finishing a page; past it the repair is abandoned, the upstream connection replaced, and the repair retried on the next check | 300 |
SPKY_DRIFT_CHECK_TIMEOUT_SECS | Deadline for one drift check; past it the check is abandoned and the upstream connection replaced, so a wedged query cannot stop the replica draining | 120 |
SPKY_BOOTSTRAP_PAGE_SIZE | Rows read per bootstrap page from the replica | 500 |
SPKY_AUTH_SECRET | Optional shared secret; when set, callers to /proxy/* must send Authorization: Bearer <secret>. Also signs admin dashboard sessions, so setting it is what makes them survive a restart. | (empty) |
SPKY_BACKENDS | JSON-encoded backend health-check list (SPKY_SCHEDULER_BACKENDS accepted as legacy fallback) | (unset) |
Admin plane
The dashboard and its MCP server, on their own port. See Admin dashboard.
| Variable | Description | Default |
|---|---|---|
SPKY_ADMIN_ENABLED | Serve the admin plane at all. Set to 0, false or off to disable it; any other value (or unset) enables it. | true |
SPKY_ADMIN_PORT | Port for the admin plane | 9668 |
SPKY_ADMIN_HOST | Bind address for the admin plane | 0.0.0.0 |
SPKY_ADMIN_DIR | Directory holding the built dashboard. The official image ships one here; a scheduler built from a checkout serves a placeholder page instead. | /usr/share/spooky/dashboard |
SPKY_ADMIN_PASSWORD | Break-glass password, which signs in without the _00_admin roster. An empty value is treated as unset, so a deployment that templates the var but leaves it blank does not end up with a guessable password. | (unset) |
SPKY_ADMIN_ACCESS | Pins the SurrealDB record-access method used for admin sign-in. When unset the accesses are discovered from INFO FOR DB. | (auto) |
SPKY_ADMIN_SESSION_TTL_SECS | How long a signed-in session lasts | 28800 (8h) |
SPKY_ADMIN_PRESENCE_INTERVAL_SECS | How often the presence sampler reads _00_query, which feeds the Overview charts and the Views tab. One sampler serves every open dashboard, so this is the total cost however many people are watching. | 15 |
SPKY_ADMIN_SLOW_VIEW_MS | Materialization p99 at which a registered view is counted as slow. Only the default; GET /admin/api/views?slow_ms= overrides it per request. | 250 |
SPKY_ADMIN_LARGE_VIEW_ROWS | Row count from which a registered view is flagged as large. The Views tab and the Overview warn on these because an unwindowed live view republishes every row as a _00_list_ref edge on each cold registration, in one transaction, and a few thousand of those at once has stalled a tenant’s SurrealDB. Window such queries (LIMIT/START) or resolve rows by id. GET /admin/api/views?large=true lists them. | 1000 |
SPKY_ADMIN_PRESENCE_MAX_ROWS | Ceiling on the rows one presence or views query pulls back, so a tenant with a runaway number of registrations cannot make the sampler the expensive thing on the box. Above it the figures are reported as a floor. | 20000 |
SPKY_ADMIN_JOB_INTERVAL_SECS | How often the job sampler reads your outbox tables, feeding the Jobs page, its totals and the Overview tile. Every job aggregate is a scan of one of your own tables, so one sampler takes them for every open dashboard. Set it to 0 to switch the sampler off entirely: the Jobs list and its filters still work, and the totals then report that they are not being measured rather than showing zeros. | 15 |
SPKY_ADMIN_JOB_LIVE_INTERVAL_SECS | The cadence while at least one dashboard holds the Jobs stream open, so a backlog can be watched draining. Clamped to no slower than the idle interval. | 2 |
Machine pools
The listener machine pool agents connect to, and the docker provider a self-hosted scheduler can create machines with.
| Variable | Description | Default |
|---|---|---|
SPKY_POOL_ENABLED | Serve pools at all. 0 or false turns the listener and the pool sweep off. | true |
SPKY_POOL_PORT | Port of the pool listener | 9669 |
SPKY_POOL_HOST | Bind address of the pool listener | 0.0.0.0 |
SPKY_POOL_PUBLIC_URL | The listener’s URL as a machine reaches it. Handed to every machine the scheduler creates. | http://scheduler:<port> |
SPKY_POOL_SECRET | Key that signs each machine’s token. Must survive restarts, or running machines are locked out and their jobs fail over. Falls back to SPKY_AUTH_SECRET. | (SPKY_AUTH_SECRET) |
SPKY_POOL_DOCKER | Enable the docker provider: machines are containers on this scheduler’s Docker host. Needs the Docker socket mounted. | (off) |
SPKY_POOL_DOCKER_NETWORK | Docker network machine containers join, so they can reach SPKY_POOL_PUBLIC_URL | (default bridge) |
SPKY_POOL_AGENT_IMAGE | Image carrying the spky-agent binary each machine runs. Defaults to the agent released with this scheduler, which is the one that speaks its protocol. | mono424/spooky-agent:<release> |
Backup storage
These carry no SPKY_ prefix. The backup plane is enabled as soon as any of
S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY or S3_BUCKET is present, so a
partial configuration turns backups on with defaults filling the rest. Any
S3-compatible store works. Also read by a standalone SSP, which serves the same
backup routes.
| Variable | Description | Default |
|---|---|---|
S3_ENDPOINT | S3-compatible endpoint | http://10.100.1.5:9000 |
S3_ACCESS_KEY | Access key | minioadmin |
S3_SECRET_KEY | Secret key | minioadmin |
S3_BUCKET | Bucket backups are written to | backups |
S3_REGION | Region | us-east-1 |
SPKY_PROJECT_SLUG | Prefix backups are stored under, as <slug>/<backup-id>.surql.gz. Also what the dashboard lists when it is not linked to Sp00ky Cloud. | default |
Heartbeat and alerting
An end-to-end probe: the scheduler writes _00_heartbeat:probe upstream and
times the full round trip through /ingest, the WAL, the broadcast and every
SSP’s circuit. It is what the dashboard’s latency reading and the /health
staleness flag are built on.
| Variable | Description | Default |
|---|---|---|
SPKY_HEARTBEAT_INTERVAL_SECS | Seconds between probe cycles. 0 disables the probe. | 30 |
SPKY_HEARTBEAT_TIMEOUT_SECS | Deadline for one cycle. Clamped below the interval so a cycle always finishes before the next tick. | 25 |
SPKY_HEARTBEAT_FAIL_THRESHOLD | Consecutive failed cycles before the alert fires | 3 |
SPKY_HEARTBEAT_PING_URL | Dead-man URL pinged after each successful cycle, for an external uptime monitor | (unset) |
SPKY_ALERT_WEBHOOK_URL | Webhook posted to when the probe crosses the failure threshold and again when it recovers | (unset) |
SPKY_HEALTH_MAX_HEARTBEAT_AGE_SECS | How stale the last successful probe may be before /health degrades. Floored at 3 x interval + timeout, so a smaller value has no effect. | (computed) |
Tuning
| Variable | Description | Default |
|---|---|---|
SPKY_WORKER_THREADS | Tokio worker threads | 4 |
SPKY_HTTP_TIMEOUT_SECS | Request deadline on the ingest plane, so a wedged handler produces a 408 rather than a hung connection. The admin plane is exempt because its log and event streams are meant to stay open. | 120 |
SPKY_HEALTH_MAX_LAG | Ingest lag above which /health reports degraded. Unset means lag never degrades health. | (unset) |
SPKY_CLONE_TIMEOUT_SECS | Ceiling on the initial replica clone. Overrunning it fails startup and exits, rather than leaving the scheduler wedged in cloning and answering 503 to every SSP for the life of the container. | 900 |
SPKY_BOOTSTRAP_TIMEOUT_SECS | How long an SSP may take to bootstrap before the scheduler reaps it. Also half of what the dashboard waits on before calling a restart failed. | 300 |
SPKY_CLEAR_VIEWS_ON_START | Wipe every registered view (_00_query) when the scheduler starts. false keeps them: the replica clones them, the SSPs re-register them at bootstrap, and clients keep their views across a scheduler restart with no re-registration; the SSP TTL sweep retires rows nobody heartbeats. Leave true on tenants with more than one SSP for now (every SSP would compute every view). | true |
SPKY_INGEST_TRANSPORT | http (the generated DB events post to /ingest) or changefeed (the scheduler tails SurrealDB’s CHANGEFEED; must match the schema deployed with sync.transport). See Sync transport. | http |
SPKY_CHANGEFEED_RETENTION | The schema’s CHANGEFEED retention, rendered form (1d). A cursor older than this minus the margin is a gap: the replica is re-cloned. | 1d |
SPKY_CHANGEFEED_DOORBELL | LIVE SELECT id FROM _00_version over WebSocket as the wake-up for the tail; false = poll only. | true |
SPKY_CHANGEFEED_DEBOUNCE_MS | Wait after a doorbell wake before polling, so a bulk transaction costs one poll. | 5 |
SPKY_CHANGEFEED_FALLBACK_MS | Safety-net poll interval while the doorbell is connected. | 2000 |
SPKY_CHANGEFEED_FALLBACK_DOWN_MS | Poll interval while the doorbell is reconnecting. | 250 |
SPKY_CHANGEFEED_GAP_MARGIN_SECS | Clock-skew allowance subtracted from the retention in the gap check. | 300 |
SPKY_CHANGEFEED_POLL_LIMIT | Feed keys per SHOW CHANGES (SurrealDB counts one key per transaction and table). A poll that ends mid-transaction is re-read from that transaction. | 500 |
SPKY_CHANGEFEED_POLL_TIMEOUT_SECS | Deadline for one SHOW CHANGES; past it the request is abandoned and the tail’s session replaced. Also the ceiling of the outage a wedged SHOW CHANGES can cause on SurrealDB 3.1.5, so keep it short. | 5 |
SPKY_BOOTSTRAP_RECLONE_AFTER | Consecutive bootstrap integrity failures before the replica is re-cloned from upstream | 3 |
SPKY_BOOTSTRAP_ADMIT_AFTER | Consecutive bootstrap integrity failures before the SSP is admitted to broadcast anyway, restoring sync. Floored at the re-clone threshold, so admit never precedes a re-clone attempt. | 5 |
SPKY_CATCHUP_RECLONE_AFTER | Same escalation for the catch-up gate | 3 |
SPKY_CATCHUP_ADMIT_AFTER | Same escalation for the catch-up gate | 5 |
A managed deployment also receives SPKY_CLOUD_API_URL and
SPKY_CLOUD_PROJECT on the scheduler container. They are what let the
dashboard ask the control plane to upgrade images, wipe the volume or manage
the backup catalog, authenticated with the project’s own SPKY_AUTH_SECRET.
Self-hosted schedulers leave them unset and report cloud_linked: false.