Github|...

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

  1. SurrealDB: Your persistent data store.
  2. Sp00ky SSP (Sidecar): A backend service that monitors SurrealDB for changes and maintains materialized views.
  3. Scheduler (optional): Central coordinator for distributed deployments with multiple SSPs. Maintains a RocksDB snapshot replica and WAL.
  4. Client: Connects directly to SurrealDB.

Single Node (Development)

The simplest setup for development and testing. No scheduler needed.

services:
  surrealdb:
    image: surrealdb/surrealdb:v3.1.0-beta.3
    command: start --user root --pass root rocksdb:/data/db
    ports:
      - "8666:8000"
    volumes:
      - ./data:/data

  ssp:
    image: ghcr.io/sp00ky-org/ssp-server:canary
    environment:
      - SPKY_DB_WS=ws://surrealdb:8000
      - SPKY_DB_USER=root
      - SPKY_DB_PASS=root
      - SPKY_DB_NS=main
      - SPKY_DB_NAME=app
      - SPKY_SSP_LISTEN_ADDR=0.0.0.0:8667
      - SPKY_AUTH_SECRET=your-secret-token
      - SPKY_SSP_REF_MODE=dedicated
    ports:
      - "8667:8667"
    depends_on:
      - surrealdb

Distributed (Production)

For production with high availability and horizontal scaling, use a Scheduler with multiple SSP instances.

services:
  surrealdb:
    image: surrealdb/surrealdb:v3.1.0-beta.3
    command: start --user root --pass root rocksdb:/data/db
    ports:
      - "8666:8000"
    volumes:
      - surreal-data:/data

  scheduler:
    image: ghcr.io/sp00ky-org/scheduler:canary
    environment:
      - SPKY_DB_WS=ws://surrealdb:8000
      - SPKY_DB_NS=main
      - SPKY_DB_NAME=app
      - SPKY_DB_USER=root
      - SPKY_DB_PASS=root
      - SPKY_SCHEDULER_ID=scheduler-01
      - SPKY_AUTH_SECRET=your-secret-token
    # Bind host/port and storage paths come from the scheduler's
    # sp00ky.yml (ingest_host, ingest_port, replica_db_path, wal_path).
    ports:
      # 9668 is the admin plane: the operator dashboard and its MCP server.
      # This is the only scheduler port you publish. 9667 stays internal.
      - "127.0.0.1:9668:9668"
    volumes:
      - scheduler-data:/app/data
    depends_on:
      - surrealdb

  ssp-1:
    image: ghcr.io/sp00ky-org/ssp-server:canary
    environment:
      - SPKY_SSP_ID=ssp-01
      - SPKY_DB_WS=ws://surrealdb:8000
      - SPKY_DB_USER=root
      - SPKY_DB_PASS=root
      - SPKY_DB_NS=main
      - SPKY_DB_NAME=app
      - SPKY_SSP_LISTEN_ADDR=0.0.0.0:8667
      - SPKY_AUTH_SECRET=your-secret-token
      - SPKY_SCHEDULER_URL=http://scheduler:9667
      - SPKY_SSP_ADVERTISE_ADDR=ssp-1:8667
      - SPKY_SSP_REF_MODE=dedicated
      - HEARTBEAT_INTERVAL_MS=5000
    ports:
      - "8667:8667"
    depends_on:
      - surrealdb
      - scheduler

  ssp-2:
    image: ghcr.io/sp00ky-org/ssp-server:canary
    environment:
      - SPKY_SSP_ID=ssp-02
      - SPKY_DB_WS=ws://surrealdb:8000
      - SPKY_DB_USER=root
      - SPKY_DB_PASS=root
      - SPKY_DB_NS=main
      - SPKY_DB_NAME=app
      - SPKY_SSP_LISTEN_ADDR=0.0.0.0:8667
      - SPKY_AUTH_SECRET=your-secret-token
      - SPKY_SCHEDULER_URL=http://scheduler:9667
      - SPKY_SSP_ADVERTISE_ADDR=ssp-2:8667
      - SPKY_SSP_REF_MODE=dedicated
      - HEARTBEAT_INTERVAL_MS=5000
    ports:
      - "8668:8667"
    depends_on:
      - surrealdb
      - scheduler

