Github|...

sp00ky.yml

Every key in the project config the CLI reads: apps, schedules, schema paths, versions and per-environment overrides.

sp00ky.yml at your project root is the single source of truth for the spky CLI. It tells spky dev, spky deploy and spky doctor how to run your stack, which SurrealDB version to spin up, where your schema and buckets live, which client types to generate, and how your apps boot.

For the client-side object you pass to new SyncedDb(...), see Client config.

Validate it at any time with spky lint, or run spky doctor for a fuller project health check.

Add the schema comment as the first line of sp00ky.yml to get autocomplete and validation in editors that support yaml-language-server:

# yaml-language-server: $schema=https://sp00ky.cloud/schema/sp00ky.schema.json

Minimal example

The smallest viable file to get spky dev running:

mode: singlenode
surrealdb:
  namespace: main
  database: main
schema: ./schema
buckets:
  - ./schema/src/buckets/profile.surql
clientTypes:
  - format: typescript
    output: ./src/schema.gen.ts
  - format: dart
    output: ../app/lib/sync/app_db.g.dart

For a fully annotated reference covering every option, see example/sp00ky.yml in the repo.

Top-level settings

FieldTypeDefaultPurpose
modesinglenode | cluster | surrealismsinglenodeDeployment topology. singlenode runs one SSP, cluster runs multiple, surrealism is for embedded setups.
slugstring(none)Project slug used by spky deploy and the other cloud commands.
surrealdbobject(none)DB connection. Sub-fields: namespace, database, username, password, hosting (cloud or external), endpoint (required when hosting: external).
schemapath(none)Directory containing your .surql schema sources. See Schema.
bucketspath[][]Bucket definition files referenced from your schema. See Buckets.
clientTypesarray[]Codegen targets. Each entry has format (typescript or dart), an output path, (dart only) an optional workdir, and (typescript only) optional queries, allowlistOutput and app for the query allowlist. See Client types.
versionstring | objectanyWhich SSP and scheduler build runs, enforced: an exact version, newest or any. Accepts a single value, a {ssp, scheduler} split, a {dev, cloud} split, or a path: override pointing at a local binary. See Versions.
logLevelstring | objectinfoLog verbosity for the SSP and scheduler. Accepts trace, debug, info, warn, error, off, target=level directives, or a {dev, cloud} split. Under spky dev a bare level applies to the sp00ky crates only (third-party crates stay at warn); pass a full directive list such as ssp=debug,hyper=info,warn to control those too.
refModededicated | singlededicatedStorage layout for the SSP’s _00_list_ref table. dedicated gives each user their own table and works around a SurrealDB v3 LIVE-permission gap.
syncobject{ transport: http, queryAllowlist: off }How row changes reach the scheduler: transport (http or changefeed) and changefeedRetention, see Sync transport; and whether the SSP refuses query shapes the app does not ship: queryAllowlist, see Query allowlist.
impersonationobject{ enabled: false }Lets admins act as other users from the DevTools. See Impersonation.
appsmap{}Your frontend and backend services. See Apps below.
poolsmap{}Machine pools: machines that run one backend’s jobs while there is work. See Machine pools.
machinesmap{}Dedicated machines: one Hetzner VM of its own per always-on backend. See Dedicated machines.
schedulesmap | path{}Server-side cron/interval jobs. See Schedules.
workflowsmap | path{}Server-side workflow DAGs. See Workflows.
deploymentobject(none)Cloud deployment knobs: sspCount, backup, env (per-role infra environment variables). See Cloud Deployment.
retentionobject(see below)How long finished jobs and run history are kept. See Retention.

Apps

Each entry under apps is a service spky knows how to run locally and deploy to the cloud. Set type: frontend, type: backend, or type: docker, then describe how it boots (dev) and how it deploys (deploy). Backends additionally need an OpenAPI spec, a baseUrl, an optional auth block, and a method (typically outbox) so the SSP can deliver jobs.

apps:
  web:
    type: frontend
    dev:
      type: npm
      script: dev:app
    deploy:
      dockerfile: ./app/Dockerfile
      context: ../
      port: 80
  api:
    type: backend
    spec: ./api/openapi.yml
    baseUrl: http://host.docker.internal:3660
    dev:
      type: npm
      script: dev
      workdir: ./api
    deploy:
      dockerfile: ./api/Dockerfile
      context: ../
      port: 3660
      healthcheck: /health
    auth:
      type: token
      token: THIS_IS_TOP_SECRET
    method:
      type: outbox
      table: job
      schema: ./schema/src/outbox/api.surql
  livekit:
    type: docker
    scope: devOnly
    image: livekit/livekit-server:latest
    ports: [7880, 7881, "7882/udp"]
    args: ["--dev", "--bind", "0.0.0.0"]

A backend may also set runOn: { pool: <name> } to run its jobs on a machine pool instead of an always-on container, or runOn: { machine: <name> } to run, always on, on a dedicated machine of its own.

method describes the outbox the SSP delivers jobs through: type: outbox, the table the rows live in, the schema file defining it, and an optional concurrency bounding how many of that table’s jobs run at once (default 1; extra rows wait as pending and are admitted oldest-first). See Jobs → Concurrency.

dev accepts either a raw command string or a typed form: {type: npm, script, workdir?}, {type: docker, file, workdir?, port?} (builds a Dockerfile for this app’s dev server), or {type: uv, script, workdir?}. deploy accepts dockerfile, context, port, healthcheck, resources (vcpus, memory, disk), a timeout, a cmd (override the image’s ENTRYPOINT/CMD), build_args (build-time-only args, same shape as env), expose (backends only: publish it at <slug>-<name>.<domain>, which is also what a custom domain can point at), grpc_port (a second, h2c port at <slug>-<name>-grpc.<domain>), and static ({build, dir}, for Cloudflare static-asset frontends).

scope: where an app runs

Every app accepts an optional scope controlling where it runs:

scopeBehavior
all (default)Started by spky dev and deployed to the cloud.
devOnlyLocal-only, started by spky dev, never deployed. Validation is relaxed (no spec/method/deploy required), so it’s handy for a local sidecar.
cloudOnlyDeployed to the cloud but skipped by spky dev.

type: docker: run a prebuilt image

A docker app runs a prebuilt image directly (no Dockerfile build), useful for local infra like a LiveKit SFU, a mock service, or a cache. Fields:

  • image (required): the image to run, e.g. livekit/livekit-server:latest.
  • ports: published ports. A bare port (7880) maps host→container 1:1 (7880:7880); use "host:container" to remap and a /proto suffix for UDP ("7882/udp"). On a dedicated machine they are published on the VM’s public address and opened in its firewall to everyone; the core host only proxies port and grpc_port.
  • args: appended after the image as the container command (e.g. ["--dev"], or ["go", "run", "."] to run a service from source).
  • volumes: bind/volume mounts (docker run -v), e.g. ["/var/run/docker.sock:/var/run/docker.sock", "${PROJECT_DIR}/../..:/src", "gomod:/go"]. ${PROJECT_DIR} (the absolute directory of sp00ky.yml) is expanded in the host portion.
  • workdir: working directory inside the container (docker run -w).
  • dependsOn: names of other docker apps that must be ready before this one starts. spky dev launches apps in dependency order; an unknown name, a self-dependency, or a cycle is rejected at config load (caught by spky lint/spky doctor).
  • healthcheck: an HTTP path (e.g. /health) polled on the app’s first published host port until it returns 200. A dependsOn waits for this, i.e. for the service to be genuinely up, not just for the container to have started. Without it, a dependency is “ready” as soon as its container is running.

Under spky dev it runs as the container sp00ky-dev-<name> on the dev network (reachable from sibling apps by its name) with --rm teardown. ${PROJECT_DIR} is also expanded in env values, and user env overrides the auto-injected SPKY_* vars. With scope left as all/cloudOnly, deploys pull the image and ship it through the same image pipeline as a backend; pair it with scope: devOnly for a purely local service (dependsOn/healthcheck are spky dev-only). env (below) is passed through as container environment variables.

For per-app environment variables (env: { dev, cloud, vault }), see Environment Variables.

Split a service’s config into its own file

