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:
Minimal example
The smallest viable file to get spky dev running:
For a fully annotated reference covering every option, see example/sp00ky.yml in the repo.
Top-level settings
| Field | Type | Default | Purpose |
|---|---|---|---|
mode | singlenode | cluster | surrealism | singlenode | Deployment topology. singlenode is one SSP with no scheduler, cluster puts a scheduler in front of one or more SSPs, surrealism is for embedded setups. See Self-hosting. |
slug | string | (none) | Project slug used by spky deploy and the other cloud commands. |
surrealdb | object | (none) | DB connection. Sub-fields: namespace, database, username, password, hosting (cloud or external), endpoint (required when hosting: external), size (pay as you go with hosting: cloud only: S 1 GB, included, M 2 GB, L 4 GB; default S). On pay as you go (Cloudflare), hosting: cloud (or no surrealdb block) gives the project a database of its own at {slug}-db.<domain>, in the namespace/database you set, with a root login shown by spky project credentials; spky deploy bakes that endpoint into the web app. On the retired free plan it puts the project on the shared free database, always in the namespace named after its slug, database main, with a namespace-level owner login. |
schema | path | (none) | Directory containing your .surql schema sources. See Schema. |
buckets | path[] | [] | Bucket definition files referenced from your schema. See Buckets. |
clientTypes | array | [] | 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. |
version | string | object | any | Which 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. |
logLevel | string | object | info | Log 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. |
refMode | dedicated | single | dedicated | Storage 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. |
sync | object | { 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. |
impersonation | object | { enabled: false } | Lets admins act as other users from the DevTools. See Impersonation. |
sessionGuard | string | (none) | SurrealQL expression over $auth and $token that decides whether a signed-in session may still sync, so an open WebSocket from before a password reset stops syncing. Applied by spky migrate. See Ending sessions. |
apps | map | {} | Your frontend and backend services. See Apps below. |
pools | map | {} | Machine pools: machines that run one backend’s jobs while there is work. See Machine pools. |
machines | map | {} | Dedicated machines: one Hetzner VM of its own per always-on backend. See Dedicated machines. |
schedules | map | path | {} | Server-side cron/interval jobs. See Schedules. |
workflows | map | path | {} | Server-side workflow DAGs. See Workflows. |
deployment | object | (none) | Cloud deployment knobs: sspCount, backup, env (per-role infra environment variables), storage.sizeGB (the volume behind file: buckets; only grows). See Cloud Deployment and Bucket storage. |
retention | object | (see below) | How long finished jobs and run history are kept. See Retention. |
push | object | path | enabled, no rules | Web Push rules: which row changes notify which users. See Web Push. |
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 (except a Worker backend), an optional auth block, and a method (typically outbox) so the SSP can deliver jobs.
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, alone or next to other backends.
On a Cloudflare project a backend may instead set worker: to run as a Cloudflare Worker inside the project’s SSP Worker, with no Dockerfile and no baseUrl. See worker below.
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).
worker: a backend as a Cloudflare Worker
A type: backend app with a worker: block is a Worker backend. On a Cloudflare project, spky deploy bundles entry with esbuild and the project’s SSP Worker runs it as a Dynamic Worker. It is called through jobs like any backend, so spec and an outbox method with a table are still required. On a VM project the block is ignored and the backend deploys from its Dockerfile.
| Key | Type | Default | Purpose |
|---|---|---|---|
entry | path | (required) | The entry module, relative to sp00ky.yml. Its default export (a Hono app, or any object with fetch) handles every request. |
compatibilityDate | YYYY-MM-DD | 2026-10-01 | Workers compatibility date. |
compatibilityFlags | string[] | [] | Workers compatibility flags, e.g. [nodejs_compat]. With nodejs_compat (or nodejs_compat_v2) bare Node built-in imports stay external in the bundle. |
textModules | glob[] | [] | Files imported as strings, e.g. ["**/*.surql"]. Each glob must end in an extension, and the extension is what counts. |
cpuMs | number | Cloudflare’s default | CPU time per request in ms, 1 to 300000. |
ai | string | (none) | Env binding name for Workers AI, e.g. AI. The backend calls env.AI.run(...). |
workflows | { binding, class }[] | [] | Cloudflare Workflows (Cloudflare only). env.<binding> starts the class, which must be exported from entry. For workflows that run on every host, use the backend’s own workflows. |
expose | string[] | [] | Paths served publicly on the project host https://<slug>.<domain>. Each starts with /; a trailing * matches a prefix. Everything else is reachable only through jobs. |
Rules spky lint and spky deploy enforce: hosting: external and runOn are refused with worker; binding names (ai and every workflow binding) must differ; entry must exist; the backend’s name must be lowercase letters, digits, - or _. spky lint warns when an expose pattern covers a path in the OpenAPI spec (that job route would be public too), and when the manifest can only deploy to VMs.
workflows: durable workflows
A backend may list durable workflow classes (@spooky-sync/backend): Cloudflare Workflows’ API
(step.do, step.sleep, step.waitForEvent) on sp00ky’s own job engine, on every host. Each entry
is { binding, class }: env.<binding> starts the exported class. The backend needs an outbox
method with a table, where the workflow’s runs go as jobs; spky dev and spky deploy inject
its name as SPKY_OUTBOX_TABLE. Bindings are letters, digits and _, and must differ from
worker.ai and worker.workflows. See Durable workflows.
scope: where an app runs
Every app accepts an optional scope controlling where it runs:
scope | Behavior |
|---|---|
all (default) | Started by spky dev and deployed to the cloud. |
devOnly | Local-only, started by spky dev, never deployed. Validation is relaxed (no spec/method/deploy required), so it’s handy for a local sidecar. |
cloudOnly | Deployed 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/protosuffix 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 proxiesportandgrpc_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 ofsp00ky.yml) is expanded in the host portion.workdir: working directory inside the container (docker run -w).dependsOn: names of otherdockerapps that must be ready before this one starts.spky devlaunches apps in dependency order; an unknown name, a self-dependency, or a cycle is rejected at config load (caught byspky lint/spky doctor).healthcheck: an HTTP path (e.g./health) polled on the app’s first published host port until it returns 200. AdependsOnwaits 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).
| Field | Type | Default | Purpose |
|---|---|---|---|
transport | http | changefeed | http | http: 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. |
changefeedRetention | duration | 1d | Retention 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.
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.
| Field | Type | Default | Purpose |
|---|---|---|---|
queryAllowlist | off | warn | enforce | off | off: 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. |
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.
| Field | Type | Default | Purpose |
|---|---|---|---|
enabled | boolean | false | Turns the feature on. Needs a non-empty SPKY_AUTH_SECRET; the token signing key is derived from it. |
tokenTtl | duration | 15m | Lifetime of one token, between 1m and 1h. The client renews it before it expires. One unit only (30m, 1h). |
maxDuration | duration | 2h | Hard cap on one impersonation session. At least tokenTtl. |
userTable | identifier | user | The table whose records can be impersonated. |
searchFields | identifier[] | [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.
| Field | Type | Default | Purpose |
|---|---|---|---|
success | duration | 6h | Job rows in success. |
failed | duration | 14d | Job rows in failed. |
runSuccess | duration | 24h | Schedule/workflow runs in success, skipped, replaced. |
runFailed | duration | 30d | Schedule/workflow runs in failed, killed. |
maxRows | integer | 0 (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. |
mode | all | failures-only | all | Default 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.
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. A quarantined key is probed on a doubling backoff (1h up to 24h), so one that recovers heals itself. 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.
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:
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.
Web Push
push declares rules of the form “when a row of this table looks like this, push to the users in that field”. Browsers subscribe through db.webPush in the client, iOS and Android apps through the spooky_push Flutter plugin once apns / fcm are set (Native push), and the platform delivers: no app backend, no push library, no key management. The VAPID key is derived from SPKY_AUTH_SECRET (or taken from SPKY_VAPID_PRIVATE_KEY); SPKY_PUSH=off turns push off. The scheduler runs the engine in cluster mode, the SSP in singlenode; surrealism has no push host. Subscribing and the service worker side are in Push notifications.
Without a push block, push is on with no rules: direct pushes (a _00_push_message row, db.webPush.notify(), spky push send) still work. The block may also be a path to a YAML file holding it, push: ./push.yml.
| Field | Type | Default | Purpose |
|---|---|---|---|
enabled | boolean | true | Master switch. false stops every rule and every direct push; subscriptions are kept. |
subject | mailto: or https: URI | https://sp00ky.cloud | The VAPID contact the push services can reach about your pushes. Apple and Mozilla want one of yours. |
defaults | object | (none) | ttl, urgency, throttle and notification, applied to every rule unless it sets its own. defaults.notification (an icon, a badge, a lang) is merged under each rule’s notification, the rule’s keys winning. |
limits.perUserPerMinute | integer | 60 | Pushes one user may receive per minute, counted once per matched recipient, not per device. |
limits.perMinute | integer | 6000 | Pushes the whole project may send per minute, one per subscription. |
limits.maxRecipients | integer | 10000 | Recipients one matched row may fan out to. |
rules | map | {} | Rules keyed by name (letters, digits, -, _, .). The name travels in every push and is what a device filters on. |
apns | object | (none) | iOS delivery through APNs, see Native providers. |
fcm | object | (none) | Android (and FCM-registered iOS) delivery through FCM, see Native providers. |
defaults also takes native (merged under every rule’s native block).
Over a limit a push is dropped and counted, and the engine warns at most once a minute.
Rules
| Field | Type | Default | Purpose |
|---|---|---|---|
table | table name | (required) | The synced table whose rows the rule watches. A -- @nosync table never reaches the engine, and _00_ platform tables cannot be watched. |
on | create | update | delete list | [create] | Which operations. A delete sees the row as it was before the delete. |
when | map | (none) | Conditions on the row after the change, keyed by field path (data.kind). All must hold. See Conditions. |
to | field path(s) | { query } | (required) | Who receives it: one field (recipient), several ([owner, assignee]), or a root SurrealQL query with $row bound. A value may be a record id, its string form, an array of either, or rows with an id. |
except | field path(s) | (none) | Users removed from the recipients, typically the author of the change. |
once | field path list | once per record version | Push at most once per record for each distinct combination of these fields’ values: once: [state] pushes when a row reaches ready, not on every later update while it stays ready. |
topic | template | the record id | Collapse key. An undelivered push is replaced at the push service by a newer one with the same topic, the service worker uses it as the notification tag, and throttling is per rule, recipient and topic. |
throttle | duration | (none) | Minimum gap between two pushes of this rule to one recipient under one topic. Changes inside the gap collapse into one trailing push carrying the latest row, sent when the gap ends. |
maxAge | { field, within } | (none) | Ignore rows older than within, judged by field (a datetime, or epoch milliseconds or seconds). Keeps a backfill or an import from paging everyone. |
with | map of SurrealQL | (none) | Extra template context: one root query per name, with $row and $rule bound, run only when the rule matched. A name wins over a row field of the same name. |
notification | object | (none: a nudge) | What the device shows: title, body, icon, badge, image, tag, url (opened on click), lang, dir, requireInteraction, renotify, silent, actions ({ action, title, icon }), data. Any other key passes through to showNotification. Every string is a template. |
data | field list | map of templates | (none) | payload.data for the service worker: row fields to copy, or named templates. Sent with nudges too. |
urgency | very-low | low | normal | high | (push service default) | How eagerly a sleeping device is woken for it. |
ttl | duration | 1d | How long the push service keeps a push for a device that is offline. |
enabled | boolean | true | Switch one rule off without deleting it. |
platforms | web | ios | android list | every kind | Which kinds of device receive it. |
native | { notification, apns, android } | (none) | What iOS and Android show. notification overrides the rule’s notification key by key; apns sets aps keys (sound, badge, category, threadId, interruptionLevel, relevanceScore, mutableContent, anything else as written); android sets android.notification keys (channelId, sound, icon, color, anything else as written) and priority (high | normal). A rule with no notification anywhere is a silent push on phones. |
Durations are 500ms, 30s, 10m, 1h30m, 2d, 1w, or a plain number of seconds.
A rule without notification sends a nudge: a push with no visible content that wakes the app’s service worker, which renders the notification from its own live data (and the client decides what to show). A matching row pushes to every enabled subscription of every recipient; whether the user is currently looking at the app is the service worker’s call (it can suppress the notification when a visible window exists), because the server cannot know.
Conditions
A key of when is a field path, its value one of:
| Form | Meaning |
|---|---|
state: ready, count: 3, muted: false, deleted_at: null | Equal to. |
state: [ready, urgent] | One of. |
count: { gte: 2, lt: 10 } | Operators, all of which must hold: eq, ne, in, nin, gt, gte, lt, lte (numbers, or strings compared as strings), exists (true: present and not null), contains (array element or substring), startsWith. |
Templates
topic, every string in notification and the values of a data map are templates: {{ path | filter }} over the row’s fields (also under row), id, table, op, rule, each with name, recipient (the user being rendered for) and now. A missing path renders empty. Filters: default(x), truncate(n), upper, lower, trim, key (user:abc to abc), table, json, len, join(sep), first, last, date(format). In data, a value that is exactly one {{ expr }} keeps its JSON type.
Native providers
apns and fcm turn on delivery to native apps. Their secrets are references the CLI resolves at every migrate and deploy: { vault: KEY } (the project vault, recommended), { env: KEY }, { file: path } (relative to sp00ky.yml), or an inline string (spky lint warns: it is committed). The resolved secret is checked and stored in the root-only _00_push_credential table; _00_push_config holds the block with the secrets redacted. A reference that does not resolve on the machine running the deploy keeps the stored credential; removing the block removes it. The engine reloads credentials with the config, so a rotated key needs no restart.
| Field | Type | Purpose |
|---|---|---|
apns.teamId | 10 characters | Apple Developer team id. |
apns.keyId | 10 characters | Id of the APNs auth key. |
apns.key | secret | The .p8 key (PEM). |
apns.bundleIds | list | Bundle ids devices may register with (the APNs topic). Empty: any. |
fcm.serviceAccount | secret | The Firebase service account JSON. |
fcm.android | { projectId, appId, apiKey, senderId } | The Android app’s Firebase client config (from google-services.json: project_id, mobilesdk_app_id, current_key, project_number). Not secret: apps read it through fn::push::info() and ship no google-services.json. |
More rules
What a deploy does with it: spky migrate and spky deploy store the block, normalized, in _00_push_config:default (unchanged blocks are not rewritten), and the engine picks it up within 30 seconds, no restart. A block that fails validation is not stored and the previous rules stay. spky lint checks every rule, including against your schema: a table that does not exist or is @nosync, a field path the table does not define, and a field that is @opaque or @nosync (stripped from what the engine sees) are reported. A @crdt field is plain text to the engine and can be matched like any column. spky push sync stores the block (and the native credentials) without a deploy, and spky push status shows what is stored, each native provider included. See spky push.
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.
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 for always-on backends, one or several per machine. A backend opts in with runOn: { machine: <name> }; Sp00ky Cloud then creates the VM, updates each backend on it in place when it is deployed (the others and the machine’s address stay as they are), replaces the VM when its type or locations change or it dies, and proxies traffic to the backends, so deploy.expose and spky domain add --app work exactly as for any backend.
Each backend on a machine needs deploy.port and deploy.healthcheck and must be hosting: cloud, and no two backends on one machine may use the same port. They reach each other as http://<app>:<port> and get the machine’s public address as SPKY_MACHINE_IP. 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 tosp00ky.yml), the file that exports theq*builder functions. When set,spky generateruns it through@spooky-sync/query-allowlistafter the schema module is written and records the resulting SurrealQL shapes.allowlistOutput: where the allowlist JSON goes. Defaults to<queries>.allowlist.jsonnext to the module. Commit it; deploy reads it and does not regenerate.app: the app name the_00_query_allowlistrows are keyed by. Defaults to the frontend app inapps:.
dart: runs spooky_core’s richer generator (dart run spooky_core:spooky_gen), which emits the typed client,Patchclasses, andspookySchema/surqlSchema. Because that tool resolvesspooky_corefrom the enclosing Dart package, it runs from the package directory, auto-derived fromoutput’s nearest ancestorpubspec.yaml. Set the optionalworkdironly to override that. (This replaces a manualdart 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:
| Value | Policy | What a deploy does | What --upgrade does |
|---|---|---|---|
an exact version, e.g. 0.0.1-canary.270 | Pinned | Runs exactly that version. A stack on anything else is moved to it, up or down. | Nothing. The file wins: change version and deploy. |
newest | Tracked | Rolls the stack to the newest published build whenever there is one. | The same as any deploy. |
any, or no version at all | Manual | Leaves the stack alone. | Moves the stack to the newest published build. |
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: