Launching a server

Run a Bubble server with the bundled CLI, or embed it programmatically when you need more control.

Running a serverdocs/server/10-launch.mdx
On this page

Bubble ships a small bubble CLI that reads a JSON config file and starts a wired Fastify server. For most use cases — local development, a container CMD, a service unit — the CLI is all you need. The same launcher is also exported as a function for callers that want to embed Bubble inside a larger Node process.

The CLI

After installing @octostaff/bubble in a project (or running it via pnpm exec / npx), the bubble binary is on the path. Pointing it at the bundled dev config is a complete program:

bash
export BUBBLE_MASTER_KEY=local-dev-key
bubble --config ./examples/config/local-dev.json

That gives you:

  • The in-memory store seeded with one system principal (root) that owns the system target.
  • A two-driver auth stack: local-token (bot tokens) followed by master-key (your BUBBLE_MASTER_KEY is interpreted as root).
  • HTTP routes mounted under /v1/..., a WebSocket endpoint at /v1/ws, and /healthz for liveness.

Run with --help to see every option:

text
Usage: bubble [options]

Options:
  -c, --config <path>   Path to a JSON config file (default: $BUBBLE_CONFIG, or built-in defaults)
  -p, --port <port>     Port to listen on (default: $PORT or 3000)
  -h, --host <host>     Host to bind to (default: $HOST or 0.0.0.0)
      --help            Show this message

The CLI:

  • Resolves --config against the current working directory and parses it as JSON. Without a flag it falls back to $BUBBLE_CONFIG; without that it launches with built-in defaults (in-memory store, single default authenticator, no seeded principals or grants — fine for a smoke test, not useful for real work).
  • Reads --port/$PORT and --host/$HOST for the listen address.
  • Handles SIGINT and SIGTERM by closing Fastify cleanly.

Watch Mode for Local Development

While iterating on Bubble itself, the package exposes a dev script that rebuilds on every source change and restarts the CLI against the bundled dev config:

bash
export BUBBLE_MASTER_KEY=local-dev-key
pnpm dev

That runs tsup --watch over src/ and, on each successful build, re-launches bubble --config ./examples/config/local-dev.json. Stop it with Ctrl+C.

Zero-Config (Sanity Check)

The shortest possible invocation skips the config file entirely:

bash
bubble
# server listening on http://0.0.0.0:3000

Every config field has a default, so this starts a server — but with no seeded principals or grants nothing is authorised to do anything interesting. Use it for a quick "did the binary install correctly?" check and reach for a config file as soon as you want to exercise the API.

Containerized / Service Use

The same binary is what you point a container or service unit at:

dockerfile
ENV BUBBLE_CONFIG=/etc/bubble/config.json
CMD ["bubble"]
ini
# systemd unit excerpt
ExecStart=/usr/local/bin/bubble --config /etc/bubble/config.json
Environment=PORT=8080

A Working Local Config

The package ships four runnable configs under examples/config/, each for a different job:

FileFor
reference.jsonA documented template showing every driver and option.
local-dev.jsonWhat pnpm dev runs: in-memory store, master key, a seeded root owner.
fixtures.jsonThe seeded corpus the integration tests and UI verification run against.
entra.jsonSigning in against a real Microsoft Entra ID directory.

local-dev.json is the starting point:

json
{
  "store": { "driver": "memory" },
  "auth": [
    { "driver": "local-token" },
    {
      "driver": "master-key",
      "masterKeyEnvVar": "BUBBLE_MASTER_KEY",
      "principalIdHeader": "x-bubble-principal-id",
      "defaultPrincipalId": "root"
    }
  ],
  "seed": {
    "principals": [{ "principalId": "root", "kind": "system", "displayName": "Development Root" }],
    "authz": [{ "target": { "type": "system" }, "principalId": "root", "role": "owner" }],
    "threads": []
  },
  "maxEventPageSize": 100,
  "logger": { "level": "info" }
}

Copy it, set BUBBLE_MASTER_KEY in the environment, and bubble --config ./examples/config/local-dev.json is enough to exercise the whole API as the root system owner.

Running Against a Real Entra ID Directory

examples/config/entra.json boots the same in-memory server with the entra driver in front of it, so a browser can complete a real Microsoft sign-in against a local server.

The directory identifiers come from the environment rather than the file, so a real tenant's ids never have to be written into a tracked config to run against it:

bash
export BUBBLE_ENTRA_TENANT_ID=<directory (tenant) id>
export BUBBLE_ENTRA_CLIENT_ID=<application (client) id>
export BUBBLE_MASTER_KEY=dev
pnpm --filter @octostaff/bubble dev:entra

The app registration needs an SPA redirect URI pointing at wherever Starfish is served — http://localhost:3001/auth/entra/callback for pnpm dev — plus the exposed API scope. The registration requirements are listed under the entra driver.

The master-key driver is kept in that config on purpose: it is the way back in as root if the federated login is not working yet, and the way to inspect what a completed sign-in actually created.

Verifying It Is Up

Two stable touchpoints regardless of config:

bash
# Liveness check; no auth required.
curl -s http://localhost:3000/healthz
# => {"ok":true}

# Calling identity for the credentials on this request.
curl -s http://localhost:3000/v1/auth/whoami \
  -H "Authorization: Bearer $BUBBLE_MASTER_KEY"
