Github|...

Self-hosting

Run sp00ky on your own server with Docker Compose. SurrealDB and one SSP is a complete setup, big projects included.

A self-hosted sp00ky is two containers. SurrealDB holds your data, and the SSP keeps every live query up to date. Your app connects to SurrealDB directly, so the SSP never has to be reachable from the internet.

sequenceDiagram
    participant App as Your app
    participant DB as SurrealDB
    participant SSP
    App->>DB: reads and writes over wss://db.example.com
    DB->>SSP: every change, through the sync events
    SSP->>DB: updated query results
    DB-->>App: live update
One SSP is enough

A single SSP serves every live query for every connected user, and also runs your jobs, schedules, workflows, backend health checks and backups. It carries large projects on its own. Add a scheduler when you want the operator dashboard, and a second SSP only when one machine runs out of memory or CPU.

What you need

  • A Linux server with Docker and Docker Compose, ports 80 and 443 open.
  • A domain for the database, for example db.example.com, with its DNS pointing at the server. Caddy uses it to get a TLS certificate automatically.
  • Your sp00ky project and the spky CLI on your own machine.

Set it up

  1. Write the .env file

    On the server, create an empty directory with a .env file in it. Compose reads it automatically, and every value below is used in the next steps.

    .env
    # Your images and your CLI must be the same version (spky --version)
    SPOOKY_VERSION=0.0.1-canary.283
    
    # The domain your app connects to. Point its DNS at this server.
    DB_DOMAIN=db.example.com
    
    # Long random values, e.g. from: openssl rand -hex 32
    SURREAL_PASS=change-me
    SPKY_AUTH_SECRET=change-me
  2. Add the Caddyfile and docker-compose.yml

    Caddy terminates TLS for your domain and forwards it to SurrealDB, WebSockets included. If you already run a reverse proxy, drop the caddy service and point your proxy at surrealdb:8000 instead.

    Caddyfile
    {$DB_DOMAIN} {
    	reverse_proxy surrealdb:8000
    }
    docker-compose.yml
    services:
      surrealdb:
        image: surrealdb/surrealdb:v3.1.0-beta.3
        user: root
        command: start --allow-all rocksdb:/data/db
        environment:
          SURREAL_USER: root
          SURREAL_PASS: ${SURREAL_PASS}
          SURREAL_CAPS_ALLOW_EXPERIMENTAL: files,surrealism
        volumes:
          - surrealdb:/data
        restart: unless-stopped
    
      ssp:
        image: mono424/spooky-ssp:${SPOOKY_VERSION}
        environment:
          SPKY_DB_URL: http://surrealdb:8000
          SPKY_DB_NS: main
          SPKY_DB_NAME: main
          SPKY_DB_USER: root
          SPKY_DB_PASS: ${SURREAL_PASS}
          SPKY_AUTH_SECRET: ${SPKY_AUTH_SECRET}
          # Row bytes on disk. With a scheduler, SPKY_SSP_SNAPSHOT_DIR also keeps
          # row checkpoints across restarts; a standalone SSP rebuilds from the
          # database on every start.
          SPKY_SSP_SNAPSHOT_DIR: /data/snapshot
          SPKY_SSP_ARENA_DIR: /data/arena
        volumes:
          - ssp:/data
        ports:
          - "127.0.0.1:8667:8667" # health checks from the server only
        depends_on:
          - surrealdb
        restart: unless-stopped
    
      caddy:
        image: caddy:2
        environment:
          DB_DOMAIN: ${DB_DOMAIN}
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - caddy:/data
        restart: unless-stopped
    
    volumes:
      surrealdb:
      ssp:
      caddy:

    SPKY_DB_NS and SPKY_DB_NAME must match surrealdb.namespace and surrealdb.database in your sp00ky.yml. Both default to main.

  3. Start it
    docker compose up -d
    curl localhost:8667/health

    The SSP answers with a short JSON status. Once it contains "status":"ready", it is up.

  4. Apply your schema

    From your project directory on your own machine, apply your migrations together with the sync events sp00ky adds to every table:

    export SURREAL_URL=https://db.example.com
    export SURREAL_PASS=<SURREAL_PASS from .env>
    
    spky migrate apply --endpoint http://ssp:8667 --secret <SPKY_AUTH_SECRET from .env>
    • SURREAL_URL is how your machine reaches SurrealDB.
    • --endpoint is how SurrealDB reaches the SSP. The sync events call it on every write, from inside the Docker network, so it is the service name ssp and not your domain.
    • --secret must be the exact SPKY_AUTH_SECRET from .env, or the SSP rejects every change.
  5. Point your app at it

    Set the client’s endpoint to your domain. Namespace and database are the same as above.

    const dbConfig: SyncedDbConfig<typeof schema> = {
      // ...
      database: {
        endpoint: 'wss://db.example.com/rpc',
        namespace: 'main',
        database: 'main',
      },
    };

    If your client sets enableAnonymousLiveQueries: true, add SPKY_SSP_ANON_LIVE_QUERIES: "true" to the SSP as well. The two must agree, or signed-out users get no live updates.

