Configuration
The better-compute.config file, every betterCompute() option with its default, the host export that serve runs, environment variables and the deployment secret.
A deployment is configured in one TypeScript or JavaScript module that every process loads. Its default export
holds the options of betterCompute(); its named host export holds what better-compute serve runs around the
control plane. Values that differ between environments come from environment variables.
The configuration file
The CLI looks for the configuration in this order:
--config PATH;BETTER_COMPUTE_CONFIG;- the nearest
better-compute.config.mjs,.js,.tsor.mts, in the working directory and then each parent.
With no file, BETTER_COMPUTE_DATABASE_URL and BETTER_COMPUTE_SECRET describe a minimal deployment on their own
(configFromEnv): the store from the URL, BETTER_COMPUTE_BASE_URL (default http://localhost:3000), root tenant
root, and no IAM, so only public methods answer. A .ts configuration needs Node.js 22.18 or newer, which strips
types by default. better-compute init writes a starter file and never overwrites one.
The module is executable code and is trusted as such. It can export four things:
| Export | What it is |
|---|---|
default | defineConfig(options) or defineConfig(factory): the options of betterCompute() |
host | defineHost(config) or defineHost(factory): routes, metrics, tunnels, IAM handlers and the gateway role for serve |
commands | Project commands made with defineCommand, run as better-compute <name> |
cli | { defaults: { [command]: { flag: value } } }, keyed by command name or '*' for every command: flag defaults, used when neither the flag nor its environment variable is set |
Prefer the factory form. Importing the module then opens nothing; the CLI calls the factory with
{ command, env, cwd }, and your own server calls it through configOptions(config):
import { postgres } from 'better-compute/postgres';
import { pglite } from 'better-compute/pglite';
import { defineConfig } from 'better-compute/server';
export default defineConfig(({ env = process.env, cwd = process.cwd() } = {}) => ({
database: env.BETTER_COMPUTE_DATABASE_URL
? postgres({ url: env.BETTER_COMPUTE_DATABASE_URL })
: pglite({ dataDir: `${cwd}/.better-compute-pglite` }),
secret: env.BETTER_COMPUTE_SECRET ?? '',
baseURL: env.BETTER_COMPUTE_BASE_URL ?? 'http://localhost:3000',
rootTenantId,
iam: bridge.embedded(iam, { rootTenantId }),
modules: [...bridge.modules /* , metal(), vps(), … */],
}));import config from './better-compute.config';
import { betterCompute, configOptions } from 'better-compute/server';
const compute = betterCompute(await configOptions(config));betterCompute() options
betterCompute() is synchronous and side-effect free: it validates the options and registers the method table.
Anything wrong throws INVALID_CONFIG before a connection is opened.
Required
| Option | Type | Notes |
|---|---|---|
database | SqlExecutor | postgres({ url }) or pglite(). Must implement query, transaction and migrate. See database. |
secret | string | At least 32 characters, the same in every process. See the deployment secret. |
baseURL | string | The API's public origin, such as https://compute.example.com. HTTP or HTTPS, no credentials, query or fragment. Cookie requests must come from exactly this origin. |
iam | IamPort | bridge.embedded(iam, …) from the IAM bridge. Must implement authenticate, require and authorize. See Better IAM. |
Optional
| Option | Default | What it does |
|---|---|---|
rootTenantId | iam.rootTenantId | The IAM root tenant. Required when the port does not name it; when both are given they must agree. |
basePath | /api/compute | Where the handler is mounted. An absolute path without a trailing slash. |
typePrefix | compute- | IAM resource type prefix, the same as the IAM bridge's. |
modules | [] | Injected modules (provisionModule(), metal(), vps(), eventsModule(), …), registered after the built-ins. |
plugins | [] | Third-party modules mounted under plugins/{id}, with actions compute:<id>:*. |
drivers, providers, attestors | [] | Extensions: bare-metal drivers, VPS providers, join attestors beyond join-token. |
scheduler | none | Placement plugins, scorer weights and topN (default 5) for the built-in capacity module. |
capacity | see below | Options of the built-in capacity module. |
pki | none | CAs, JoinService keys and command signing. See pki. |
secrets | the local sealed store | SecretStores by URI scheme. Without any, secrets are sealed in sealed_secrets under secret. |
tunnels | none | Exec tickets and break-glass SSH (v0.3). Without it neither tunnels.open nor nodes.issueSshCert exists. See tunnels. |
notifier | none | Best-effort wake-ups after commit, such as gatewayNotifier({ sql, tls }) from better-compute/gateway. Never a source of truth. |
sinks | none | { events?, audit? }. Each sink needs an id unique among its kind: its cursor is stored under it. Audit sinks add the built-in audit-sinks module. usage with any entry is INVALID_CONFIG: usage goes to IAM billing through the bridge. Sinks a module lists in extensions join these. See sinks. |
clock | anchored to compute_now() | Business time. Tests pass the sim clock. |
acceptIamCookie | false | Accept the IAM session cookie on same-origin requests (a console on one origin). |
log | JSON warnings and errors on stderr | A Logger with debug, info, warn and error. |
iamJobs | [] | Better IAM's jobs for the iam worker role: { name, everyMs, run }, everyMs at least 100. Usually filled by the IAM bridge. |
worker | see below | Worker runtime tuning. |
ports | built-in | Replace ctx.capacity or ctx.inventory. |
downloads | none | { dir?, blob? } (at least one): where GET {basePath}/downloads/bcd/{version}/{os}-{arch} finds bcd binaries, and GET {basePath}/downloads/bcx-ipmi/{version}/{os}-{arch} the bcx-ipmi plugin archive. The examples set dir from BETTER_COMPUTE_DOWNLOADS_DIR (5792b2b; see examples). See installing bcd. |
supportBundles | none | { blob, maxBytes? }: a BlobStore with get and presign, and the largest upload (a positive integer, default 256 MiB). commands.run logs.collect then hands the daemon a presigned PUT for support-bundles/{nodeId}/{key}.bundle. Without it logs.collect has no upload destination. The CLI host does not set it. See support bundles. |
gateway | none | { url?, serverName?, publicUrls?, maxStreams? }. With url (https, no credentials, query or fragment; INVALID_CONFIG otherwise) the server publishes where daemons dial in /.well-known/better-compute as gateway: { url, serverName?, servingCaPin? }, the pin computed from pki.servingCa, so bcd join --server <control plane> on a machine without config.toml finds the gateway (a host build still checks the document against the pins its install brings). serverName is the TLS name when it differs from the URL's host (a DNS name). publicUrls and maxStreams are not read. See installing bcd. |
capacity
| Option | Default | What it does |
|---|---|---|
placer | inline | inline places a reservation in the admission transaction. pool-lease (v0.4) queues it; the worker owning the pool places up to maxBatch at a time and reservations.create answers pending with a Queued condition, or waits for the batch when the request sets wait: { timeoutMs }. |
maxBatch | 64 | Reservations per pool-lease batch, at most 64. |
maxAttempts | 3 | Placement attempts per admission. |
rollupEvery | 5m | How often the rollup job rebuilds every pool. |
usageEvery | 5m | How often closed hourly usage windows are written. |
spotNoticeMs | 2 minutes | Notice a preempted spot reservation gets. |
attestedBindings | pkiAllocationBindingCheck() with pki.commandSigning, otherwise off | An AllocationBindingCheck: a reservation's allocation counts as bound only once the node's own signed AllocationBound verifies. See attested bindings. |
worker
| Option | Default | Range | What it does |
|---|---|---|---|
id | wkr_<ulid> | [A-Za-z0-9._:-], 1-128 | This process's worker id and lease owner |
batchSize | 32 | 1-1,000 | Keys a reconciler claims per pass |
concurrency | 4 | 1-64 | Keys one reconciler runs at once |
pollMs | 1,000 | 10-60,000 | Wait when there was nothing to do |
claimTtlMs | 60,000 | 1,000-3,600,000 | How long a claimed key stays claimed |
resyncMs | 300,000 | 1,000-86,400,000 | Owned keys are re-enqueued every resyncMs plus up to resyncMs of jitter |
These are fixed: shard and singleton leases live 15 s and renew every 5 s, a worker is live while its heartbeat is
younger than 30 s, SYNC_AGAIN brings a key back after 1 s, failures back off from 1 s to 5 minutes, and the
CommandSweeper runs every 5 s.
pki
| Option | What it does |
|---|---|
pki.nodeCa | The node CA. The gateway role takes its trust anchor and revocation set from it. |
pki.servingCa | The gateway serving CA. With gateway.url, its SPKI pin is published in /.well-known/better-compute as gateway.servingCaPin (read once; a failure is logged, the pin left out and retried a minute later). The gateway's own TLS still comes from host.gateway, issued with pki issue-gateway. |
pki.internalCa | Accepted, but nothing reads it yet. |
pki.join | JoinService's keys and node CA: pkiJoinCrypto({ trustDomain, nodeCa, signingKey, encryptionKey }). Without it, Join and Renew answer INVALID_CONFIG. Its public keys appear in /.well-known/better-compute as join. |
pki.commandSigning | The command-signing root (v0.2). Every command is then signed with a 24 h worker key it certifies, every NodeSpec generation is signed as it is written (v0.4), daemons get its root in their trust bundle, and capacity's attestedBindings defaults to the pki check. |
pki.commandSigningRoots | Roots still trusted during a rotation. Needs commandSigning. |
pki.trustVersion | The trust bundle's lowest version (default 1). The published version follows the content: a changed bundle (roots, approvers, tiers) takes the next one by itself (trust_bundle_state). Setting it above the recorded version republishes a bundle as newer. Approver-set signatures do not depend on it: they sign pki.approvers.epoch (c6214dc); see changing the approver set. |
The PKI bundle supplies all of these:
import { loadPkiBundle } from 'better-compute/cli';
import { pkiJoinCrypto } from 'better-compute/server';
const bundle = await loadPkiBundle('.better-compute/pki.json', env); // unsealed with BETTER_COMPUTE_SECRET
const pki = {
nodeCa: bundle.nodeCa,
join: pkiJoinCrypto({
trustDomain: bundle.trustDomain,
nodeCa: bundle.nodeCa,
signingKey: bundle.joinSigning,
encryptionKey: bundle.joinEncryption.privateKey,
}),
commandSigning: bundle.commandSigning,
};See PKI.
tunnels
| Option | Default | What it does |
|---|---|---|
issuer | required | The exec ticket issuer: execTicketIssuer(bundle, { issuer }) or createExecTicketIssuer, with issuer set to baseURL's origin (the api accepts only tickets from it). Its JWKS goes into every node's NodeSpec and to the gateway. |
ssh | none | The compute SSH CA (bundle.sshCa) for nodes.issueSshCert. Without it that method answers UNSUPPORTED. |
sshRequiresMfa | true | Break-glass certificates need an MFA session. |
sshAccounts | ['root'] | Unix accounts a certificate may name; the principal is <account>@<nodeId>. |
limits | 10 tunnels a minute, 20 certificates an hour | Per identity and per api replica. Beyond them: RATE_LIMITED with retryAfterMs. |
The api role mounts the tunnel upgrade only when tunnels.issuer is set, and it needs a re-authorizer: an
IamPort with authorizeSession (the IAM bridge has one) or host.tunnels.authorize. See
tunnels.
The host export
host is what serve runs around the control plane. A factory receives { compute, options, env, cwd }, so
routes can close over the same instances the modules use.
import { readFile } from 'node:fs/promises';
import { defineHost } from 'better-compute/cli';
import { handleWatch } from 'better-compute/events';
import { createMetrics } from 'better-compute/observability';
const metrics = createMetrics();
export const host = defineHost(async ({ compute, env }) => ({
http: { routes: [{ path: '/watch', method: 'GET', handler: (request) => handleWatch(request, { sql: compute.context.sql, authorize }) }] },
metrics: { registry: metrics, bearerToken: env.BETTER_COMPUTE_METRICS_TOKEN },
gateway: {
region: 'eu-1',
listen: { port: 8443 },
tls: {
key: await readFile('.better-compute/gateway/gateway.key', 'utf8'),
cert: await readFile('.better-compute/gateway/gateway.crt', 'utf8'),
ca: bundle.nodeCa.export().certificatesPem,
},
internal: { url: 'https://gw-1.internal:8444', tls: internalServerTls },
},
}));| Field | What it configures |
|---|---|
http.routes | Fetch-style routes beside the API: { path, method?, prefix?, handler(request) } |
http.upgrade | Upgrade handlers, tried after the tunnel handler |
http.wellKnown | Extra public fields of /.well-known/better-compute |
metrics.registry | The process's createMetrics() registry; serve adds the gateway role's series to it |
metrics.bearerToken, metrics.path | With a token of at least 16 characters, GET /metrics (or path) |
tunnels.authorize | The tunnel re-authorizer (default tunnelAuthorizer(iam)) |
tunnels.control | Client TLS (tls, timeoutMs) for the gateways' internal control APIs |
tunnels.reauthorizeMs, maxDurationMs, onClose | Tunnel re-authorization period, lifetime and close hook |
iam.bridge | The IAM bridge whose inbox the workers drain: a revoked delegation closes the agent's tunnels on every gateway, and revocations drop IAM caches |
iam.streams, iam.handlers, iam.gatewayStaleMs | This process's createWatchRegistry() registries, closed on revocation too; your own reactions in place of the defaults; gateways asked (heartbeat within 120 s by default). See tunnels |
streams | This process's own stream registries (such as the createWatchRegistry() your GET /watch route passes to handleWatch) that IAM revocations close at once; added to iam.streams, and unlike those also used with the embedded IAM of dev --sim. Anything without closeWhere is INVALID_CONFIG |
iamReactions, iam.reactions | Modules' reactions to IAM events (PLAN.md §12.8) beside capacity's: objects with agent(tx, context, filter) and/or identity(tx, context, end), run by the default agent and identity handlers in the inbox transaction, such as vps's { agent: withdrawAgentSandboxes, identity: reassignSandboxes }. iamReactions is also used with dev --sim's embedded IAM. Anything else is INVALID_CONFIG |
iam.webhook | { secret, path? }: remote IAM's signed event webhooks into iam_inbox at POST /iam/events (or path), for deployments that cannot read the IAM database. See IAM events |
mcp | { path?, handler }: the gated MCP endpoint (computeMcpEndpoint(...).handler), default path /mcp. See MCP |
gateway.region | Required. The region, as in sites.region |
gateway.listen | Daemon listener, default 0.0.0.0:8443 |
gateway.tls | Required. key and cert from the serving CA, ca the node CA roots; any of them may come from tlsFiles instead |
gateway.tlsFiles | { key, cert, ca?, intervalMs? }: file paths read at start and re-read every intervalMs (60 s); a renewed certificate is hot-reloaded for new handshakes. See PKI |
gateway.identity | { roots, trustDomain, revocations }; default: pki.nodeCa, pki.join.trustDomain and the node CA's revocation set |
gateway.internal | { url, listen?, tls, tlsFiles? }: the internal control API (default port 8444) over internal-CA mutual TLS; url is advertised in the gateways row. tls or tlsFiles must include ca (the internal CA), or serve refuses it: without it Node would accept any publicly issued client certificate |
gateway.ws | { publicHost, listen? }: the v0.4 WebSocket fallback (default port 443) |
gateway.options | Anything else createGateway() takes (maxStreams, rates, extra reports) |
serve checks the shape and refuses a bad one with INVALID_CONFIG; doctor checks the wiring.
Modules and their options
Modules are values you construct and pass in modules, each with options of its own. The examples register:
const bareMetal = metal({ hardwareProfiles });
modules: [
provisionModule({ modules: [bareMetal] }), // runs metal's workflows
bareMetal,
vps(),
retentionModule(), // added by the CLI when missing
eventsModule({ secret }), // seals webhook signing secrets under the deployment secret
fleetModule(),
rolloutModule({ release: { pins }, downloadsUrl }), // needs the release keys' pins
auditModule({ signer: bundle.auditSigning }), // needs the bundle's audit key
];Each module's own migrations run with better-compute migrate (see database). Its
options are documented with the module; see modules.
Environment variables
The CLI reads these:
| Variable | Used by | Meaning |
|---|---|---|
BETTER_COMPUTE_CONFIG | every command | The configuration module, like --config |
BETTER_COMPUTE_DATABASE_URL | environment-only mode, dev, the template | postgres://…, pglite:DIR or pglite:memory |
BETTER_COMPUTE_SECRET | environment-only mode, pki, audit, the template | The deployment secret |
BETTER_COMPUTE_BASE_URL | environment-only mode, the template | The public origin (default http://localhost:3000) |
BETTER_COMPUTE_GATEWAY_URL, BETTER_COMPUTE_GATEWAY_SERVER_NAME | environment-only mode, the examples | gateway.url and gateway.serverName, published in /.well-known/better-compute for bcd join --server. Environment-only mode has no PKI bundle, so no serving-CA pin is published; the examples add it from the bundle |
BETTER_COMPUTE_PKI_ALLOW_UNAUTHENTICATED | every bundle loader | 1 loads a bundle sealed in the former format (or unsealed while a secret is set) until it is resealed; the commands that write the bundle refuse it. See pki reseal |
BETTER_COMPUTE_ROLES, PORT, HOST | serve | --roles (default all), --port (default 3000), --host (default 0.0.0.0) |
BETTER_COMPUTE_PKI | pki, doctor, audit | The PKI bundle (default .better-compute/pki.json) |
BETTER_COMPUTE_TRUST_DOMAIN | pki init | --trust-domain |
BETTER_COMPUTE_TOKEN | token commands | IAM session token, API key or session JWT to act as |
BETTER_COMPUTE_URL | token commands | Call a running server instead of loading the configuration |
BETTER_COMPUTE_TENANT | reserve, capacity, audit | The tenant |
BETTER_COMPUTE_AUDIT_PIN | audit verify | The audit key pin |
BETTER_COMPUTE_DEBUG | every command | Print the stack trace of a failure |
Inside the configuration, variables are yours to name: the file reads env and decides. The CLI only guarantees
that env is the process environment.
The deployment secret
secret is at least 32 random characters, identical in every process of the deployment. Generate it once:
openssl rand -base64 48It protects:
- Secrets at rest. Without
secrets,sealed_secretsholds values sealed with AES-256-GCM under a key derived from it (migrationpki/0001_sealed_secrets). - The PKI bundle.
pki initseals the private keys under a key derived fromBETTER_COMPUTE_SECRET(HKDF-SHA256, AES-256-GCM); every process unseals the bundle with it. - Webhook signing secrets, when you pass it to
eventsModule({ secret })as the examples do.
Better Compute is created by Sean Filimon
Last updated
Deployment
Lay out the api, gateway and worker processes, put the right load balancers in front, run the better-compute serve host or embed the handler in your own app.
Database
PostgreSQL requirements, connecting and pooling through PgBouncer, how migrations run, PGlite for development, the runtime tables and the lock order.