An app entry can also be a { path } reference instead of an inline block. path points at a directory containing a sp00ky.app.yml file, so a backend service can keep its own config next to its code:

# sp00ky.yml (project root)
apps:
  web:
    type: frontend
    # ...
  api:
    path: ./api            # pulls ./api/sp00ky.app.yml in as this app
    scope: cloudOnly       # fields set here override the referenced file
# ./api/sp00ky.app.yml
# yaml-language-server: $schema=https://sp00ky.cloud/schema/app.schema.json
type: backend
spec: ./openapi.yml        # paths are relative to THIS file's directory
method:
  type: outbox
  table: job
  schema: ./src/outbox/api.surql
deploy:
  dockerfile: ./Dockerfile
  port: 3660

The referenced file holds a single app config (the same fields you’d inline under apps.<name>) and validates against its own app.schema.json. Its relative paths are resolved from the service directory, and at load time the whole thing is merged into one effective config. Any field you also set alongside path in the root file wins (main overrides sub). The split is entirely optional, leave your apps inline and nothing changes.

Sync transport

sync.transport decides how a committed row change reaches the scheduler (and from there the SSPs and your clients).

FieldTypeDefaultPurpose
transporthttp | changefeedhttphttp: every generated _00_<table>_mutation / _delete event calls the scheduler’s /ingest with http::post inside the user’s transaction. changefeed: every synced table carries a SurrealDB CHANGEFEED clause and the scheduler tails SHOW CHANGES FOR DATABASE; the events keep only the _00_version bookkeeping and never make a network call.
changefeedRetentionduration1dRetention of the CHANGEFEED clause, in the form SurrealDB renders it (1d, 12h, 1d12h). A scheduler that falls further behind than this re-clones its replica.
sync:
  transport: changefeed
  changefeedRetention: 1d

Why changefeed: the feed is written by the same commit as the row, so it is post-commit only (a transaction that fails after its event fired can no longer announce a change that never happened), ordered by commit versionstamp, durable and resumable. User writes stop waiting on the scheduler: a scheduler restart delays delivery instead of failing writes, and no transaction holds row locks for the length of a network call. Propagation rides a LIVE SELECT doorbell on _00_version plus a 2 s safety-net poll, about 8 ms from commit to SSP.

What a deploy does with it: spky deploy / spky migrate add CHANGEFEED <retention> INCLUDE ORIGINAL to every synced table (ALTER TABLE IF EXISTS, so your own table definitions are kept and a migration that redefined a table gets the clause back), remove the _00_dbsp_cleanup event, and generate events that do not post. The scheduler and SSP containers receive SPKY_INGEST_TRANSPORT and SPKY_CHANGEFEED_RETENTION automatically (cloud: merged into deployment.env; spky dev: injected into the containers). Switching back to http regenerates the posting events; the CHANGEFEED clauses stay and are harmless.

“Synced” means every table that is not @nosync, TYPE RELATION tables included: an edge table is queryable from a client like any other, so it has to be in the feed like any other.

A table is also born with its feed. Your migrations define tables bare, and the clause used to arrive only with the pass that follows them, so rows written in between were committed to a table with no feed and surfaced minutes later as a drift_repair incident. Each migration’s DEFINE TABLE is now completed as it is applied (the clause, and the sp00ky:nosync marker for a @nosync table), decided by your current annotated schema. The file on disk and its recorded checksum are untouched, and spky migrate create still diffs against exactly what your migrations say.

Note

Set sync.transport before the first deploy of a database or plan a short window: the scheduler must run with SPKY_INGEST_TRANSPORT=changefeed from the moment the schema stops posting, or nothing arrives. Deploy the schema, then restart the scheduler with the new env. The changefeed tail starts one second before the restart’s clone, so the window is replayed rather than lost.

Query allowlist

sync.queryAllowlist decides what the SSP does with a view registration whose shape is not one spky generate recorded from the app’s query module (clientTypes[].queries). Off by default.

FieldTypeDefaultPurpose
queryAllowlistoff | warn | enforceoffoff: any parseable query registers. warn: the SSP logs allowlist miss, counts it, and admits the registration; the discovery mode for switching on. enforce: the registration is refused with HTTP 403 not_allowlisted.
sync:
  queryAllowlist: warn          # off | warn | enforce

