Worker fleets on managed PostgreSQL
A worker fleet keeps its work queue, leases, admission and fleet controller in one shared database. This guide puts that database on a managed PostgreSQL service and connects every machine to it: the machines that start roots, the worker hosts, and the fleet controller. For the database options themselves, see PostgreSQL. In this release a fleet’s pool writes on PostgreSQL take turns, one at a time across every machine that shares the database, whatever its prefix, as they do on libSQL. Hosts that wait for work poll the database.What you need
- Atomic 0.9.88 or later on every machine that opens the database. Upgrading an existing fleet? Read Upgrade a fleet first.
- PostgreSQL 16 or later.
- The adapter on every machine, installed as the user that runs Atomic:
atomic install npm:@bastani/atomic-storage-postgres. It is published on npm from Atomic 0.9.88. Container images and cloud-init install it in the same way. - A role and a database it owns. Atomic creates its tables the first time it uses the database. The role needs
CONNECTon the database andUSAGE, CREATEon the schema; owning the database gives it both. - One credential per machine: a password file, a token file that another process keeps fresh, or the machine’s cloud identity. See Choose a credential.
Connect every machine
Each kind of machine connects in its own way. All of them name the same URL and, if you use one, the same--prefix:
"workflows": { "workers": { "placement": "shared" } } in their settings, as Start roots with shared placement explains. atomic team join takes libSQL URLs only; use atomic storage join for PostgreSQL.
Put the user name in the URL and never the password: a URL with a password, or with a password= parameter, is refused. Quote the URL in a shell, because a query string such as ?sslmode=verify-ca&sslrootcert=… holds a &.
Choose a credential
atomic storage join and every atomic worker command take exactly one of these with a PostgreSQL URL:
A cloud identity needs its cloud’s SDK beside the adapter, installed as the same user:
Neon and Supabase take a password. Keep password and token files at mode 600;
atomic worker start warns when other users can read them. See Cloud identities for where each SDK finds the identity.
Plan connections
Each Atomic process opens connections as it needs them, up to 8 (maxConnections), and closes those idle for 10 seconds. A worker host runs a process for itself, one that coordinates its workers, and one for each slot, so it can open up to 8 × (slots + 2) connections. A controller, a metrics server and each Atomic session on a root machine can open up to 8 each:
A fleet usually holds far fewer, because a process opens connections only for the statements it runs at once. When this ceiling exceeds the server’s
max_connections, watch the server’s connection count as the fleet grows, and connect through a connection pooler before it runs out. Machines whose storage setting you write by hand can lower maxConnections instead.
Connection poolers
A pooler lets many machines share a few server connections. Atomic works through poolers in transaction mode, the default for most managed poolers: it keeps no settings, locks or prepared statements on a connection between transactions. Point the URL at the pooler instead of the server:
What to check:
- Pool mode. Use
transactionorsession.statementmode is not supported, because Atomic runs transactions of several statements. - Pool size. The pooler’s server-side pool (PgBouncer’s
default_pool_size) bounds how many transactions run at once. Its client limit (PgBouncer’smax_client_conn) must cover the connections from Plan connections. - Cloud identities. The pooler must accept the cloud token and pass it to the server, as Azure’s built-in PgBouncer does for Microsoft Entra ID. A PgBouncer you run checks passwords against its own user list, so use
--password-filethrough it. - TLS. The pooler’s certificate is the one Atomic checks. An address that is not in that certificate, such as a private IP address, needs
sslmode=verify-cawithsslrootcert. - A pooler restart drops connections. Atomic reports it as a lost connection, and a write that wrote nothing runs again.
Prefixes
Aprefix keeps several teams or projects apart in one database: pass the same --prefix to atomic storage join and every atomic worker command. On PostgreSQL a prefix can be at most 24 characters, because PostgreSQL shortens table and index names longer than 63 bytes. A longer prefix is refused with StoragePrefixError reason "too-long" before Atomic creates anything. A prefix holds ASCII letters, digits and underscores and starts with a letter or underscore.
Hosts serve only the roots of their own prefix, and each prefix has its own fleet: its own controller, metrics and hosts. Pool writes take turns across every prefix in the database, so give a large fleet its own database rather than a prefix.
Amazon RDS and Aurora PostgreSQL
- Create an RDS for PostgreSQL instance or an Aurora PostgreSQL cluster, version 16 or later, in the hosts’ VPC. Allow port 5432 from the hosts’ security group.
-
Create the database and a role, connected as the master user:
On PostgreSQL 15 and later, a role that does not own the database also needs
GRANT CREATE ON SCHEMA public TO atomic. -
Download your region’s certificate bundle to every machine, for example to
/etc/atomic/rds-ca.pem: -
Connect with the endpoint, an Aurora cluster’s writer endpoint, or an RDS Proxy endpoint:
aws secretsmanager get-secret-value --secret-id <arn> --query SecretString --output text.
IAM authentication instead of a password. Enable IAM database authentication on the instance or cluster, then:
rds-db:connect on arn:aws:rds-db:<region>:<account-id>:dbuser:<DbiResourceId or cluster resource id>/atomic_hosts, install the SDK, and connect with --auth aws:
AWS_REGION or the AWS profile. Once the first machine has created the tables, CREATE on the schema is no longer needed.
Google Cloud SQL for PostgreSQL
-
Create a Cloud SQL for PostgreSQL 16 instance with a private IP in the hosts’ network, and require TLS (
ENCRYPTED_ONLY). -
Create the user and database:
A user created this way belongs to
cloudsqlsuperuser, which owns the databasesgcloudcreates, so it can create Atomic’s tables. -
Save the instance’s server CA certificate on every machine:
-
Connect by private IP address. That address is not among the certificate’s names, so check the CA only (
verify-ca):
cloudsql.iam_authentication=on, add the hosts’ service account as an IAM database user, and grant it roles/cloudsql.instanceUser:
atomic user, grant the IAM user CONNECT on the database and, connected to atomic, USAGE, CREATE on the public schema. Its name is the service account’s email without .gserviceaccount.com; in the URL, write its @ as %40:
cloud-platform access scope; GKE pods use Workload Identity Federation. AlloyDB for PostgreSQL works the same way, with roles/alloydb.databaseUser and roles/serviceusage.serviceUsageConsumer and the cluster’s CA.
Azure Database for PostgreSQL
- Create a flexible server, PostgreSQL 16 or later, with private access in the hosts’ virtual network, or public access limited to their addresses.
-
Create the database and a role as the administrator:
-
Connect. Azure’s certificates chain to roots that Node.js trusts, so no CA file is needed:
az login --identity --allow-no-subscriptions && az keyvault secret show --vault-name <vault> --name atomic-postgres-password --query value --output tsv.
Microsoft Entra ID instead of a password. Enable Microsoft Entra authentication on the server. As the Entra administrator, connected to the postgres database, create a role named exactly as the hosts’ managed identity, then grant it the database:
atomic, run GRANT USAGE, CREATE ON SCHEMA public TO "atomic-workers". The identity needs no Azure RBAC role for the database. Then:
AZURE_CLIENT_ID selects a user-assigned identity on a VM; AKS pods use workload identity. To pool, enable the server’s built-in PgBouncer and connect to port 6432.
Neon
-
Create a project and a database, then a role in the console or with
CREATE ROLE. -
Copy the connection string, take the password out of it, and save the password to a file:
-pooler host name is Neon’s PgBouncer in transaction mode; leave -pooler out for a direct connection. Neon’s certificates chain to roots that Node.js trusts. Worker hosts poll the database while they run, so a Neon compute that scales to zero stays awake for as long as any host, controller or metrics server runs.
Supabase
-
Create a project. Its
postgresrole owns thepostgresdatabase, or create a role and a database of your own. - Download the project’s CA certificate (Project Settings → Database → SSL Configuration) to every machine. Supabase’s certificates chain to its own CA, which Node.js does not trust.
-
Connect through the transaction pooler (port 6543), whose user name ends with the project reference:
db.<project-ref>.supabase.co, IPv6 unless you add IPv4) work too, with the same CA.
Run the fleet controller
The controller drives your platform through a preset and opens the database like any host. Run it on a machine or pod with two identities: one that the preset’s CLI uses, and one for the database. Each cloud’s identity can be both, when the database uses that cloud’s IAM authentication:
For example, a controller for an AWS Auto Scaling group whose role reaches both the group and the database:
--workers-per-host must match the --slots the hosts start with. Run more than one controller for failover; one is active at a time through a lease in the database. The hosts the preset starts must connect to the same database and prefix, and each must report its provider id, as the recipes below do.
Recipes in the package
The package’sexamples/worker-fleet directory has the files the worker hosts guide describes. For PostgreSQL:
terraform/aws-asg,terraform/gcp-mig,terraform/azure-vmss: each module’spostgres.tfcreates an Amazon RDS instance, a Cloud SQL instance or an Azure Database for PostgreSQL flexible server beside the scaling group, keeps its password in the cloud’s secret manager, and points the hosts at it. Setpostgres = falseto use a libSQL or Turso database fromdatabaseinstead. Terraform keeps the generated password in its state, so keep the state in a private backend.terraform/cloud-init.yaml.tftpl: installs the adapter that matches the URL, and writes the database’s CA bundle when it needs one.Dockerfile:--build-arg STORAGE_ADAPTER=atomic-storage-postgresinstalls the PostgreSQL adapter, which is the default.helm/atomic-worker:databaseSecretnames a Secret whoseurlkey holds the database URL, andtokenSecretthe Secret whosetokenkey holds the password.github-actions/atomic-workers.yml: installs the adapter that matchesATOMIC_DB, and reads the password like a libSQL token.
Troubleshooting
self-signed certificate in certificate chain,unable to get local issuer certificateorunable to verify the first certificate: the server’s CA is not one Node.js trusts. Addsslrootcertwith the provider’s CA, as in the recipes above.StorageConfigErrorreasontls-parameter-caseorunsupported-sslmode: write TLS parameters such assslmodeandsslrootcertin lowercase, and use ansslmodethat libpq knows (disable,allow,prefer,require,verify-ca,verify-full). Nothing was opened.Hostname/IP does not match certificate's altnames: you connect by an address that is not in the server’s certificate, such as Cloud SQL’s private IP. Usesslmode=verify-cawithsslrootcert, which checks the CA but not the host name.too many connections(53300), orremaining connection slots are reserved: the fleet has outgrownmax_connections. Use a pooler, raisemax_connections(on managed services, a larger instance), or run fewer slots per host.password authentication failed(28P01) with--auth: the token was refused. Check that the role exists with the identity’s exact name, that RDS hasrds_iamgranted and IAM authentication enabled, or that Cloud SQL’scloudsql.iam_authenticationflag is on. An RDS token is signed for one host and port, so connect to the host it names.auth-unavailable: the cloud’s SDK is not installed for the user that runs Atomic, or the machine has no identity. Install the package the message names.permission denied for schema public(42501) on the first start: the role cannot create tables. GrantUSAGE, CREATE ON SCHEMA public, or make the role the database’s owner.Invalid storage prefix: too-long(StoragePrefixError): shorten the prefix to 24 characters or fewer.StorageSchemaVersionErroron an older machine: a machine of this release has upgraded the database. Upgrade that machine to 0.9.88 or later; see Upgrade a fleet.- Hosts register but never claim: they serve another prefix or database than the roots, or lack the roots’ worker definitions. Compare
atomic worker statuswith the roots’atomic storage status.