# => { "principal": { "principalId": "root", "kind": "system", ... }, "grants": [...] }

If whoami returns the expected principal and a non-empty grants array, the auth stack and the seed both took effect. A 401 means the authenticator stack rejected the credential; a successful response with grants: [] means the authz seed did not match — double-check seed.principals / seed.authz.

Embedding Programmatically

When the CLI does not fit — for example, you want to merge config from multiple sources, attach extra Fastify plugins, or run Bubble alongside other routes — call launchBubbleServer directly. The CLI is a thin wrapper around exactly this entry point:

ts
import { launchBubbleServer } from '@octostaff/bubble';

const app = await launchBubbleServer({
  store: { driver: 'memory' },
  auth: [{ driver: 'default', defaultPrincipalId: 'root' }],
  seed: {
    principals: [{ principalId: 'root', kind: 'system' }],
    authz: [{ target: { type: 'system' }, principalId: 'root', role: 'owner' }],
  },
});

await app.listen({ port: 3000, host: '0.0.0.0' });

If you want to validate or inspect the parsed config before launch (for example, to overlay environment overrides on a file), call the parser explicitly:

ts
import { launchBubbleServer, loadBubbleServerConfig } from '@octostaff/bubble';

const config = loadBubbleServerConfig(rawConfig);
// ... inspect or mutate `config` ...
const app = await launchBubbleServer(config);

loadBubbleServerConfig only parses and defaults; the launcher builds the runtime objects (store, authenticator, seeded principals, seeded grants, policy, Fastify instance) from the parsed result.

What Each Config Field Drives

A short orientation map; each field is detailed in the linked doc:

FieldDrivesDetail in
storeWhich BubbleStore implementation backs the server (memory or postgres).Thread Model
authOrdered authenticator stack used by the dispatcher.Authentication
seed.principalsDurable principals created at startup; each receives owner on itself.Bootstrapping
seed.authzGrant rows seeded into the store after the principal seed.Bootstrapping
maxEventPageSizeUpper bound on getEventPage responses; honoured by the in-memory store today.Events
loggerForwarded to Fastify (true, false, or a logger options object).—
publicBaseUrlBubble's externally reachable origin for extension-owned absolute URLs.—
extensions.<key>Enables and configures one trusted local server extension.—
extensions.artifactsEnables the built-in artifacts extension.—

Core Bubble does not depend on the artifact protocol. The artifact manifest, role capabilities, catalog schema, routes, loopback API, and cleanup lifecycle are enabled together only when extensions.artifacts is present. A local durable deployment can pair the Postgres catalog with disk bytes:

json
{
  "publicBaseUrl": "https://bubble.example.com",
  "extensions": {
    "artifacts": {
      "signingSecret": { "env": "BUBBLE_ARTIFACT_SIGNING_SECRET" },
      "objects": { "driver": "disk", "root": "/var/lib/bubble/artifacts" }
    }
  }
}

When this block is absent, Bubble does not advertise artifacts from GET /v1/info and does not mount /v1/artifacts. Unknown extension config keys are rejected at startup rather than silently ignored.

Use objects.driver: "azure" with either connectionString or accountUrl (Default Azure Credential). Set the optional objects.publicUrl to an HTTP(S) origin such as https://files.example.com when direct upload and download SAS URLs should use a custom domain. Bubble still authenticates and signs against the canonical account endpoint, then replaces only the URL origin; the custom endpoint must preserve the path and query string and support GET and PUT.

Where the storage account disallows shared-key and SAS access, pair accountUrl with objects.transfers: "bubble". Bubble then never mints a SAS: upload and download URLs point at Bubble's own signed transfer route, and Bubble streams the bytes to and from the container with its Entra credential. Grant that identity Storage Blob Data Contributor on the storage account. Clients such as Starfish need to reach only Bubble, and every transfer's bytes cross Bubble's network path. publicUrl applies only to the default transfers: "direct".

json
{
  "objects": {
    "driver": "azure",
    "container": "artifacts",
    "accountUrl": "https://account.blob.core.windows.net",
    "transfers": "bubble"
  }
}

Artifacts are governed by an explicit authorization target rather than by their URL. The authenticated control plane lives under /v1/artifacts, and each operation supplies its accessScope (sourceAccessScope and destinationAccessScope for imports). SDK consumers register the trusted local artifactClientExtension and obtain its API with client.extensions.require(artifactExtensionKey). That call negotiates the locally installed protocol version against /v1/info; Bubble never sends or loads executable extension code. Amphibians can explicitly compose the extension's tool provider to expose the same authenticated API to a model.

Bubble has no other way to bootstrap authorization: the policy reads from the store, the store starts empty, so a useful server needs at least one seed.principals entry plus a matching seed.authz grant (or, in production, a real authenticator that registers users on first login).

Mount Points

Once the server is listening, everything Bubble exposes lives under two prefixes:

  • /v1/... — the HTTP REST surface. Full route catalogue in HTTP Protocol.
  • /v1/ws — the multiplexed WebSocket endpoint. Frame contracts in WebSocket Protocol.

Plus the version-independent /healthz for liveness probes.

Next Steps

  • If you want to understand the auth stack you just configured, jump to Authentication.
  • If you are wiring this into an embedding application that already parses config from YAML/env, Bootstrapping walks through the seeding and registration flow that turns authenticated requests into durable principal rows.