clientTypes:
  - format: typescript
    output: ./src/schema.gen.ts
    queries: ./src/lib/query.ts # recorded by spky generate
    # allowlistOutput: ./src/lib/query.ts.allowlist.json
    # app: web

What it changes elsewhere: the SSP receives SPKY_SSP_QUERY_ALLOWLIST (cloud: merged into the ssp role’s deployment.env; spky dev: injected into the container). The generated TypeScript schema carries policy: { queryAllowlist }, and when it is warn or enforce the client SDK refuses useRemote, remoteQuery and queryRaw unless allowRawRemote is set. spky deploy, spky release and spky dev publish the allowlist JSON as _00_query_allowlist rows; under enforce a missing or stale JSON fails the deploy. Matching rules, sidecar exports and the rollout recipe are in the Query allowlist guide.

Impersonation

impersonation lets a _00_admin member act as any non-admin user from the DevTools Access tab. Off by default; when off, every deploy removes the access method and functions, which also invalidates tokens issued earlier.

FieldTypeDefaultPurpose
enabledbooleanfalseTurns the feature on. Needs a non-empty SPKY_AUTH_SECRET; the token signing key is derived from it.
tokenTtlduration15mLifetime of one token, between 1m and 1h. The client renews it before it expires. One unit only (30m, 1h).
maxDurationduration2hHard cap on one impersonation session. At least tokenTtl.
userTableidentifieruserThe table whose records can be impersonated.
searchFieldsidentifier[][username, email, name]Fields the DevTools user picker searches and shows. Missing fields are ignored.

What it changes elsewhere: the SSP and scheduler receive SPKY_IMPERSONATION=on (cloud: merged into both roles’ deployment.env; spky dev: injected into the containers). The generated TypeScript schema carries policy: { impersonation }, which the DevTools read to decide whether to offer the feature. Only singlenode and cluster modes support it. Usage, the security model and the audit tables are in Admin impersonation.

Retention

Finished jobs and run history are deleted automatically, asymmetrically by outcome: a success is read once if at all, a failure is what you went looking for.

retention:
  success: 6h        # job rows that succeeded
  failed: 14d        # job rows that failed
  runSuccess: 24h    # run history: success | skipped | replaced
  runFailed: 30d     # run history: failed | killed
FieldTypeDefaultPurpose
successduration6hJob rows in success.
failedduration14dJob rows in failed.
runSuccessduration24hSchedule/workflow runs in success, skipped, replaced.
runFailedduration30dSchedule/workflow runs in failed, killed.
maxRowsinteger0 (off)Hard ceiling on rows in a successful status, per table. Trims oldest-first and never touches failures. A valve for very wide fan-out; the age windows above are the main mechanism.
modeall | failures-onlyallDefault history mode for every schedule: failures-only means a successful execution leaves nothing but a counter. A schedule’s own history: overrides it either way. Resolved at deploy time, so changing it needs a redeploy — unlike the windows above. Never applies to client db.run() jobs.

Whatever retention deletes is first folded into permanent hourly counters (_00_run_rollup), so totals survive the rows; spky schedules get shows them. pending and processing jobs, and running runs, are never pruned at any age. An individual schedule can override the run-history windows with history: (see Schedules and workflows). Full behaviour in Jobs → Retention.

Schedules and workflows

schedules and workflows declare work that runs server-side on a clock, whether or not a client is connected. Each cycle the scheduler creates a new job row in the target backend’s outbox table, so a scheduled run is an ordinary job with the usual retry, kill, and recovery behaviour.

schedules:
  nightly-cleanup:
    cron: "0 3 * * *"
    timezone: Europe/Berlin
    backend: api
    route: /cleanupExpired
    payload:
      olderThanDays: 30

  game-sync:
    every: 5m
    backend: api
    route: /syncGames
    forEach:
      query: SELECT id FROM connection WHERE active = true
      key: id
    concurrency: skip
    retry: { max: 3, strategy: linear }
    timeout: 120s

workflows:
  monthly-report:
    schedule: { cron: "0 6 1 * *" }
    steps:
      extract-orders: { backend: api, route: /exportOrders }
      extract-users:  { backend: api, route: /exportUsers }
      transform:
        backend: analytics
        route: /buildReport
        dependsOn: [extract-orders, extract-users]
      notify: { backend: notify, route: /postSlack, dependsOn: [transform] }
    onFailure: halt

A schedule sets exactly one of cron (a 5-field expression, evaluated in timezone, default UTC) or every (a fixed interval, minimum 10s). forEach fans one schedule out into one job per row, and concurrency decides what a fire does when that row’s previous run is still going: skip (default) records the suppressed tick, allow permits overlap, replace kills the in-flight run first.

quarantineAfter sets a per-key consecutive-failure budget: past it the schedule stops firing that key and opens an incident, so one permanently broken forEach row cannot burn a job every tick forever. Opt-in, and one success forgets the streak. See Failing keys.

A schedule may also set history: to override the project’s retention for its own run history — worth doing for a wide fan-out, which writes one run row per item per fire. It takes either the shorthand history: failures-only (a successful run then never persists at all), or an object with mode, success and failed.

Warning

forEach.key must be unique per row. The run id is derived from it, so two rows producing the same key collide and only the first spawns; a key naming a field the rows don’t have collapses the whole fan-out onto one run. The scheduler records this on the schedule, visible in spky schedules list.

A workflow is a DAG: steps with no dependsOn are roots and run in parallel, a step with several dependencies is a fan-in join, and each step receives its dependencies’ response bodies under payload.steps. See Schedules and Workflows for the full behaviour and the spky schedules / spky workflows commands.

Either section can also live in its own file, per entry or wholesale, useful once you have more than a handful:

schedules: ./schedules.yml            # the whole map lives in that file

workflows:
  monthly-report: ./workflows/monthly-report.yml   # one definition per file
  quick-one: { steps: { ping: { backend: api, route: /ping } } }   # mixing is fine
Note

spky lint parses every cron expression, checks each interval, resolves each backend and route against that app’s OpenAPI spec, and rejects a workflow whose dependsOn edges form a cycle, so a definition that lints is one the scheduler can actually run.

Machine pools

pools declares machines that exist only while a backend has work: a pool is sized by the scheduler and its machines are created and destroyed on demand. A backend opts in with runOn: { pool: <name> }, and its outbox jobs then run on the pool’s machines instead of on an always-on container.

mode: cluster

pools:
  render:
    provider: hetzner                 # hetzner (Sp00ky Cloud) | docker (local, self-hosted)
    machine: { type: cx33, locations: [fsn1, nbg1] }
    slots: 1                          # jobs per machine at once
    min: 0                            # machines that always run
    autoscale: true
    max: 2                            # required with autoscale
    buffer: 0                         # ready machines kept above what is in use
    idleTimeout: 10m
    recycle: job                      # restart the backend between jobs (or: never)
    maxJobDuration: 8h
    maxLifetime: 24h
    lease: 90s
    bootTimeout: 5m

apps:
  renderer:
    type: backend
    runOn: { pool: render }
    # ...spec, baseUrl, method (an outbox table) and deploy as usual

A pool is fixed (autoscale: false, exactly min machines), scales on demand up to max, or keeps a warm buffer of ready machines above what is in use. provider is hetzner for VMs on Sp00ky Cloud; spky dev always runs pools as local containers (docker). Each pool serves exactly one backend, and pools need mode: cluster. The full behaviour, every field with its default, and how to operate a pool are in Machine pools.

Dedicated machines

machines declares Hetzner VMs that each run one always-on backend, fixed. A backend opts in with runOn: { machine: <name> }; Sp00ky Cloud then creates the VM, keeps it healthy, replaces it with a fresh one on every deploy and on failure, and proxies traffic to it, so deploy.expose and spky domain add --app work exactly as for any backend.

machines:
  api-box:
    provider: hetzner                 # the only provider
    type: cx33                        # Hetzner server type
    locations: [fsn1, nbg1]           # tried in order

apps:
  api:
    type: backend
    runOn: { machine: api-box }
    deploy:
      dockerfile: ./api/Dockerfile
      port: 8080
      healthcheck: /health            # required: probed before traffic is sent
      expose: true                    # https://<slug>-api.<domain>
    # ...spec, baseUrl and method (if it takes jobs) as usual

Each machine runs exactly one backend, which needs deploy.port and deploy.healthcheck and must be hosting: cloud. Its deploy.ports, if any, are raw TCP/UDP ports opened on the VM’s public address to everyone (WebRTC media, RTMP); they are not proxied. spky dev ignores the placement and runs the backend as an ordinary app. Everything else, from what spky deploy shows to how a redeploy rolls the VM, is in Dedicated machines.

Client types

clientTypes is a list of codegen targets; spky generate (run with no args) regenerates every entry from your schema. Each entry has a format and an output path (relative to sp00ky.yml):

  • typescript: the CLI’s built-in generator emits a typed schema module (e.g. schema.gen.ts). Three optional keys feed the query allowlist:
    • queries: path to the app’s query module (relative to sp00ky.yml), the file that exports the q* builder functions. When set, spky generate runs it through @spooky-sync/query-allowlist after the schema module is written and records the resulting SurrealQL shapes.
    • allowlistOutput: where the allowlist JSON goes. Defaults to <queries>.allowlist.json next to the module. Commit it; deploy reads it and does not regenerate.
    • app: the app name the _00_query_allowlist rows are keyed by. Defaults to the frontend app in apps:.
  • dart: runs spooky_core’s richer generator (dart run spooky_core:spooky_gen), which emits the typed client, Patch classes, and spookySchema/surqlSchema. Because that tool resolves spooky_core from the enclosing Dart package, it runs from the package directory, auto-derived from output’s nearest ancestor pubspec.yaml. Set the optional workdir only to override that. (This replaces a manual dart run spooky_core:spooky_gen / Makefile step.)

A project can list several outputs, e.g. a TypeScript web client plus one or more Dart clients (a Flutter app, a renderer), and keep them all in sync from one command.

Versions

version decides which SSP and scheduler build your project runs, and Sp00ky Cloud enforces it on every deploy, from the CLI and from a git push alike. Each value is one of three policies:

ValuePolicyWhat a deploy doesWhat --upgrade does
an exact version, e.g. 0.0.1-canary.270PinnedRuns exactly that version. A stack on anything else is moved to it, up or down.Nothing. The file wins: change version and deploy.
newestTrackedRolls the stack to the newest published build whenever there is one.The same as any deploy.
any, or no version at allManualLeaves the stack alone.Moves the stack to the newest published build.
# Pinned: the cloud runs exactly this, and --upgrade cannot move it.
version: 0.0.1-canary.270

# Tracked: every deploy (and every git push) rolls to the newest build.
version: newest

# Manual: deploys never touch it, only --upgrade does. Same as leaving it out.
version: any

# Per service and per environment, in any combination.
version:
  dev: newest
  cloud:
    ssp: 0.0.1-canary.270
    scheduler: 0.0.1-canary.270

Whatever the policy, Sp00ky Cloud records the concrete version it resolved, so a container that is recreated later for an unrelated reason (a changed env var, a crash) comes back on the version it had, not on whatever is newest by then. spky status prints the policy next to the running versions and warns when a pinned stack is not running its pin.

A pinned version that was never published fails the deploy before anything is built. A roll restarts the scheduler and the SSPs (about a minute of sync downtime), so with newest every deploy can include one: pin a version when you want rolls to happen only on purpose.

Under spky dev, an exact version is the image tag to run, any runs the local canary image (pulling it once if missing), and newest re-pulls canary on every start.

Per-environment overrides

version, logLevel, and apps.<name>.env each accept a {dev, cloud} split so your local stack can differ from what runs in Sp00ky Cloud. A common pattern is to run host-built binaries during spky dev and a pinned image tag in the cloud:

version:
  dev:
    ssp:
      path: ../target/debug/ssp-server
    scheduler:
      path: ../target/debug/scheduler
  cloud: 0.0.1-canary.270

logLevel:
  dev: trace
  cloud: info

apps:
  api:
    env:
      dev: '.env.local'
      cloud:
        vault:
          - 'API_AUTH_TOKEN'