better/compute

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:

  1. --config PATH;
  2. BETTER_COMPUTE_CONFIG;
  3. the nearest better-compute.config.mjs, .js, .ts or .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:

ExportWhat it is
defaultdefineConfig(options) or defineConfig(factory): the options of betterCompute()
hostdefineHost(config) or defineHost(factory): routes, metrics, tunnels, IAM handlers and the gateway role for serve
commandsProject 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):

better-compute.config.ts
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(), … */],
}));
server.ts
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

OptionTypeNotes
databaseSqlExecutorpostgres({ url }) or pglite(). Must implement query, transaction and migrate. See database.
secretstringAt least 32 characters, the same in every process. See the deployment secret.
baseURLstringThe 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.
iamIamPortbridge.embedded(iam, …) from the IAM bridge. Must implement authenticate, require and authorize. See Better IAM.

Optional

OptionDefaultWhat it does
rootTenantIdiam.rootTenantIdThe IAM root tenant. Required when the port does not name it; when both are given they must agree.
basePath/api/computeWhere the handler is mounted. An absolute path without a trailing slash.
typePrefixcompute-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.
schedulernonePlacement plugins, scorer weights and topN (default 5) for the built-in capacity module.
capacitysee belowOptions of the built-in capacity module.
pkinoneCAs, JoinService keys and command signing. See pki.
secretsthe local sealed storeSecretStores by URI scheme. Without any, secrets are sealed in sealed_secrets under secret.
tunnelsnoneExec tickets and break-glass SSH (v0.3). Without it neither tunnels.open nor nodes.issueSshCert exists. See tunnels.
notifiernoneBest-effort wake-ups after commit, such as gatewayNotifier({ sql, tls }) from better-compute/gateway. Never a source of truth.
sinksnone{ 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.
clockanchored to compute_now()Business time. Tests pass the sim clock.
acceptIamCookiefalseAccept the IAM session cookie on same-origin requests (a console on one origin).
logJSON warnings and errors on stderrA 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.
workersee belowWorker runtime tuning.
portsbuilt-inReplace ctx.capacity or ctx.inventory.
downloadsnone{ 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.
supportBundlesnone{ 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.
gatewaynone{ 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

OptionDefaultWhat it does
placerinlineinline 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 }.
maxBatch64Reservations per pool-lease batch, at most 64.
maxAttempts3Placement attempts per admission.
rollupEvery5mHow often the rollup job rebuilds every pool.
usageEvery5mHow often closed hourly usage windows are written.
spotNoticeMs2 minutesNotice a preempted spot reservation gets.
attestedBindingspkiAllocationBindingCheck() with pki.commandSigning, otherwise offAn AllocationBindingCheck: a reservation's allocation counts as bound only once the node's own signed AllocationBound verifies. See attested bindings.

worker

OptionDefaultRangeWhat it does
idwkr_<ulid>[A-Za-z0-9._:-], 1-128This process's worker id and lease owner
batchSize321-1,000Keys a reconciler claims per pass
concurrency41-64Keys one reconciler runs at once
pollMs1,00010-60,000Wait when there was nothing to do
claimTtlMs60,0001,000-3,600,000How long a claimed key stays claimed
resyncMs300,0001,000-86,400,000Owned 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

OptionWhat it does
pki.nodeCaThe node CA. The gateway role takes its trust anchor and revocation set from it.
pki.servingCaThe 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.internalCaAccepted, but nothing reads it yet.
pki.joinJoinService'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.commandSigningThe 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.commandSigningRootsRoots still trusted during a rotation. Needs commandSigning.
pki.trustVersionThe 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

OptionDefaultWhat it does
issuerrequiredThe 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.
sshnoneThe compute SSH CA (bundle.sshCa) for nodes.issueSshCert. Without it that method answers UNSUPPORTED.
sshRequiresMfatrueBreak-glass certificates need an MFA session.
sshAccounts['root']Unix accounts a certificate may name; the principal is <account>@<nodeId>.
limits10 tunnels a minute, 20 certificates an hourPer 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.

better-compute.config.ts
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 },
  },
}));
FieldWhat it configures
http.routesFetch-style routes beside the API: { path, method?, prefix?, handler(request) }
http.upgradeUpgrade handlers, tried after the tunnel handler
http.wellKnownExtra public fields of /.well-known/better-compute
metrics.registryThe process's createMetrics() registry; serve adds the gateway role's series to it
metrics.bearerToken, metrics.pathWith a token of at least 16 characters, GET /metrics (or path)
tunnels.authorizeThe tunnel re-authorizer (default tunnelAuthorizer(iam))
tunnels.controlClient TLS (tls, timeoutMs) for the gateways' internal control APIs
tunnels.reauthorizeMs, maxDurationMs, onCloseTunnel re-authorization period, lifetime and close hook
iam.bridgeThe 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.gatewayStaleMsThis 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
streamsThis 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.reactionsModules' 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.regionRequired. The region, as in sites.region
gateway.listenDaemon listener, default 0.0.0.0:8443
gateway.tlsRequired. 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.optionsAnything 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:

VariableUsed byMeaning
BETTER_COMPUTE_CONFIGevery commandThe configuration module, like --config
BETTER_COMPUTE_DATABASE_URLenvironment-only mode, dev, the templatepostgres://…, pglite:DIR or pglite:memory
BETTER_COMPUTE_SECRETenvironment-only mode, pki, audit, the templateThe deployment secret
BETTER_COMPUTE_BASE_URLenvironment-only mode, the templateThe public origin (default http://localhost:3000)
BETTER_COMPUTE_GATEWAY_URL, BETTER_COMPUTE_GATEWAY_SERVER_NAMEenvironment-only mode, the examplesgateway.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_UNAUTHENTICATEDevery bundle loader1 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, HOSTserve--roles (default all), --port (default 3000), --host (default 0.0.0.0)
BETTER_COMPUTE_PKIpki, doctor, auditThe PKI bundle (default .better-compute/pki.json)
BETTER_COMPUTE_TRUST_DOMAINpki init--trust-domain
BETTER_COMPUTE_TOKENtoken commandsIAM session token, API key or session JWT to act as
BETTER_COMPUTE_URLtoken commandsCall a running server instead of loading the configuration
BETTER_COMPUTE_TENANTreserve, capacity, auditThe tenant
BETTER_COMPUTE_AUDIT_PINaudit verifyThe audit key pin
BETTER_COMPUTE_DEBUGevery commandPrint 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 48

It protects:

  • Secrets at rest. Without secrets, sealed_secrets holds values sealed with AES-256-GCM under a key derived from it (migration pki/0001_sealed_secrets).
  • The PKI bundle. pki init seals the private keys under a key derived from BETTER_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.

Changing the secret

With the defaults, a new secret cannot open anything sealed under the old one: sealed secrets, the PKI bundle and webhook secrets. To rotate the sealed-secret key, configure the store yourself with both keys, secrets: [localSealedSecretStore({ sql, keys: { current: 'k2', keys: { k1: old, k2: next } } })], and run its reseal() until it reports no rows left. There is no CLI command for this yet, and re-sealing the PKI bundle under a new secret means exporting it again with exportPkiBundle.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page