Machine pools
Run a backend's jobs on machines that exist only while there is work. Autoscaling, warm buffers, one machine per job if you want it, and nothing left running when the queue is empty.
Machine pools are experimental. They need a CLI and a scheduler that ship them (the first release after 0.0.1-canary.269) and a project deployed with spky deploy; see Limitations. Expect the configuration to settle and the operator tooling to grow.
Overview
Every backend normally runs as one always-on container next to your database, and the SSPs hand it one job after another. That is right for most work. It is wrong for work that needs a machine of its own: a render that pins every core for twenty minutes, a transcode, a headless browser, a model run, anything whose CPU, memory or bandwidth would starve the rest of the project.
A machine pool gives a backend’s jobs their own machines, created when there is work and destroyed when there is not. You declare the pool in sp00ky.yml and point a backend at it with runOn. Nothing about the backend changes: it still answers a POST per job, and your app still creates jobs with db.run() or a schedule.
With that configuration nothing runs while the queue is empty. The first job makes the scheduler ask for a machine; about a minute later the machine is up, runs the job, and after five idle minutes it is gone again. Two jobs at once get two machines. A third waits for one of them.
How a job runs
- Your app (or a schedule) writes a
pendingrow into the backend’s outbox table, as for any job. - The scheduler sizes the pool against what is queued and asks the provider for machines it lacks.
- On each machine, a small agent starts your backend’s container, waits for its
healthcheckto answer, and reports the machine ready. - The scheduler binds the oldest queued job to a free slot. The agent picks it up on its next poll and sends it to your backend as
POST http://127.0.0.1:<port><path>with the job’s payload as the JSON body, and holds the request open until the backend answers. - The answer goes back to the scheduler, which records it on the job row exactly as it would for any other job.
The agent dials out: it polls the scheduler every few seconds and nothing ever connects to the machine. Every poll also renews the lease of the jobs it is running, so a job may run for hours without anyone tuning a timeout for it.
Sizing
A pool sizes itself one of three ways, decided by min, autoscale, max and buffer:
| Prop | Type | Default | Description |
|---|---|---|---|
| Fixed | autoscale: false | - | Exactly min machines, always. More jobs than free slots wait in the queue. Predictable cost, no boot waits once the pool is up. |
| Baseline + on demand | autoscale: true, buffer: 0 | - | min machines always, plus one per job that finds no free slot, up to max. A job that needs a new machine waits one boot. |
| Warm buffer | autoscale: true, buffer: N | - | N whole machines stay ready above what is in use, so jobs start at once and the buffer refills behind them. You pay for the idle buffer. |
The rule behind all three, evaluated every two seconds:
A pool with min: 0 and buffer: 0 scales to zero: no machine runs until a job arrives, and each burst of work waits one boot. Surplus machines are only ever taken away once they have been idle for idleTimeout, the longest-idle first, and never a machine that is running a job.
max is required with autoscale: true. It is the number that bounds the bill, so the config makes you state it. A fixed pool’s size is min, so max and buffer are rejected there rather than silently ignored.
Machines
Providers
| Prop | Type | Default | Description |
|---|---|---|---|
| hetzner | provider | - | One Hetzner Cloud VM per machine, created by Sp00ky Cloud. Use it in production. |
| docker | provider | default | One container per machine on the scheduler's own docker host. What spky dev always uses, and what a self-hosted scheduler with a docker socket can use. |
spky dev runs every pool on docker, whatever the file says, so the same sp00ky.yml works locally and in the cloud. A deployed pool that should create VMs sets provider: hetzner.
Machine shape
machine.type is a Hetzner Cloud server type name. It defaults to cx33. Use an x86 type (the CX, CPX and CCX lines): your backend image is built for linux/amd64, so the Arm types (CAX) cannot run it.
machine.locations lists Hetzner locations (fsn1, nbg1, hel1, ash, hil, sin) in order of preference. A location that is out of capacity, or does not offer the server type, is skipped for the next one. Capacity in single locations does run out now and then, so list more than one. Leave it empty and Hetzner picks.
slots is how many jobs one machine runs at the same time (default 1). Your backend must be able to serve that many concurrent requests; the machine’s cores are what they share.
Boot time
About a minute on Sp00ky Cloud from the scheduler asking to the machine being ready (the VM boots, installs the agent, downloads and starts your backend’s image), and a couple of seconds under spky dev. Keep images small, and use buffer when jobs must not wait for a boot.
Writing a pool backend
A pool backend is an ordinary backend with an outbox method. spky lint checks what a pool needs from it:
- an outbox
methodwith atable: pool machines run outbox jobs; deploy.port: the port the agent sends jobs to;deploy.dockerfile: the image each machine runs (also whatspky devbuilds);deploy.healthcheck(recommended): a path that answers2xxonce the backend can take work. Without it, the backend counts as ready as soon as its port accepts connections.
The contract
| Prop | Type | Default | Description |
|---|---|---|---|
| 2xx | answer | - | Success. The response body is stored on the job row as result. |
| any other status | answer | - | A failed attempt: errors gets { code: <status>, reason: <the first 2 KB of the body> }, and the job is retried until it has used max_retries attempts (default 3), then failed. |
| no answer | answer | - | Connection refused or dropped: a failed attempt with code 0, retried the same way. |
| too slow | answer | - | Past the job's deadline the request is dropped and the job is failed with code deadline, without a retry. |
A job’s deadline is maxJobDuration (default 8 hours). A job’s own timeout, when it has one (Jobs), shortens it but never extends it. The backend’s deploy.timeout is the core-host job runner’s setting and plays no part here.
Environment
Each machine runs your backend with:
- the backend’s own
env:, resolved at deploy exactly as for any backend: inline values, env files andvaultreferences; - the image’s own
ENV; - on Sp00ky Cloud,
SPKY_ENV,SPKY_DB_NSandSPKY_DB_NAME.
The environment reaches the machine through the agent’s authenticated connection to your scheduler, never through the cloud provider’s metadata, which anything on a VM can read back.
A pool machine is not on your project’s private network. The connection variables the platform injects into backends on the core host (SPKY_DB_URL, SPKY_DB_WS, SPKY_DB_USER, SPKY_DB_PASS, SPKY_SCHEDULER_URL, SPKY_SSP_ADDR) are not set there, and would not resolve if they were. Prefer returning results as the job’s response. A pool backend that must reach the database needs its public endpoint and credentials of its own through env:.
Networking
On Sp00ky Cloud, machines accept no inbound connections: every pool machine sits behind a firewall with no inbound rules, and your backend’s port is never published, only the agent inside the machine talks to it. Outbound traffic is open, so a job can fetch its input from and upload its output to anywhere. Each machine has its own network interface, which is the point for bandwidth-heavy work.
Machine lifecycle
A machine moves through these states, left to right; a machine that never becomes ready or stops answering is failed instead, and is destroyed all the same.
| Prop | Type | Default | Description |
|---|---|---|---|
| requested | state | - | The scheduler has asked the provider for it. |
| booting | state | - | It exists and is starting the agent and your backend. |
| ready | state | - | Healthy and polling. Takes jobs up to its slots. |
| draining | state | - | Takes no new jobs, finishes the ones it has. |
| terminating | state | - | Being destroyed at the provider; retried until it sticks. |
| gone | state | - | Destroyed. |
| failed | state | - | Taken away because it never became ready or stopped answering. It still passes through terminating, so it is destroyed too. |
A machine starts draining when:
- it has been idle for
idleTimeoutand the pool has more machines than it needs; - it has lived
maxLifetime(default 24 hours), so a long-lived pool is renewed piece by piece; - a deploy changed what it runs: a new image, command, environment or setting. Old machines finish their jobs and are replaced; a deploy never interrupts a running job;
- an operator drained it by hand.
recycle decides what happens between jobs on one machine. job (the default) restarts your backend’s process once the machine has no job running, so every job starts from a fresh process and nothing leaks from one job into the next. never keeps the process up, for backends that keep a useful cache in memory. Either way the machine itself stays until one of the reasons above.
When things go wrong
Worth knowing, because it is what makes a pool safe to leave alone:
- A machine dies or loses its network. Each job attempt holds a lease (default 90 seconds) that the machine’s polls renew. When the polls stop, the attempt goes back to the queue once the lease runs out and runs again on another machine; that counts as an attempt against
max_retries. The agent keeps its own clock too and stops its jobs a safety margin before the lease can run out, so the same attempt never runs on two machines at once. A machine out of contact for long enough shuts itself down. - The scheduler restarts. Nothing is lost. Which job runs where is recorded on the job rows, and the machines keep working and reconnect.
- A job is killed.
spky jobs killsends the cancel to the machine: the agent drops the request and, when it was the machine’s only job, restarts your backend so the work really stops. The job is failed with codecancelled. - Machines keep failing to boot. After three failed boots in a row the pool stops creating machines for a minute, doubling up to fifteen, and opens a
pool_breaker_openincident on the admin dashboard. A machine that boots fine closes it again. Typical causes: an image that does not start, a healthcheck that never answers, a server type the provider does not have. - A limit is reached. Sp00ky Cloud caps how many machines a project may have (see below). A machine asked for beyond it is refused, and a refusal counts as a failed boot, so a pool whose
maxis above your plan’s limit opens its breaker whenever it tries to grow past the limit. Keepmaxwithin your plan.
Sp00ky Cloud also watches from its side, whatever your scheduler does: a pool never gets more than twice its size limit plus one machines (max, or min for a fixed pool; the headroom covers a deploy’s old machines draining while new ones start), machines of a scheduler that has been silent for half an hour are destroyed, no machine lives longer than 72 hours, and destroying a project destroys its machines first.
Local development
spky dev runs every pool as containers next to your dev stack, with the same scheduler logic as production. It builds each pool backend’s image from deploy.dockerfile (as sp00ky-dev-pool-<backend>), gives the scheduler the Docker socket so it can start and stop machine containers, and removes machine containers left over from a previous session.
A machine is ready in a couple of seconds, so scale-up, idle timeouts and a killed job are all easy to watch with docker ps. The agent and your backend log to the container, docker logs <container>.
On Sp00ky Cloud
Deploy with spky deploy. It sends the pool definitions along, keeps pool backends off the core host (their image only runs on pool machines), and publishes the scheduler’s pool listener at <slug>-pool.<domain> for machines to dial.
| Prop | Type | Default | Description |
|---|---|---|---|
| Free | plan | - | No pool machines. (Free projects have no scheduler.) |
| Starter | plan | - | Up to 2 pool machines per project at a time. |
| Pro | plan | - | Up to 10 pool machines per project at a time. |
The limit counts machines in every pool of the project together, including ones still booting or draining.
Machine time is recorded per machine from creation to destruction and shows up in your project’s usage as pool_machine_hours. A pool with min: 0 costs nothing while it has no work.
Operating pools
For the pools themselves, the scheduler’s admin API and admin MCP server have five operations:
| Prop | Type | Default | Description |
|---|---|---|---|
| pools_list | GET /admin/api/pools | - | Every pool with its sizing, pause and breaker state, machines by state, busy slots, and how many jobs are waiting for a machine. |
| pool_machines | GET /admin/api/pools/:name/machines | - | One pool's machines, newest first: state, slots in use, last contact, and why a machine was taken away. |
| pool_pause | POST /admin/api/pools/:name/pause | - | No new jobs are assigned and no machines are created. Running jobs finish. Survives redeploys. |
| pool_resume | POST /admin/api/pools/:name/resume | - | Resume a paused pool. |
| machine_drain | POST /admin/api/machines/:id/drain | - | One machine takes no new jobs, finishes what it runs, and is destroyed. The pool replaces it if it still needs the capacity. |
Pausing is the big red button: a deploy never un-pauses a pool, just as it never un-pauses a schedule.
Self-hosting
A self-hosted scheduler serves pools with the docker provider. It needs the Docker socket and a pool listener its machine containers can reach:
The full list is in Self-hosting → Machine pools. The hetzner provider needs Sp00ky Cloud, which holds the cloud credentials and enforces the limits on its side.
Limitations
- One backend per pool. A machine runs exactly one backend’s image. Give each backend its own pool.
- Deploy with
spky deploy. Git-linked deploys do not carry pools yet. - x86 only. See Machine shape.
- No database connection variables on the machine. See Environment.
- Hetzner is the only cloud provider.
- Cluster mode only. Pools are run by the scheduler, so the project needs
mode: cluster. - Jobs, not services. A pool runs a backend for its jobs and takes the machines away when there are none. For one always-on backend on a VM of its own, use a dedicated machine.
Validation
spky lint checks pools with the rest of the config:
- every
runOn.poolnames a declared pool, and every pool is used by exactly one backend; - a pool backend has an outbox
methodwith atable, and adeploy.port; autoscale: truehas amaxof at least 1,minis not abovemax, andbufferis not abovemax;- a fixed pool has
minof at least 1 and nomaxorbuffer; slotsis at least 1,leaseat least 30 seconds, andmaxLifetimenot shorter thanmaxJobDuration;- every duration parses.
Reference
Pool fields
| Prop | Type | Default | Description |
|---|---|---|---|
| provider | hetzner | docker | docker | Where machines come from. spky dev always uses docker. |
| machine.type | string | cx33 | Hetzner Cloud server type. x86 types only. |
| machine.locations | string[] | [] | Hetzner locations in order of preference. Empty lets Hetzner choose. |
| slots | integer | 1 | Jobs one machine runs at the same time. |
| min | integer | 0 | Machines that always run. The whole size of a fixed pool. |
| autoscale | boolean | false | Add machines while jobs wait, up to max. |
| max | integer | (required with autoscale) | Most machines the pool may have. |
| buffer | integer | 0 | Ready machines kept above what is in use. Autoscaling pools only. |
| idleTimeout | duration | 10m | How long a surplus machine may sit idle before it goes. |
| recycle | job | never | job | Restart the backend process between jobs, or keep it running. |
| maxJobDuration | duration | 8h | Longest a job may run. A job's own timeout can only shorten it. |
| maxLifetime | duration | 24h | Oldest a machine gets before it is drained and replaced. |
| lease | duration | 90s | How long a job attempt survives without a word from its machine. Minimum 30s. |
| bootTimeout | duration | 5m | How long a machine may take to become ready before it is given up on. |
Durations take the same form as elsewhere in sp00ky.yml: 90s, 10m, 1h30m, 8h.
On a backend
| Prop | Type | Default | Description |
|---|---|---|---|
| runOn.pool | string | - | Name of the pool this backend's jobs run on. The backend then gets no always-on container. |
| runOn.machine | string | - | Instead of a pool: the dedicated machine this backend runs on, always on. See Dedicated machines. |
Job errors written by a pool
| Prop | Type | Default | Description |
|---|---|---|---|
| <HTTP status> | code | - | The backend answered with a non-2xx status. Retried. |
| 0 | code | - | The request to the backend failed. Retried. |
| lease_expired | code | - | The machine running the attempt went silent. Retried on another machine. |
| deadline | code | - | The job ran past its deadline or past maxJobDuration. Not retried. |
| cancelled | code | - | The job was killed. Not retried. |