That is a complete production setup. Everything below is optional.

Everyday tasks

Change the schema

Create a migration as usual, then apply it with the same command as the first time:

spky migrate create add_due_date
spky migrate apply --endpoint http://ssp:8667 --secret <SPKY_AUTH_SECRET>

The SSP notices new and changed tables on its own within 15 seconds. No restart needed.

Upgrade

Update the CLI, set SPOOKY_VERSION in .env to the version it prints, then pull the new images and re-apply so the sync events match the new version:

docker compose pull
docker compose up -d
spky migrate apply --endpoint http://ssp:8667 --secret <SPKY_AUTH_SECRET>

To upgrade the scheduler or an SSP without downtime, start the new container next to the old one and let it take over, see Blue/green handover.

Back up

Add the S3_* variables from Backup storage to the SSP and it can export SurrealDB to any S3-compatible bucket and restore from it, through the backup routes. With the scheduler, the dashboard does this for you.

Backends and jobs

If your sp00ky.yml has backends that take jobs from an outbox table, run each one as another service in the same compose file and tell the SSP where to send the jobs:

docker-compose.yml
  ssp:
    environment:
      # ...
      SPKY_JOB_CONFIG: '[{"name":"api","base_url":"http://api:3000","table":"job"}]'

  api:
    image: registry.example.com/my-api:latest
    environment:
      SPKY_DB_URL: http://surrealdb:8000
      SPKY_DB_NS: main
      SPKY_DB_NAME: main
      SPKY_DB_USER: root
      SPKY_DB_PASS: ${SURREAL_PASS}
    restart: unless-stopped

table is the backend’s outbox table (method.table in sp00ky.yml) and base_url is how the SSP reaches the backend on the Docker network. With no backends, leave SPKY_JOB_CONFIG out.

Add the scheduler

The scheduler sits between SurrealDB and your SSPs. You still run one SSP. What you get:

  • The operator dashboard and its MCP server.
  • A replica of your database, so an SSP restart loads its own row checkpoints and repairs only what changed against the replica, instead of reading everything from SurrealDB again.
  • An end-to-end heartbeat that can page you through a webhook, and drift checks that repair the replica on their own.
  • The option to run more SSPs later.

To switch:

  1. Set mode: cluster in your sp00ky.yml.
  2. Add SPKY_ADMIN_PASSWORD (another long random value) to .env.
  3. Add the scheduler service and point the SSP at it:
docker-compose.yml
  scheduler:
    image: mono424/spooky-scheduler:${SPOOKY_VERSION}
    environment:
      SPKY_DB_URL: http://surrealdb:8000
      SPKY_DB_NS: main
      SPKY_DB_NAME: main
      SPKY_DB_USER: root
      SPKY_DB_PASS: ${SURREAL_PASS}
      SPKY_AUTH_SECRET: ${SPKY_AUTH_SECRET}
      SPKY_ADMIN_PASSWORD: ${SPKY_ADMIN_PASSWORD}
    volumes:
      - scheduler:/data # replica and write-ahead log
    ports:
      - "127.0.0.1:9668:9668" # dashboard. Never publish 9667.
    depends_on:
      - surrealdb
    restart: unless-stopped

  ssp:
    image: mono424/spooky-ssp:${SPOOKY_VERSION}
    environment:
      # ...everything from before, plus:
      SPKY_SCHEDULER_URL: http://scheduler:9667
      SPKY_SSP_ID: ssp-0
      SPKY_SSP_ADVERTISE_ADDR: ssp:8667
    depends_on:
      - scheduler

