Running workers on many machines
A workflow root with shared placement keeps its work queue in a shared database. Any machine that has Atomic installed can run that root’s pooled children: install Atomic, then run one command that names the database. The machines coordinate only through the database. There is no other service to run. Choose the database:- PostgreSQL for fleets in the cloud, or when you already run managed PostgreSQL: Amazon RDS or Aurora, Google Cloud SQL, Azure Database for PostgreSQL, Neon or Supabase. Hosts sign in with a password file or the cloud’s own identity. Worker fleets on managed PostgreSQL has a recipe for each service.
- libSQL or Turso as the lightweight option: one URL and a token. If you host it yourself with sqld, start sqld with
--checkpoint-interval-s 60(orSQLD_CHECKPOINT_INTERVAL_S=60): sqld 0.24.33 can otherwise stop taking writes when many hosts write at once.
max_connections for every host and its slots, or put a pooler in front: see Plan connections.
Join a machine
@bastani/atomic-storage-libsql instead and pass the token:
ready host=… slots=… journal=… and then one line per event, such as claimed child=… when it takes a child and released child=… when the child finished. It serves every running root on that database that uses shared placement, and keeps serving roots that start later or resume on another machine.
--dband its credential apply to this process only and write nothing to settings. On a machine joined withatomic storage joinoratomic team join,atomic worker startwith no options uses that database.- A PostgreSQL URL takes exactly one credential:
--password-file,--token-filefor a password another process keeps fresh, or--auth aws|gcp|azurefor the machine’s cloud identity. See Choose a credential. A libSQL URL takes--token-file. --slots <n>is the most children the machine runs at once. The default is half its CPUs, from 1 to 16, which leaves room for the tools the children run.--label zone=eastadds a label thatatomic worker statusshows. Repeat it for more labels.--prefix <prefix>selects the tables of one team when several share a database. On PostgreSQL it can be at most 24 characters.--idle-exit <ms>drains and exits once no child has run for that long. Use it for machines that should shut down when the queue is empty.--provider-id <id>names the VM instance, pod or CI job the host runs on, for a fleet controller. It defaults to theATOMIC_PROVIDER_IDenvironment variable, or on Kubernetes to the pod name.--jsonprints the events as one JSON object per line, for log shippers.
Start roots with shared placement
On the machine that starts the runs, select the shared database for workflow runs (withatomic storage join, or atomic team join for libSQL), and set workflows.workers beside workflows.localWorkers in settings:
/workflow run starts then run their pooled children on this machine’s own workers and on every worker host. SDK runs pass workers: { placement: "shared" } instead. See Workflow settings.
Stop, drain and status
SIGTERM or SIGINT drains a host: it takes no more children, lets the ones it runs finish, leaves the database’s host list, and exits0. A second signal stops it at once: the children it held go back to the queue, other hosts replay them from their last checkpoints, and it exits 2. Give a drain as long as the longest child step takes, for example with TimeoutStopSec in systemd or terminationGracePeriodSeconds in Kubernetes.
A machine that dies, or is killed with SIGKILL, loses nothing that was committed: once its children’s leases expire, other hosts take them over from their last checkpoints.
atomic worker status prints the fleet: how many running or paused roots use the database and how many of their children are ready and running, then each registered host and each root’s queue. --json prints the same as JSON, in the shape of host.stats(). Each host line starts with the host’s state:
busyoridle: whether any of its workers runs a child. The line also shows the instance it runs on when it has a provider id, its workers out of its slots, how many are busy, its labels, and when it last reported. Busy counts are as of the host’s last report, at most 5 seconds old.draining: it takes no more children and stops once its running ones finish.degraded: its polls of the database fail; the line shows how many in a row and the last error.stale: it has not reported for 15 seconds. It may have died, or lost the database. Its children go to other hosts once their leases expire.live: a host of an older Atomic version, which does not report whether it is busy.
shared store degraded, why, and a hint, and exits 1. A database it cannot reach at all prints shared store unknown the same way. The command also tries one write that changes nothing, and prints shared store degraded with the hint when the database answers reads but does not commit that write within 2 seconds.
Upgrade a fleet
Atomic 0.9.88 upgrades a shared database’s session schema the first time it opens it, and older Atomic versions then refuse that database withStorageSchemaVersionError. Upgrade every machine that shares the database together, in this order:
- Stop the fleet controllers and
atomic worker metrics, so nothing adds hosts. - Let running roots finish, or stop them and continue them after the upgrade with
/workflow resume. - Drain the worker hosts with SIGTERM, and stop the Atomic sessions on the machines that start roots.
- Install 0.9.88 or later on every machine, including container images and the user data of scaling groups (
atomic update, ornpm install -g @bastani/atomic), and update the storage adapter withatomic update --extensions. - Start the roots’ machines, then the hosts, then the controllers.
pg_dump.
Scale the fleet automatically
Every worker host is the same portable process,atomic worker start, wherever it runs: a VM, a container, a pod or a CI job. It drains on SIGTERM and can exit on its own once idle (--idle-exit), so any platform’s own scaling can add and remove hosts safely. Atomic gives that scaling two things:
- A scaling signal.
atomic worker metricsserves how many hosts the fleet wants, with the queue and slots behind it, for KEDA, Prometheus or any autoscaler to act on. Nothing of Atomic runs in your scaling group. - A fleet controller.
atomic worker controllerdecides when to add and remove hosts itself, and drives your platform through a provisioner: a command provisioner configured for your platform’s CLI (presets for Azure, AWS, Google Cloud, Kubernetes and GitHub Actions come with Atomic), or a JavaScript module of your own.
--checkpoint-interval-s 60, as Turso and libSQL explains: many hosts writing at once can otherwise stop sqld taking writes.
The scaling signal
The policy wantsceil(demand / workersPerHost) hosts, kept between minHosts and maxHosts. Demand is the ready and running children of every running root, each root capped by its admission.limits.session. A root whose budget is spent, or that is paused, counts only its running children.
atomic worker status --json prints it as scaling, and atomic worker metrics serves it over HTTP:
GET /scalingreturns the signal as JSON, the shape a KEDAmetrics-apitrigger reads. It answers503while the database cannot be read.GET /metricsreturns the same counts in Prometheus text:atomic_fleet_desired_hosts,atomic_fleet_desired_workers,atomic_fleet_demand,atomic_fleet_queue_depth,atomic_fleet_running,atomic_fleet_hosts,atomic_fleet_slots{state="busy"|"idle"}, the policy’satomic_fleet_min_hosts,atomic_fleet_max_hostsandatomic_fleet_workers_per_host, andatomic_fleet_store_up.
The endpoints serve counts only: no host, root, URL or token. They read the database at most once every 5 seconds however often they are scraped, never write to it, and cut off a client that takes longer than 10 seconds. The default address is
127.0.0.1:9464; listen on 0.0.0.0 only inside a container or a private network.
A KEDA ScaledObject that scales a Deployment of worker pods to desiredHosts, down to zero:
terminationGracePeriodSeconds, and a child still running after that is replayed on another host from its last checkpoint. Set the grace period to your longest child step.
The fleet controller
A fleet controller adds worker hosts when the queues grow, and drains and removes only hosts that sat idle, so it never removes a busy host. Run it on any machine that opens the database:--provisioner takes the JSON configuration of a command provisioner, or a JavaScript module whose default export is a provisioner with three methods:
atomic worker start --provider-id <id>, or the environment variable ATOMIC_PROVIDER_ID, set to the id list reports for it, so the controller can match the two. On Kubernetes a host uses its pod name by default.
A drain applies to the instance, not only to the worker on it. If a drained host’s worker restarts, for example when a pod’s container restarts, it registers under a new host id but with the same provider id, so it drains again at once and takes no children until the provider has removed the instance, including while a deletion is still in progress. A host started without a provider id is matched by its host id only. Such a host keeps its id in its agent directory, so if that directory does not survive a restart, set ATOMIC_PROVIDER_ID (or --provider-id) for its drain to survive the restart. Without one, a restarted host is drained only if list reports its new host id for the instance, and the controller keeps a drained instance whose host is gone while a host it cannot match holds a child. When several listings in a row no longer include an instance, the controller forgets its drain, so a new instance that reuses the id serves normally. One listing that briefly misses an instance does not clear its drain.
SDK roots can run it themselves while they run: pass workers: { placement: "shared", provisioner: { provisioner, maxHosts: 20, workersPerHost: 8 } }, where provisioner is a module’s provisioner or createWorkerHostProvisioner(config), or call startWorkflowFleetController({ provisioner, … }). When such a root ends, its controller asks the idle hosts above the target to drain at once, but nothing removes them from your provider afterwards unless another controller runs. Have your provisioner reap hosts that exited, or run atomic worker controller for a fleet that serves many roots.
Every 10 seconds the controller:
- computes the scaling signal’s target.
- adds hosts with
scaleTowhen more thanscaleUpQueueDepthchildren have been waiting forscaleUpDelayMs, or when children wait and no host runs at all. Hosts belowminHostsare added at once. - asks a host to drain once it has held no child for
idleDrainMsand the fleet is above its target. The host takes no more children, from the moment the drain is asked even before the host has read it, and exits as on SIGTERM; the controller callsdrainonce the host has left the registry. A host that stopped reporting without leaving is removed only 5 minutes after its drain was asked, and only if it holds no child. A host that holds a child is never drained or removed, whether or not it still reports. A child whose root died and whose own lease expired no longer counts, so it does not keep an idle host or the fleet’s target up. - removes a host that has not registered
bootTimeoutMsafter it started, or that the provider reports failed, and replaces it once. A host that registers just as its boot timeout is reached is kept, but a host that registered and then stopped reporting is not. The instance is drained before it is removed, so a host that registers on it during the removal takes no child. Further failures in a row wait 30 seconds, then twice as long each time, up to 10 minutes. - makes no provider call once its lease may have lapsed, including after its process was paused or blocked: it checks the lease’s age right before each call, by the longer of the monotonic and wall clocks, so a machine that slept past its lease does too.
You can run several controllers, such as one per root machine. One is active at a time through a lease in the database; another takes over within 30 seconds when it stops or dies. A provisioner that fails (a missing CLI, refused credentials, a quota) never fails, pauses or blocks a run: the controller prints
degraded, keeps the hosts the fleet has, and tries again after 10 seconds, then twice as long after each further failure, up to 10 minutes. Every decision and failure is recorded in the database’s last 1000 fleet events.
Command provisioners and presets
A command provisioner runs three commands of yours, each an executable and its arguments, never through a shell, with the controller’s environment:upadds hosts.{target}(alsoATOMIC_TARGET) is the count to reach and{add}(ATOMIC_ADD) how many that adds. It runs only whenlistreports fewer thantargethosts. With"upEach": trueit runs once for each host to add, with{add}1.downremoves the host{id}(ATOMIC_PROVIDER_ID), which drained already or never started, and lowers the count by one.{remaining}(ATOMIC_REMAINING) is the countlistreported, less one. It runs only for a host thatlistreports and that is notdrainingalready, so removing a host twice, or one already gone, does nothing.listprints the hosts as JSON: by default an array of{ "id": "vm-3", "state": "running", "since": "2026-11-14T09:30:00Z" }, wherestateispending,running,draining,stoppedorfailed, andsince(milliseconds or ISO 8601) andhostId(the Atomic host id, when you know it) are optional.listFormatreads other JSON:itemsis the path to the array (such asAutoScalingGroups.0.Instances),id,state,sinceandhostIdare paths in each item, andstatesmaps your platform’s states to host states. A state mapped tonullleaves the host out, for hosts your platform no longer counts, such as one being deleted;"*"maps any state not listed.
up and down may each be several commands run in order, as [["first", "…"], ["second", "…"]]. So may list: its commands report their hosts together, and a host two of them report counts once. Changes run one at a time. timeoutMs (default 120000) bounds each command; a command still running then, or when the controller gives up on the call, is stopped together with every process it started.
Credentials come from each tool’s own login or environment, or, for gh, from short-lived tokens Atomic mints from a GitHub App (GitHub App auth). Nothing in the configuration is a credential, and none of it is written to the database. A command that fails reports its exit code and its error output, with tokens, passwords and the values of secret-named environment variables replaced by [redacted]; its arguments are never reported. The error’s code says what went wrong: missing-cli (not installed or not on PATH), auth (credentials refused), exit, timeout, malformed (output the list format does not match) or unavailable (GitHub could not be reached when minting a token, or answered with a server error or a rate limit).
Atomic ships presets in its package’s examples/worker-fleet/presets directory. Copy one, replace the names in it (resource group, scale set, group, zone, namespace, repository) with yours, and pass it to --provisioner. The controller also needs a credential for the database; Run the fleet controller pairs each preset with one for PostgreSQL:
How each preset removes a host:
- The Azure, AWS and Google Cloud presets delete the named instance and lower the group’s size in one call, so the platform never picks a victim.
- The Kubernetes preset deletes the drained pod, then scales the Deployment down by one, so the ReplicaSet does not replace it. If the ReplicaSet already started a replacement, the scale-down removes it, because a ReplicaSet removes pods that are not ready, or have been ready for the shortest time, first. For scaling to zero on Kubernetes, prefer KEDA on the scaling signal.
- The GitHub Actions preset starts one workflow run per host, each running
atomic worker start --idle-exit, and cancels a run only if it never registered. A drained host’s run ends by itself.
${{ github.run_id }} in Actions. The recipes below do this.
GitHub App auth
Thegithub-actions.json preset authenticates gh as a GitHub App, as actions-runner-controller and actions/create-github-app-token do. The controller signs a JWT with the app’s private key, exchanges it for an installation token limited to Actions: write on the one repository, and passes the token to each gh command in that command’s environment only. Each token lasts an hour; the controller mints a new one 5 minutes before it expires, so no long-lived token exists anywhere.
- Create a GitHub App in your organization with the repository permission Actions: Read and write and nothing else, and install it on the repository that holds
atomic-workers.yml. - Generate a private key for the app, and store it on the controller’s machine in a file only the controller’s user can read:
chmod 600 app.pem. In Kubernetes, mount it from a Secret, the Secrets Store CSI driver or the External Secrets Operator, and copy it into memory at mode 600 as the Helm chart does for the database token. - Give the controller the app’s settings, in the preset or in environment variables:
A setting in the file takes precedence over its variable. The shipped preset sets only
repository, so the rest can come from the environment. For GitHub Enterprise Server, the controller passes the token as GH_ENTERPRISE_TOKEN with GH_HOST, as gh expects.
The private key and the tokens are never printed, logged or written to the database. If GitHub refuses the app (auth), or cannot be reached or answers with an error (unavailable), or the request runs past timeoutMs (timeout), the controller reports degraded, keeps its hosts, and tries again with its usual backoff. Runs are never affected.
To use a fine-grained personal access token instead, remove auth from the preset and give the controller the token as GH_TOKEN, or sign in once with gh auth login. Give the token access to that one repository with Actions: Read and write, and an expiry.
A controller that runs inside GitHub Actions needs neither: remove auth, and pass the workflow’s own token. workflow_dispatch events made with GITHUB_TOKEN do start workflow runs.
Credentials for each platform
Every recipe uses the platform’s own short-lived identity, and no recipe stores a cloud key:
A database password or token is long-lived. On PostgreSQL,
--auth replaces it with a short-lived token from the machine’s cloud identity: see Worker fleets on managed PostgreSQL.
Recipes
The package’sexamples/worker-fleet directory has a starting point for each platform:
Dockerfile: the worker image. It runsatomic worker startas PID 1 so SIGTERM drains it.STORAGE_ADAPTERselects the adapter it installs:atomic-storage-postgres(the default) oratomic-storage-libsql.helm/atomic-worker: a Helm chart sketch with the worker Deployment, anatomic worker metricsDeployment and Service, and a KEDAScaledObjectthat scales the workers from zero. The pods run as their own ServiceAccount, which you can annotate for workload identity (serviceAccount.annotations,podLabels). The database URL comes fromdatabase, or from theurlkey of the Secret thatdatabaseSecretnames. An init container copies the password or token from its Secret, or from your secret manager through the Secrets Store CSI driver (tokenSecretProviderClass), into memory, readable only by the container’s user.controllerRbac: truereplaces KEDA with a ServiceAccount and Role for a controller that uses thekubernetes.jsonpreset. Install the release in its own namespace, because a Role cannot limitpatchon pods to one Deployment’s pods.terraform/azure-vmss,terraform/aws-asg,terraform/gcp-mig: a scaling group of spot or preemptible VMs that boot intoatomic worker startwith their provider id, at size 0. Each module’spostgres.tfcreates a managed PostgreSQL database beside the group (Azure Database for PostgreSQL, Amazon RDS, Cloud SQL) and keeps its password in the cloud’s secret manager; setpostgres = falseto use a libSQL or Turso database fromdatabaseinstead. The VMs read the password or token from the secret manager with their own identity. Each module also creates the controller’s identity (a managed identity, an IAM role, a service account) with the least-privilege role for its preset.github-actions/atomic-workers.yml: a workflow whoseworkflow_dispatchinputworkersfans out that many jobs, each runningatomic worker start --idle-exit. Each job installs the adapter that matchesATOMIC_DB, signs in to your cloud with GitHub’s OIDC token, and reads the database password or token from its secret manager. Run it by hand for a burst, or let thegithub-actions.jsonpreset dispatch it.presets/: the command provisioner presets above.
Run it as a systemd service
/etc/systemd/system/atomic-worker.service:
/etc/atomic/worker.env holds the URL, such as ATOMIC_DB=postgresql://atomic@db.example.com:5432/atomic. /etc/atomic/token holds the PostgreSQL password, or the libSQL token, owned by the atomic user with mode 600. --token-file reads it again for every new connection, so a rotated password takes effect without a restart. With a cloud identity, replace --token-file /etc/atomic/token with --auth aws, --auth gcp or --auth azure. Run atomic install npm:@bastani/atomic-storage-postgres (or @bastani/atomic-storage-libsql) as the atomic user, because each user has their own agent directory. Exit code 1 is a setup error, which Restart=on-failure retries; read it with journalctl -u atomic-worker.
Provision with cloud-init
This user data installs Atomic on a new VM and joins it:runcmd before the last line, as the package’s Terraform recipes do. For libSQL, set a libsql:// URL, put the token in /etc/atomic/token, and install @bastani/atomic-storage-libsql.
Run it in a container
exec makes atomic the process that receives the container’s SIGTERM, so stopping the container drains it. Mount the password or token as a secret file, never as an environment variable or build argument. For libSQL, build with --build-arg STORAGE_ADAPTER=atomic-storage-libsql and pass a libsql:// URL. Options after the image name, such as --slots 4, are passed to atomic worker start. Process containment needs Linux, so run the container on a Linux host.
Security
- The token is the trust boundary. Anyone with a token for the database can read and write every table in it, including the work queue. Give a token only to machines you trust to run your workflows.
atomic worker startreads the token from a file only when it opens the database. It never prints or logs the token, and never puts it on a child process’s command line. The workers it starts receive a reference to the token file, not the token.- Keep the token file at mode 600. The command warns when other users can read it.
- Keep a GitHub App private key at mode 600 too. The controller refuses one that other users can read, and never prints it, the tokens it mints, or their JWT.
Troubleshooting
Worker host setup is incompletelists every missing piece at once, with a fix for each: the adapter not installed (atomic install npm:@bastani/atomic-storage-postgresfor a PostgreSQL URL,atomic install npm:@bastani/atomic-storage-libsqlfor libSQL), a token file that cannot be read, a journal that other machines cannot open (pass--db), or a machine where Atomic cannot contain its workers’ processes (Windows, or no native process identity). The command exits1.degraded failures=… retry_in_ms=…means the host could not reach the database. It keeps running its children and tries again after 1 second, then twice as long after each further failure, up to 30 seconds, and printsrecoveredonce it gets through. Its workers keep their children while each child’s lease (localWorkers.leaseMs, 30 seconds by default) lasts. A child whose lease expires during a longer outage, or that writes a checkpoint while the database is unreachable, is replayed by another host from its last checkpoint.shared store unknownorshared store degraded, or hosts that all turnstaleat once, mean the database stopped answering or stopped committing writes. sqld 0.24.33 and earlier can stop taking writes from every client until it restarts (tursodatabase/libsql#2286): start sqld with--checkpoint-interval-s 60(SQLD_CHECKPOINT_INTERVAL_S=60) to avoid it, and restart it if writes stay stuck. Hosts keep their running children meanwhile.- PostgreSQL connection, TLS, pooler and cloud identity errors: see Troubleshooting in the PostgreSQL fleet guide.
- A host never claims anything. It runs a root’s children only if it has the root’s worker definitions at the same paths with the same entry module, and the root uses shared placement. Check
atomic worker status, and/workflow status <run>on the root’s machine.