Writing a custom provisioner
ss provision is extensible. Any cloud API, local tool, or third-party service can be added as a provisioner by implementing the ProvisionerAdapter interface and registering it with the registry.
The ProvisionerAdapter interface
Section titled “The ProvisionerAdapter interface”import type { ResolvedService } from "@celestial/starsystem/server";import type { ProvisionerAdapter, ProvisionContext, ProvisionResult, ProvisionStatus, ProvisionStateRecord,} from "@celestial/starsystem/provisioners";
export class MyProvisioner implements ProvisionerAdapter { /** Unique provider name. Must match `provision.provider:` in YAML or `target.type:` */ readonly provider = "my-provider";
/** * Set to false if your provisioner doesn't need an API key from the vault. * Defaults to true (vault lookup happens before provision() is called). */ readonly requiresApiKey = true;
/** Return true if this provisioner can handle the given service */ canProvision(service: ResolvedService): boolean { return service.target?.type === "my-provider"; }
/** * Create or refresh the resource. * ctx.existing is set when the resource was previously provisioned. * Idempotent: if ctx.existing is set, refresh credentials without re-creating. */ async provision(ctx: ProvisionContext, service: ResolvedService): Promise<ProvisionResult> { const isNew = !ctx.existing;
// Your API call here const resource = isNew ? await myApi.create({ name: ctx.serviceId, apiKey: ctx.apiKey }) : await myApi.get(ctx.existing!.resourceId, ctx.apiKey);
// Optionally save partial state mid-run to survive failures await ctx.savePartialState?.({ resourceId: resource.id });
return { state: { provider: "my-provider", resourceId: resource.id, provisionedAt: new Date().toISOString(), region: resource.region, metadata: { name: resource.name }, }, credentials: { // These are written to the vault only — never to YAML MY_SERVICE_URL: resource.url, MY_SERVICE_API_KEY: resource.apiKey, }, summary: isNew ? `created ${resource.name} (${resource.id})` : `${resource.name} already exists`, }; }
/** Check the health of a provisioned resource */ async status(ctx: ProvisionContext, state: ProvisionStateRecord): Promise<ProvisionStatus> { try { const resource = await myApi.get(state.resourceId, ctx.apiKey); return { healthy: resource.status === "active", message: `status: ${resource.status}`, raw: resource, }; } catch (err) { return { healthy: false, message: String(err) }; } }
/** Tear down the resource. Irreversible. */ async destroy(ctx: ProvisionContext, state: ProvisionStateRecord): Promise<void> { await myApi.delete(state.resourceId, ctx.apiKey); }}ProvisionContext fields
Section titled “ProvisionContext fields”| Field | Type | Description |
|---|---|---|
project | string | Starsystem project name (from name: in starsystem.yaml) |
env | string | Active environment (e.g. "local", "prod") |
serviceId | string | Service ID within the system |
apiKey | string | API token read from vault via providers:<name> api_key |
existing | ProvisionStateRecord | undefined | State from previous run (undefined on first run) |
configDir | string | undefined | Directory containing starsystem.yaml (for resolving relative paths) |
vaultGet | (key: string) => Promise<string | null> | Read any vault key |
vaultSetProviderKey | (provider, key, value) => Promise<void> | Write a provider key back to vault |
savePartialState | (partial) => Promise<void> | Persist partial state mid-run |
Registering your provisioner
Section titled “Registering your provisioner”Register it once at startup in the provisioner registry:
// In your app's entry point or plugin setupimport { provisioners } from "@celestial/starsystem/provisioners";import { MyProvisioner } from "./my-provisioner.js";
provisioners.register(new MyProvisioner());Or extend the registry in packages/starsystem-builder/src/provisioners/registry.ts for built-in provisioners:
import { MyProvisioner } from "./my-provisioner.js";
constructor() { // ... existing registrations this.register(new MyProvisioner());}YAML declaration
Section titled “YAML declaration”Once registered, use it in starsystem.yaml via provision.provider::
services: my-service: type: external name: My Service provision: provider: my-provider plan: starter region: us-east-1Or as a deployment target via target.type: in a v2 overlay:
services: my-service: target: type: my-provider credential_env: MY_SERVICE_URLThe ProvisionResult contract
Section titled “The ProvisionResult contract”| Field | Where it goes | Notes |
|---|---|---|
state | Vault + overlay YAML _state block | Safe to commit — no secrets |
credentials | Vault only | Never written to YAML |
summary | CLI output + MCP response | Human-readable one-liner |
The state block is written back to the overlay YAML as a _state: entry, giving you a human-readable record of what was provisioned:
# starsystem.prod.yaml (written by ss provision)services: my-service: provision: _state: provider: my-provider resourceId: res_abc123 provisionedAt: "2026-04-27T12:00:00Z" region: us-east-1 metadata: name: my-service-prodBuilt-in provisioners (source reference)
Section titled “Built-in provisioners (source reference)”| Provisioner | File | Handles |
|---|---|---|
NeonProvisioner | provisioners/neon.ts | Neon serverless Postgres |
SupabaseProvisioner | provisioners/supabase.ts | Supabase cloud projects |
SupabaseLocalProvisioner | provisioners/supabase-local.ts | Local Supabase CLI stack |
FlyProvisioner | provisioners/fly.ts | Fly Machines deployments |
RailwayProvisioner | provisioners/railway.ts | Railway services + databases |
LocalProcessProvisioner | provisioners/local-process.ts | Local shell processes |
CloudflareProvisioner | provisioners/cloudflare.ts | CF custom hostnames, DNS, Pages |
Use NeonProvisioner as the reference for a simple API-based provisioner, and FlyProvisioner for a multi-step provisioner that uses savePartialState.