volumes:
  # ...
  scheduler:
  1. Run docker compose up -d, then apply again with the scheduler as the endpoint, since the sync events now go to the scheduler instead of the SSP:
spky migrate apply --endpoint http://scheduler:9667 --secret <SPKY_AUTH_SECRET>
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 only scheduler port you ever expose.

Operator dashboard

The dashboard shows the health of the scheduler and every SSP, end-to-end sync latency, backend health, live logs and workflow runs. It can restart SSPs, cancel and retry workflow runs, pause schedules and take backups.

The compose file above binds it to the server itself. Reach it over an SSH tunnel:

ssh -L 9668:localhost:9668 you@your-server
# then open http://localhost:9668/admin

Or give it its own domain by adding a second block to the Caddyfile:

Caddyfile
{$DB_DOMAIN} {
	reverse_proxy surrealdb:8000
}

admin.example.com {
	reverse_proxy scheduler:9668
}

Sign in with SPKY_ADMIN_PASSWORD, the break-glass login that works even when the database is down, or with a normal app account after spky admin add <user> puts it on the admin roster.

The same port serves the MCP server at /admin/api/mcp, so an AI assistant can read and operate the deployment with tokens minted on the dashboard’s Access page.

A few actions only work on Sp00ky Cloud, because they manage containers rather than processes: pulling newer images, wiping the scheduler volume, restarting SurrealDB, and the managed backup catalog, schedule and retention. The dashboard shows them greyed out with the reason Not linked to Sp00ky Cloud. See Admin dashboard for the full surface.

Run more than one SSP

Only when one SSP runs out of memory or CPU. Each extra SSP needs its own id, address and volume:

docker-compose.yml
  ssp-1:
    image: mono424/spooky-ssp:${SPOOKY_VERSION}
    environment:
      # ...same as ssp, except:
      SPKY_SSP_ID: ssp-1
      SPKY_SSP_ADVERTISE_ADDR: ssp-1:8667
    volumes:
      - ssp-1:/data
    depends_on:
      - scheduler
    restart: unless-stopped

volumes:
  # ...
  ssp-1:

The scheduler spreads views across the SSPs by load. Keep SPKY_CLEAR_VIEWS_ON_START at its default (true) while you run more than one.


Environment variables

Every variable the SSP and the scheduler read, with its default. The setups above only need the handful they show; reach for these when you tune or debug.

SSP (Sidecar)

Read straight from the process environment.

VariableDescriptionDefault
SPKY_SSP_LISTEN_ADDRAddress and port for the SSP HTTP server0.0.0.0:8667
SPKY_SSP_REPLACESBlue/green: id of the serving SSP this one replaces. It registers as that SSP’s standby and is promoted in its place once caught up (handover)(unset)
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 starts each publication immediately; updates arriving during a publication are still merged into the next one. See Architecture.20
SPKY_JOB_CONFIGJSON array of the backends the SSP dispatches jobs to: [{"name":"api","base_url":"http://api:3000","table":"job"}], with optional auth_token and timeout per entry. See Backends and jobs. The literal null disables the job runner, so pass [] for none.(unset)
SPKY_CRDT_FOLD_IDLE_SECSCRDT fields: fold a field’s edit log into its canonical document once the log has had no new rows for this long5
SPKY_CRDT_FOLD_MAX_ROWS…or once this many log rows are pending, however active the field is200
SPKY_CRDT_FOLD_MAX_BYTES…or once this many bytes are pending262144
SPKY_CRDT_FOLD_MIN_GAP_SECSNever fold one field more often than this10
SPKY_CRDT_FOLD_WORKERSFolds running at once on this SSP2
SPKY_CRDT_FOLD_TICK_MSHow often the SSP looks for fields due a fold5000
SPKY_CRDT_PRESENCE_TTL_SECS@cursor presence rows older than this are removed by the SSP’s periodic sweep60
SPKY_CRDT_CACHE_SIZEHot CRDT documents kept in memory between folds1024
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