volumes:
  surreal-data:
  scheduler-data:
Never publish port 9667

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_admin roster; 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_PASSWORD on 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.

Note

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.

VariableDescriptionDefault
SPKY_SSP_LISTEN_ADDRAddress and port for the SSP HTTP server0.0.0.0:8667
SPKY_AUTH_SECRETBearer token for authenticating SSP API requests. When unset the auth middleware accepts any bearer (dev only).(empty)
SPKY_DB_URLSurrealDB URL (HTTP engine; ws:// values are accepted and normalized)http://127.0.0.1:8000
SPKY_DB_WSLegacy fallback for SPKY_DB_URL (any scheme accepted)(unset)
SPKY_DB_USERSurrealDB usernameroot
SPKY_DB_PASSSurrealDB passwordroot
SPKY_DB_NSSurrealDB namespacetest
SPKY_DB_NAMESurrealDB databasetest
SPKY_SCHEDULER_URLScheduler URL (enables distributed mode)(unset)
SPKY_SSP_IDUnique SSP identifierssp-<uuid>
SPKY_SSP_ADVERTISE_ADDRExternally reachable host:port for this SSP(unset)
HEARTBEAT_INTERVAL_MSHeartbeat interval in ms5000
TTL_CLEANUP_INTERVAL_SECSView TTL cleanup interval60
SPKY_SCHEMA_POLL_SECSHow 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_TRANSPORTStandalone 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_MSWindow (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_CONFIGJSON-encoded { "job_tables": { … } } describing backends the SSP can dispatch to.(unset)
SPKY_CRDT_CACHE_SIZEIn-memory CRDT cache capacity10000
SPKY_CRDT_FIELDSJSON override for @crdt field detection, e.g. {"thread":["content"]}(auto)
SPKY_SSP_BOOTSTRAP_PAGE_SIZERows pulled per /proxy/query page500
SPKY_SSP_REGISTER_MAX_WAIT_SECSHow long to keep retrying scheduler registration before exiting for a restart180
SPKY_SSP_ANON_LIVE_QUERIESAllow live queries from unauthenticated clients (1/true to enable)false
SPKY_SSP_QUERY_ALLOWLISToff, 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_MSHow often view metrics are flushed to the database2000
SPKY_SSP_MEMORY_LIMIT_MBMemory 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_ENDPOINTOpenTelemetry collector endpoint for SSP metrics(unset)
OTEL_SERVICE_NAMEOTEL service identifierssp

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.

VariableDescriptionDefault
SPKY_SSP_SNAPSHOT_DIRDirectory 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_SECSHow often the snapshot is rewritten while running. Only read when SPKY_SSP_SNAPSHOT_DIR is set, which arms the timer.300
SPKY_SSP_ARENA_DIRDirectory 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_MBArena segment size in MB64

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

VariableDescriptionDefault
SPKY_SCHEDULER_IDUnique scheduler identifierscheduler-<uuid>
SPKY_DB_URLSurrealDB URL (HTTP engine; ws:// values are accepted and normalized)http://localhost:8000
SPKY_DB_WSLegacy fallback for SPKY_DB_URL (any scheme accepted)(unset)
SPKY_DB_NSSurrealDB namespacesp00ky
SPKY_DB_NAMESurrealDB databasesp00ky
SPKY_DB_USERSurrealDB usernameroot
SPKY_DB_PASSSurrealDB passwordroot
SPKY_SNAPSHOT_UPDATE_INTERVAL_SECSSnapshot update interval (seconds)300
SPKY_DRIFT_CHECKCompare the replica’s row counts against upstream at startup and after each snapshot draintrue
SPKY_DRIFT_AUTO_RECLONEAct 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_TICKSConsecutive 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_SECSMinimum seconds between automatic re-clones3600
SPKY_DRIFT_REPAIR_MAX_ROWSMost 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 for2000
SPKY_DRIFT_REPAIR_TIMEOUT_SECSDeadline 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 check300
SPKY_DRIFT_CHECK_TIMEOUT_SECSDeadline for one drift check; past it the check is abandoned and the upstream connection replaced, so a wedged query cannot stop the replica draining120
SPKY_BOOTSTRAP_PAGE_SIZERows read per bootstrap page from the replica500
SPKY_AUTH_SECRETOptional 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_BACKENDSJSON-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.

VariableDescriptionDefault
SPKY_ADMIN_ENABLEDServe the admin plane at all. Set to 0, false or off to disable it; any other value (or unset) enables it.true
SPKY_ADMIN_PORTPort for the admin plane9668
SPKY_ADMIN_HOSTBind address for the admin plane0.0.0.0
SPKY_ADMIN_DIRDirectory 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_PASSWORDBreak-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_ACCESSPins 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_SECSHow long a signed-in session lasts28800 (8h)
SPKY_ADMIN_PRESENCE_INTERVAL_SECSHow 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_MSMaterialization 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_ROWSRow 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_ROWSCeiling 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_SECSHow 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_SECSThe 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.

VariableDescriptionDefault
SPKY_POOL_ENABLEDServe pools at all. 0 or false turns the listener and the pool sweep off.true
SPKY_POOL_PORTPort of the pool listener9669
SPKY_POOL_HOSTBind address of the pool listener0.0.0.0
SPKY_POOL_PUBLIC_URLThe listener’s URL as a machine reaches it. Handed to every machine the scheduler creates.http://scheduler:<port>
SPKY_POOL_SECRETKey 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_DOCKEREnable the docker provider: machines are containers on this scheduler’s Docker host. Needs the Docker socket mounted.(off)
SPKY_POOL_DOCKER_NETWORKDocker network machine containers join, so they can reach SPKY_POOL_PUBLIC_URL(default bridge)
SPKY_POOL_AGENT_IMAGEImage 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.

VariableDescriptionDefault
S3_ENDPOINTS3-compatible endpointhttp://10.100.1.5:9000
S3_ACCESS_KEYAccess keyminioadmin
S3_SECRET_KEYSecret keyminioadmin
S3_BUCKETBucket backups are written tobackups
S3_REGIONRegionus-east-1
SPKY_PROJECT_SLUGPrefix 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.

VariableDescriptionDefault
SPKY_HEARTBEAT_INTERVAL_SECSSeconds between probe cycles. 0 disables the probe.30
SPKY_HEARTBEAT_TIMEOUT_SECSDeadline for one cycle. Clamped below the interval so a cycle always finishes before the next tick.25
SPKY_HEARTBEAT_FAIL_THRESHOLDConsecutive failed cycles before the alert fires3
SPKY_HEARTBEAT_PING_URLDead-man URL pinged after each successful cycle, for an external uptime monitor(unset)
SPKY_ALERT_WEBHOOK_URLWebhook posted to when the probe crosses the failure threshold and again when it recovers(unset)
SPKY_HEALTH_MAX_HEARTBEAT_AGE_SECSHow 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

VariableDescriptionDefault
SPKY_WORKER_THREADSTokio worker threads4
SPKY_HTTP_TIMEOUT_SECSRequest 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_LAGIngest lag above which /health reports degraded. Unset means lag never degrades health.(unset)
SPKY_CLONE_TIMEOUT_SECSCeiling 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_SECSHow 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_STARTWipe 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_TRANSPORThttp (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_RETENTIONThe 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_DOORBELLLIVE SELECT id FROM _00_version over WebSocket as the wake-up for the tail; false = poll only.true
SPKY_CHANGEFEED_DEBOUNCE_MSWait after a doorbell wake before polling, so a bulk transaction costs one poll.5
SPKY_CHANGEFEED_FALLBACK_MSSafety-net poll interval while the doorbell is connected.2000
SPKY_CHANGEFEED_FALLBACK_DOWN_MSPoll interval while the doorbell is reconnecting.250
SPKY_CHANGEFEED_GAP_MARGIN_SECSClock-skew allowance subtracted from the retention in the gap check.300
SPKY_CHANGEFEED_POLL_LIMITFeed 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_SECSDeadline 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_AFTERConsecutive bootstrap integrity failures before the replica is re-cloned from upstream3
SPKY_BOOTSTRAP_ADMIT_AFTERConsecutive 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_AFTERSame escalation for the catch-up gate3
SPKY_CATCHUP_ADMIT_AFTERSame escalation for the catch-up gate5
Injected by Sp00ky Cloud, not by you

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.

Next steps