Row checkpoints

Without a snapshot directory every restart of a cluster SSP 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 loads the rows it kept, verifies them against the scheduler and repairs only what changed. Both are caches of SurrealDB, never the source of truth, so deleting them is always safe. A standalone SSP rebuilds from the database on every start; a snapshot.json an older build left in the directory is removed by the next clean restart. See State persistence.

VariableDescriptionDefault
SPKY_SSP_SNAPSHOT_DIRDirectory for what a cluster SSP keeps across a restart: row checkpoints in rows/, a base image and its deltas per table, verified against the scheduler on boot. Enabled when set and writable; the SSP probes it and falls back to memory-only rather than failing startup. Put it on a volume that outlives the container.(unset)
SPKY_SSP_ROW_CHECKPOINT_SECSCluster SSP: how often tables whose content changed are written to rows/ while running, as a delta of the rows changed since the last write (whole again after 64 deltas or when they outweigh half the base). 0 leaves only the writes on graceful shutdown and after each bootstrap.300
SPKY_SSP_CHECKPOINT_VERIFYCluster SSP: background verifies the bodies of a loaded checkpoint while the SSP registers and repairs (a table that fails is dropped and paged again); load verifies before a table is served, reading every file whole at boot.background
SPKY_SSP_REPAIR_CONCURRENCYCluster SSP: how many tables, and within a table how many id-range listings, fetches and re-reads, a warm bootstrap repairs at a time. Each proxy query is one read on a scheduler worker, so the scheduler’s worker count is the useful ceiling.4
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 and job_tables are YAML only; max_buffer_per_ssp also answers to SPKY_MAX_BUFFER_PER_SSP.

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_HANDOVER_FROMBlue/green: base URL of the running scheduler to take over from, on the same data volume. Needs the same SPKY_AUTH_SECRET (handover)(unset)
SPKY_HANDOVER_ADVERTISEHost the predecessor relays to after the handoverthe container hostname
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_WAL_SEGMENT_MBSize at which the scheduler’s event WAL (./data/wal/, append-only segments) rotates to a new file. Drains unlink covered segments instead of rewriting the log.16
SPKY_DRIFT_CHECKCompare the replica’s row counts against upstream at startup and after each snapshot drain. Before it accepts SSPs, the scheduler only checks the tables its replica holds no row of (the mismatch an SSP must never bootstrap from); the full count runs right aftertrue
SPKY_DRIFT_COUNT_CONCURRENCYUpstream count() queries one drift check runs at once. Each scans its whole table; 1 counts one table at a time, for an upstream with no CPU to spare4
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 per page when the replica clone or a drift repair reads a table from upstream; the table is cut into id windows of three quarters of a pageabout 32 MiB of rows by a one-row probe, at most 2000
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)
SPKY_JOB_CONFIGThe outbox tables whose jobs the scheduler recovers and re-dispatches. Takes the SSP’s array as is (only each table is read) or an object keyed by table. Without it, recurring and delayed jobs are never re-dispatched after their first run.(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_HOT_VIEW_UPDATES_PER_SECUpdates per second, measured between two presence samples, from which a registered view is flagged as hot. The Views tab and the Overview warn on these and a hot_views incident stays open while any view is hot, because a view changing this often (typically a bulk import into a table someone has open) floods its SSP’s publication queue. GET /admin/api/views?hot=true lists them.5
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_DATAServe /admin/api/data: the project’s tables, users and bucket files, as root. Set to 0, false, off or no to refuse it.on
SPKY_BUCKET_DIRA directory holding your file: buckets, mounted read-only into the scheduler at the same path SurrealDB uses. SurrealDB does not list directories, so without it the Storage page opens a subfolder only by its path. Writes still go through SurrealDB.(unset)
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)

SurrealDB WebSocket watchdog

Each cycle the scheduler opens a new WebSocket to SurrealDB and waits for the upgrade. A cycle counts as wedged when /health answers but the upgrade gets no response at all, the SurrealDB 3.x state where open sockets keep working and no new client can connect. A database that is down is left to the control plane. Needs a plain http:// or ws:// SPKY_DB_URL; restarts need the Sp00ky Cloud link, without it the watchdog only reports.

VariableDescriptionDefault
SPKY_SURREAL_WS_WATCHDOG_INTERVAL_SECSSeconds between probe cycles. 0 disables the watchdog.30
SPKY_SURREAL_WS_WATCHDOG_TIMEOUT_SECSHow long one upgrade may take before the cycle counts as wedged. Clamped below the interval.10
SPKY_SURREAL_WS_WATCHDOG_INCIDENT_AFTERWedged cycles in a row before the ws_accept_wedge admin incident opens, so one slow upgrade on an overloaded database is not recorded as a wedge. Clamped to 1 through the fail threshold, so a restart is always recorded on an open incident.2
SPKY_SURREAL_WS_WATCHDOG_FAIL_THRESHOLDWedged cycles in a row before the watchdog asks Sp00ky Cloud to restart SurrealDB3
SPKY_SURREAL_WS_WATCHDOG_COOLDOWN_SECSMinimum seconds between two restart requests900
SPKY_SURREAL_WS_WATCHDOG_RESTART0, false, off or no = report only, never restarttrue

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_MAX_BUFFER_PER_SSPEvents the scheduler queues for one SSP while it bootstraps or lags. Past it the queue is dropped and the SSP is told to re-bootstrap, from a fresh registration with an empty queue. Size it for the write rate times the longest bootstrap: 50,000 is five minutes at 166 events/s.50000
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 (plus a best-effort one on _00_push_message, so direct pushes do not wait for the fallback poll); 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

Web Push

The push engine runs in the scheduler in cluster mode and in the SSP in singlenode mode (an SSP behind a scheduler never runs it), and reads the same variables in both. It needs no configuration: its VAPID key is derived from SPKY_AUTH_SECRET, and the rules come from push: in sp00ky.yml. See Push notifications.

Native push needs no variable either: the APNs and FCM credentials come from push.apns / push.fcm, which spky migrate stores in the database (_00_push_credential). The host needs outbound HTTPS (HTTP/2 for Apple) to api.push.apple.com, api.sandbox.push.apple.com, fcm.googleapis.com and oauth2.googleapis.com. See Native push.

VariableDescriptionDefault
SPKY_PUSHoff (or 0, false, no, disabled) switches the engine off, web and native. Subscriptions and queued messages are kept.(on)
SPKY_VAPID_PRIVATE_KEYA VAPID private key (base64url raw P-256 scalar, the form web-push libraries print) to use instead of the one derived from SPKY_AUTH_SECRET. Set it on every host that runs the engine. Changing the key (or the secret it is derived from) invalidates every browser subscription: clients re-subscribe on their next boot through db.webPush.sync(). With neither this nor SPKY_AUTH_SECRET, push is off.(derived)
SPKY_PUSH_ALLOW_PRIVATE_ENDPOINTSAccept http:// endpoints and private or local addresses, for a mock push service in development. Never in production: endpoints are chosen by your users and the engine posts to them from inside your network.false
SPKY_PUSH_SEND_CONCURRENCYConcurrent HTTP sends per matched row or tick8
SPKY_PUSH_DEDUPE_CAPACITYRule matches remembered to drop duplicates (a row re-delivered by the changefeed look-back or a restart)100000
SPKY_PUSH_CONFIG_RELOAD_SECSHow often the stored push: block is re-read, so a deploy’s rule change is live within this30
SPKY_PUSH_DEFAULT_TTL_SECSPush-service TTL when neither the rule nor push.defaults.ttl sets one86400
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