wbsp.yaml Reference
wbsp.yaml is the single declarative file that tells the platform what to
deploy, where to place it, how to route traffic to it, and which
managed resources it needs. You write this file (and a wbsp.Docker); the
platform hides all the Kubernetes, RDS, and routing detail behind it.
This page is the single source of truth for the wbsp.yaml schema. Every
field the platform reads is documented here; other guides
(application-creator-guide.md,
wbsp-client-guidelines.md) walk through using
these fields and defer to this page for their meaning.
Top level
wbsp.yaml lives in the repository root (never under /target; with code
under /target, use source: target).
variant: <uuid> # required — your catalog variant UUID (from `wbsp whoami`); convention: just before `name`
name: <dns-safe-name> # required — [a-z0-9][a-z0-9-]*
image: <ref> # required (this OR source+dockerfile) — build tag / pull reference
source: <path> # optional — source dir (relative to repo root); triggers an ECR build on aws
dockerfile: <path> # optional — Dockerfile (relative to repo root)
destination: # required — one or more named placements (see Destinations)
<dest-name>: { … }
database: # optional — on-demand managed PostgreSQL
enabled: true|false # default false
shared_with: <app-name> # optional — share another app's database instead of provisioning one
redis: # optional — on-demand managed Redis
enabled: true|false # default false
durable: true|false # default false (cache mode); true = persistent store
s3: # optional — on-demand per-app S3 bucket (feature 061)
enabled: true|false # default false (not supported on the compose provider)
env: # optional — non-secret config (committed); override per destination
<KEY>: <value>
secrets: # optional — secret env vars BY REFERENCE (never values here)
<ENV_VAR>: <ENV_KEY_IN_DOTENV>
release: <command> # optional — one-shot migration/setup command (see Release)
release_timeout: <seconds> # optional — default 600, range 0..3600
# Single-component app: declare the one component's fields at the top level —
port: <int> # see the port contract under Components
use_declared_port: <bool> # optional — default false; make `aws` route to `port:` (see The port contract)
access: public | enclave | none # default none
command: <string> # optional — override the image entrypoint (shell-style quoting)
routes: { … } # required iff access: public (see Access & routes)
resources: # optional — scaling for the single component
replicas: <int> # default 1
components: # optional — multi-component app (omit for a single component)
<component-name>: { image|dockerfile, command?, port?, access?, replicas?|autoscale?, uses?, routes?, env?, secrets? }
ports: # optional — TCP ports this app EXPOSES to other apps (see Inter-app access)
- { name, port, access?, allow? }
uses: # optional — other apps' ports this app CONSUMES (see Inter-app access)
- { app, port, alias? }
gateways: # optional — authenticated gateways (see Gateways)
- { name: git|registry|ssh-git, enabled?, … }
health_check: # optional — health-probe path for `wbsp list --health` / `info`
path: /healthz
sample_data: # required for a demo placement (see Demo placements)
path: <dir>
timeout_minutes: <int> # default 10
projectNo: <0..999> # optional — local host-port derivation (see Local host ports)| Field | Type | Required | Description |
|---|---|---|---|
variant | string (UUID) | yes | Your catalog variant UUID — get it with wbsp whoami (or from your variant's page on wbsp.ai). Must be a canonical UUID (8-4-4-4-12). The platform derives your image repository from it (wbsp-v-<short-uuid>-…, the same repository git push uses) — this is the underlying ECR repository name; you push to it through the gateway address registry.wbsp.ai/<owner>/<repo>/app (see credential-less-deploy.md). Image-producing deploys are refused without it. Convention: place it immediately before name. Platform-internal (non-catalog) apps set non_variant: true instead (mutually exclusive with variant). |
name | string | yes | DNS-safe application name ([a-z0-9][a-z0-9-]*). |
image | string | yes¹ | Image reference / local build tag. |
source + dockerfile | string | yes¹ | Build the image from source (platform builds & pushes to ECR on aws). Paths are relative to the directory containing wbsp.yaml. On the local-machine types they are ignored — the image is used directly (build it yourself). |
destination | map | yes | One or more named placements; each declares its own tenant. |
database / redis / s3 | map | no | Opt in to a managed PostgreSQL / Redis / per-app S3 bucket (see Managed data stores). |
env | map | no | Non-secret config (committed). |
secrets | map | no | Secret env vars declared by reference (values live in .env.<destination>). |
release / release_timeout | string / int | no | One-shot per-deploy command, run before the workload starts (see Release). |
port / access / command / routes / resources | — | no | The single unnamed component's fields (see Components). |
components | map | no | Multi-component app: one entry per workload (see Components). |
ports / uses | list | no | Cross-app service ports exposed / consumed (see Inter-app access). |
gateways | list | no | Authenticated gateways: git, registry, ssh-git (see Gateways). |
health_check | map | no | path: probed by wbsp list --health / info instead of the route root — for apps that legitimately 404 at /. |
sample_data | map | for demo | Seed data + TTL for the reserved demo destination (see Demo placements). |
projectNo | int | no | Three-digit project number (0..999) used by the local-machine types to derive host-published ports (see Local host ports). |
¹ Provide either image or source+dockerfile.
Identity and isolation
The platform's identity taxonomy is cluster / tenant / enclave / app / component. The Kubernetes namespace for an app is
wbsp-<tenant>-<enclave>-<app>, and managed-resource names embed enough of the
taxonomy to stay globally unique (truncated + hashed within DNS/identifier
limits).
An enclave is an application-isolation group within a single cluster:
NetworkPolicies select peers by tenant + enclave, so by default only apps
sharing both can reach each other. The local-machine types relax these controls.
Destinations
A destination chooses where the app runs. Each key under destination: is a
placement name you choose (dev, stage, prod, …) and becomes the
--destination value at deploy time (optional when exactly one is declared).
A destination name may appear only once — a duplicate key fails the parse.
Three names are reserved: demo, sandbox, and universal. See
Reserved destinations — they declare none of the
identity fields below, but do take the settings ones (env, secrets,
release, …).
| Field | Type | Applies to | Description |
|---|---|---|---|
type | string | all | Required. compose, dev, standalone, or aws. Not permitted on a reserved destination. |
tenant | string | all | Required, DNS-safe. Declared per destination (a root-level tenant: is rejected); the same app may use different tenants in different placements. Not permitted on a reserved destination. |
mode | string | aws, compose | aws: normal (default), on-demand, gvisor, kata — demo is reserved to the demo and sandbox destinations. compose: demo (default) or on-demand, an unrelated setting. dev/standalone take no mode. Not permitted on a reserved destination. |
cluster | string | aws | Cluster this placement targets. Required for aws; rejected on the local-machine types. Not permitted on a reserved destination. |
enclave | string | aws | Application-isolation group within the cluster. Required for aws; accepted but informational on the local-machine types. Not permitted on a reserved destination. |
env | map | all | Per-destination env overrides, folded into the top-level env: with the destination winning on conflict. |
secrets | map | all | Per-destination secret-reference overrides (same precedence as env). |
gateways | list | aws | Per-destination gateway overrides — and the required operator opt-in for cloud-granting / L4 gateways (see Gateways). |
release | string | all | Per-placement override of the top-level release:. Unset ⇒ inherit; release: "" ⇒ disable for this placement (e.g. a dev destination reusing a shared DB). |
release_timeout | int | all | Per-placement override of the release timeout (0 ⇒ inherit). |
irsa_role_arn | string | aws | Platform-internal (see Platform-internal fields). |
cluster_role | string | aws | Platform-internal (see Platform-internal fields). |
The three local-machine types run on your own computer, expose the same
DATABASE_* / REDIS_* contract as aws, ignore routes, and host-publish
ports derived from your project number (see Local host ports):
dev— only the data services run in containers; you run the app from your IDE. The deploy writes a.envbeside the app with discreteDATABASE_*/REDIS_*values onlocalhost.compose— the whole stack (app + per-app PostgreSQL/Redis) runs in containers; production-parity on one machine.standalone— app + database + Redis inside one ephemeral container.
aws runs on the platform's EKS cluster. Its mode selects the shape:
normal (always-on Deployment), on-demand (AWS Lambda via the Lambda Web
Adapter — scale-to-zero, same RDS), demo (ephemeral per-user sample instance —
see Demo placements), gvisor / kata (sandboxed RuntimeClasses).
Components, access, and routes
A single-component app declares its one component's fields (image/dockerfile,
command, port, access, routes, resources.replicas) at the top level. A
multi-component app lists each workload under a components: map.
access:public(reachable from outside),enclave(itsportis reachable by other apps in the same enclave), ornone(internal / sibling-only, the default).routes: a map keyed by destination name, required iffaccess: publiconaws, and ignored on the local-machine types (there apubliccomponent is published directly on its port).command: overrides the image entrypoint. A single string, tokenized with shell-style quoting.
The port contract
By default your container must listen on port 8080. That is the platform's
ingress contract: on aws the single unnamed component is routed to container
port 8080 regardless of what port: says. The local-machine types
(compose, dev, standalone) DO use the declared port:, and named
components: entries are routed to their own declared port:.
access: public
port: 8080 # the platform's container-port contract
routes:
stage: { domain: my-app.staging.wbsp-demo.com }
prod: { domain: my-app.acme.com }use_declared_port: — route to the port you declared. Because port: is
load-bearing on a laptop destination and inert on aws, the same config can
mean two things. Set use_declared_port: true to make aws honour it too:
port: 3000
use_declared_port: true # aws routes to 3000 and injects PORT=3000- It is opt-in on purpose. Apps deployed before this existed listen on 8080
whatever they declared, so honouring
port:unconditionally would break them. - With the opt-in the platform also injects
PORT=<port>, so a$PORT-reading app (Next.js and most frameworks) binds where the platform routes. - It applies to the single unnamed component only — validation rejects it
alongside
components:, whose ports were always honoured. - It has no effect on the reserved
demodestination (demo/sandbox instances always getPORT=8080from the standalone runner). - Without it,
wbsp deployprints a warning naming the port traffic actually reaches, rather than accepting the field silently.
Route entries
Each value in the routes: map is one route:
| Field | Type | Default | Description |
|---|---|---|---|
domain | string | required | Fully qualified domain name. |
path_prefix | string | / | URL path prefix for sub-routing. Must start with /. |
strip_prefix | boolean | true | Strip the prefix before forwarding to your app — see path-prefixes.md. |
Multi-component apps (components:)
| Field | Type | Default | Description |
|---|---|---|---|
image or dockerfile | string | — | The component's image or how to build it. |
command | string | — | Entrypoint override (shell-style quoting). |
port | int | — | Container port this component serves on. |
access | string | none | public / enclave / none, as above. |
replicas | int | 1 | Fixed instance count (mutually exclusive with autoscale). |
autoscale | map | — | { min, max, targetCPU } (mutually exclusive with replicas). |
uses | list of strings | — | Peer references in convenience-addressing form (below). |
routes | map | — | Per-destination route map, as above. |
env / secrets | map | — | Per-component overrides. Effective env = destination-level merged with these, the component winning — and platform-injected DATABASE_*/REDIS_* beating both. Secret references resolve from the same .env.<destination> file. |
A component's uses: entry addresses a peer with a short hyphenated form,
expanded against the caller's own tenant and cluster (convenience addressing
never crosses tenants or clusters):
<enclave>-<app>-<component>— explicit enclave<app>-<component>— enclave defaults to the caller's<component>— a sibling component of the caller's own app
The platform resolves the peer and injects its host + port as environment variables.
Incremental today.
components:parses and validates, but only the single unnamed component is fully wired into the deploy path so far (the per-componentautoscaleis likewise not yet applied). Treat multi-component as forward-looking.
Managed data stores: PostgreSQL, Redis, and S3
Opt in with the top-level database: / redis: / s3: blocks; the platform
provisions them on deploy and injects connection details as environment
variables (DATABASE_* and REDIS_* — including REDIS_NAMESPACE and
REDIS_TLS). Build your client from these discrete variables; there is no
single DATABASE_URL/REDIS_URL, and you never write connection details into
your .env files. This contract is identical across every destination type —
read DATABASE_* / REDIS_* and your code needs no change between local and
cloud.
database:
enabled: true
# shared_with: app-one # share app-one's database instead of provisioning one
# extensions: [vector] # extensions your schema needs — the platform creates them
# before your migrations and sample data run
redis:
enabled: true # cache mode (default) — right for caching, sessions, retryable job queues
# durable: true # only when data must survive a node failure (persistent store)
# s3:
# enabled: true # optional per-app S3 bucket (feature 061; not on the compose provider)| Field | Type | Default | Description |
|---|---|---|---|
database.enabled | bool | false | Dedicated PostgreSQL database + per-app role; injects DATABASE_HOST/_PORT/_NAME/_USER/_PASSWORD (+ DATABASE_SSL on aws). |
database.shared_with | string | — | Use another app's database instead of provisioning one. Deploy the owning app first; this app receives the same DATABASE_* values. Both apps share one schema — design accordingly. |
database.extensions | list of string | — | PostgreSQL extensions the platform creates in your database before your app, its migrations, or its sample data run. Names match [a-z][a-z0-9_-]* (e.g. vector, pg_trgm, uuid-ossp). |
redis.enabled | bool | false | Managed Redis with a per-app ACL/namespace; injects REDIS_* (stay within REDIS_NAMESPACE). |
redis.durable | bool | false | false = cache mode (treat as ephemeral). true = persistent store for data that must survive a restart. |
s3.enabled | bool | false | Per-app S3 bucket + access role. Not supported on the compose provider. |
PostgreSQL extensions. The
vector(pgvector) extension and the standard contrib bundle (pg_trgm,hstore,uuid-ossp,pg_stat_statements, …) are available onaws(RDS), on thedev/composecontainers, and in the bundled engine behindstandalone,demoand sandbox.PostGIS is available too, but only if you declare it.
extensions: [postgis](or any ofpostgis_topology,postgis_raster,postgis_tiger_geocoder,address_standardizer) selects a spatial variant of the bundled engine forstandalone/demo/sandbox. The variant is a separate image because PostGIS brings GDAL, GEOS and PROJ with it, and every demo pod would otherwise pull a few hundred megabytes it has no use for. The first build on a machine takes a few minutes longer; after that it is cached like any other base.Order does not matter. Extensions are created with
CASCADE, so one that requires another (postgis_tiger_geocoderneedsfuzzystrmatch) brings its own prerequisites — you declare what your schema uses, not what those things need.Declare the ones your schema uses —
CREATE EXTENSIONis a superuser operation for most extensions, and your application's database role is not a superuser on any destination:database: enabled: true extensions: [vector]This matters most on
demo/sandbox/standalone, where your sample data is loaded as the application role:pg_dumpwritesCREATE EXTENSION IF NOT EXISTS <name>into the dump whenever the schema uses the extension's type, that statement is refused, the seed aborts, and the instance never becomes ready. Your migrations cannot fix it either — they run as the same unprivileged role, and ondemo/sandboxthey do not run at all (seerelease). Keep your ownCREATE EXTENSION IF NOT EXISTSif you have one: once the extension exists it is a no-op requiring no privilege.
release (one-shot migration/setup command)
The platform provisions an empty database. To apply your schema, declare a
release: command — the platform runs it once per deploy, in your app's own
image, with the same injected DATABASE_* / REDIS_* / secret env, after the
managed DB is provisioned and before the workload starts or any route is
flipped. This is the canonical way to run migrations/seed.
database:
enabled: true
release: "npx prisma migrate deploy" # or "node lib/migrate.js", "npm run db:migrate", …
release_timeout: 600 # optional; seconds, range 0..3600 (default 600)| Field | Type | Default | Description |
|---|---|---|---|
release | string | — | Command run once per deploy, before the workload starts and before any route flip. Tokenized with shell-style quoting (malformed quoting fails validation). |
release_timeout | int | 600 | Seconds before the release is killed and the deploy fails (range 0..3600; 0 ⇒ default). |
Semantics:
- Fail-closed and gating. A non-zero exit (or timeout) fails the deploy:
no Deployment is created/updated and no route changes, so on a redeploy the
previous good version keeps serving. A declared
release:is never silently skipped. - Make it idempotent + backward-compatible (expand/contract). The release
runs the new image's migrations while the old version may still be
serving during the rollout, so migrations must be additive and tolerated by the
old code.
prisma migrate deployalready skips already-applied migrations. - The image must contain your migration tooling. For a standalone Next.js
build, add the
prismaCLI +prisma/to the runner stage (the default trace may omit them). - Per-destination override / disable. Under
destination.<name>:setrelease:to a different command, orrelease: ""to disable it for that placement (e.g. adevdestination reusing a shared database). Unset ⇒ inherit the top-levelrelease:. - Where it runs:
aws→ a one-shot Kubernetes Job in the app's namespace under the app's ServiceAccount/IRSA;compose/standalone/dev→docker compose run --rm. demo/sandboxnever run it, and warn that they don't. A demo/sandbox instance is one ephemeral pod: its entrypoint starts the bundled Postgres, createsdatabase.extensions, loads the sample data, and execs the app — there is no step in which a migration could run. So an app whose schema exists only in migrations must ship that schema in its sample data (a fullpg_dump, not--data-only); otherwise the seed fails against tables nothing created. When a seed does fail, the instance log names thepg_dumpflag that would have prevented it —wbsp logsis where to look, since from outside the only symptom is a pod that never becomes ready. Declaringrelease:is still correct — theawsdestination uses it — it is simply inert on this lane, andwbsp deploy --dry-runsays so.universalis the exception, and accepts one. It deploys nothing, so nothing in the cloud runs the command — the appliance does, after pulling the image. Declaringrelease:underuniversal:is how you tell it what to run, and is the main reason the block is worth writing. See Reserved destinations.
Inter-app access: ports and uses
By default applications are network-isolated. To let other applications call your app, declare the ports you expose and who may reach each one. Access is controlled entirely by the provider — another application can never grant itself access to your service.
Expose a port (provider):
ports:
- name: auth # how consumers refer to it — DNS-safe, unique
port: 8080 # the TCP port your app listens on (1..65535)
access: tenant # tenant (default) | any | public
allow: # optional cross-tenant allow-list
- wbsp/ai-native-crm # a specific app: tenant/app
- wbsp/* # all apps in a tenant: tenant/*- A declared port is always reachable by other apps in your own tenant.
access: anyopens it to any tenant;access: publicto the Internet.allow:grants specific cross-tenant apps without opening to everyone.
Consume a port (consumer):
uses:
- app: test/headless-auth # provider tenant/app
port: auth # provider port name or number
alias: AUTH # optional env-var prefix ([A-Za-z][A-Za-z0-9_]*)On deploy the platform injects the provider's connection details as
<APP>_<PORT>_HOST / <APP>_<PORT>_PORT (or, with alias: AUTH, AUTH_HOST /
AUTH_PORT) — no hard-coded endpoints.
Rules to remember:
- You can't self-grant. A
uses:entry for a service you aren't authorized for gets you nothing — the provider must expose the port to your tenant, allow-list your app, or set it toany. - Same-tenant is open: apps in one tenant reach each other's declared ports
automatically; a
uses:entry just adds the injected connection env. - The platform controls reachability only. Authenticating the caller is your application's responsibility.
- No secrets go in
ports:/uses:or the injected connection details. - Enforcement is real on
aws(network policy); the local-machine types do not enforce it (open network) but inject the same connection env.
Authenticated gateways: gateways
A gateway puts an authenticated protocol front (backed by your app's own auth endpoint) in front of an upstream service. Three presets exist — a minimal declaration is just the name plus an enabled flag; the preset fills in the protocol, auth path, and upstream:
gateways:
- name: git # git over smart HTTP
enabled: true
- name: registry # Docker registry (fronts the platform image registry)
aws: { ecr: true }
- name: ssh-git # git over SSH (a raw-TCP front with its own public port)
destination:
prod:
type: aws
…
gateways:
- { name: registry, enabled: true } # per-destination operator opt-in
- { name: ssh-git, enabled: true }| Field | Type | Description |
|---|---|---|
name | string | Required. git, registry, or ssh-git. |
enabled | bool | Activation flag. See the opt-in rule below. |
protocol / forward_auth / key_lookup / upstream | — | Advanced overrides of the preset (auth-decision endpoint, upstream service/port/proxy/namespace; key_lookup is the SSH public-key→identity endpoint, ssh-git only). |
aws.ecr | bool | registry only — grants the app cloud (ECR) access and injects WBSP_ECR_*. |
- Gateways are provisioned on
awsonly (the local-machine types have no gateway provider; declarations are harmless no-ops there). - Opt-in rule: a gateway that grants cloud access (
registrywithaws.ecr: true) or opens a public TCP port (ssh-git) is activated only by anenabled: trueunderdestination.<name>.gateways— the top-level flag alone is never sufficient. Plain gateways (e.g.git) activate from the top-level flag; a per-destination flag overrides it. - The
ssh-githost key is stored in a Secret in the app's namespace and persists across redeploys; rotate it only via the CLI.
Reserved destinations
Three destination names are reserved: demo, sandbox, and universal.
They do not place your app in your tenant, cluster, or enclave, so the platform
supplies their whole identity and you declare none of it:
| Reserved name | What it does | type | mode | tenant | cluster | enclave |
|---|---|---|---|---|---|---|
demo | Publishes the promoted, publicly launchable demo | aws | demo | demo | main | demo |
sandbox | Publishes the pre-promotion sandbox ("Try in sandbox") | aws | demo | demo | main | demo |
universal | Publishes an appliance-runnable image and deploys nothing | universal | — | — | — | — |
Declaring any of those five identity keys is a hard error — including an empty value, and including a value that happens to match. Accepting a key the platform ignores would leave you believing your app is placed somewhere it is not.
Reserved means identity, not settings. Everything else on a destination is
still yours: env, secrets, release, release_timeout, and the rest.
destination:
demo:
# type, mode, tenant, cluster and enclave are supplied by the platform
env:
FEATURE_X: "on" # everything else still belongs to you
secrets:
API_KEY: MY_APP_API_KEYAn empty block is legal and is the normal shape for an app with no destination-specific configuration:
destination:
demo: {}
universal: {}Declaring the block is how an app opts in — the platform does not create a demo, a sandbox, or an appliance image for you.
mode: demo is exclusive to demo and sandbox on aws. An aws
destination named anything else may not claim it; rename it, or give it a
different mode. This does not affect compose, where mode: demo is an
unrelated setting (the production-parity shape, and its default).
If a command acts on a different destination, an illegal reserved block only produces a warning — your other deploys are never blocked by it.
sandbox borrows demo: when you have not declared it
This is the only case where a destination name does not resolve a block of
that name. wbsp deploy --destination sandbox uses:
- the
sandbox:block and.env.sandbox, if you have declared them; - otherwise the
demo:block and.env.demo.
So an app that publishes a demo today publishes a sandbox with no config change
at all. Declare sandbox: only when the sandbox should differ:
destination:
demo: {}
sandbox:
env:
BANNER: "Sandbox — data resets hourly"With neither block declared, a sandbox publish fails naming both options. The files never layer: whichever is found first is the only one read.
universal — publish for an appliance, deploy nothing
wbsp deploy --destination universal builds your app for both the cloud
architecture and Apple Silicon, publishes it, and stops. Nothing is deployed,
nothing runs, no database or route is provisioned, and no tenant is involved —
an appliance-only app need declare no cloud destination at all.
name: my-app
dockerfile: Dockerfile
release: "npx prisma migrate deploy"
destination:
universal: {}The universal: block is not ceremony: an appliance cannot read your
wbsp.yaml, so the platform stamps this destination's effective
configuration onto the image — most importantly release:, which is how the
appliance brings your database schema up to date before starting the app.
Override it when the appliance needs something different:
destination:
universal:
release: "node scripts/migrate-appliance.js"
env:
TELEMETRY: "off"Two consequences worth knowing:
- A cloud deploy publishes the cloud architecture only, moving the image tag
off any appliance index published earlier. Deploying to the cloud and
publishing for appliances are two commands, and the order matters — run
--destination universallast. A cloud deploy of an app that declaresuniversal:prints a reminder. universalaccepts arelease:declaration, unlike the serverless placements below. The appliance is precisely what runs it.
Publishing requires the credential-less route (wbsp login plus WBSP_API_URL);
--backdoor is refused. For the commands, the JSON result, and the
troubleshooting table see useful-commands.md and
credential-less-deploy.md.
Demo placements and sample_data
The reserved demo destination publishes a demo image: your app
unchanged, bundled with real in-pod PostgreSQL (and Redis if enabled), seeded
from your standard dump. Deploying it builds and pushes the image — nothing
runs until a demo is launched (wbsp demo launch or the wbsp.ai "Try the
demo" button), which starts a personal instance on a unique subdomain and reaps
it after the timeout. Inside a demo instance the platform sets
WBSP_SAMPLE_MODE. The full walkthrough lives in the
application-creator-guide.
| Field | Type | Default | Description |
|---|---|---|---|
sample_data.path | string | required | Directory of standard .sql seed files (generate with wbsp dump-data) baked into the demo image. |
sample_data.timeout_minutes | int | 10 | Auto-cleanup timeout (instance TTL). |
sample_data:
path: examples/sample-data/crm
timeout_minutes: 10Local host ports and projectNo
The local-machine types host-publish the app, PostgreSQL, and Redis on ports
derived from your project number: the first two digits of the service's
conventional port + the three-digit zero-padded project number (project 41 →
web 30041, Postgres 54041, Redis 63041). Declare the number with
projectNo: <0..999> at the top level, or omit it and the platform parses the
leading numeric prefix of the app directory's name. A derived value that would
exceed 65535 folds deterministically into the range [49152, 65535], and
host-port clashes are reported by the deploy's pre-flight. Details:
wbsp-client-guidelines.md §2.
Configuration tiers
Split configuration into three tiers (full rules in wbsp-client-guidelines.md §5):
- Non-secret config →
env:(committed); override per destination underdestination.<name>.env:. - Secrets → declared by reference in
secrets:; you supply the values in a git-ignored.env.<destination>. A declared secret no source supplies fails the deploy (fail-closed) — unless itsvalue_specsentry saysrequired: false, in which case it is omitted entirely with a warning (see the next section). - Platform-injected →
DATABASE_*/REDIS_*, provided automatically whendatabase.enabled/redis.enabledare set. On the demo/sandbox destination this also includes the shared demo auth client (AUTH_HAP_ISSUER,AUTH_HAP_ID,AUTH_HAP_SECRET,NEXT_PUBLIC_HAP_ENABLED) — never declare auth client secrets for a demo deploy (see wbsp-client-guidelines.md §6).
How .env files are found: each deploy reads exactly one file —
.env.<destination>, looked up beside wbsp.yaml (walking up parent
directories if needed). Destinations never layer: a stage deploy reads
.env.stage and nothing else. Real OS environment variables always win.
Where the deploy actually reads them from. Your .env.<destination> is
always where you supply a value, but it is not always where the deploy reads
it, and the difference matters when a deploy is refused:
| Destination type | The deploy reads |
|---|---|
compose, dev, standalone | Your .env.<destination>, directly — it runs on your machine |
aws, aws.demo, aws.on-demand | The values the platform holds for this installation. Your deploy records them there from your .env.<destination> first, so supplying them locally is still all you do |
universal | Nothing — it deploys nothing, so it resolves no values. It stamps your secret declarations onto the image's run manifest, and whoever runs the appliance supplies the values there |
The platform-side path never reads the environment of the platform's own servers, so a variable an operator sets there can never answer for your application.
Two practical consequences:
- A value you set once with
wbsp config setstays available to later deploys even if it is not in your.env— the platform is still holding it. - If a deploy is refused saying "the platform holds no value for X", that is
literally what happened: it looked at your installation and found nothing. Set
it with
wbsp config set, or supply the referenced variable locally and deploy again. Check withwbsp config status --destination <name>, whose verdict is computed from the values the deploy will read.
Value provenance: value_specs and auth (feature 086)
An image published for appliance installation carries its effective
configuration in the run-manifest label. value_specs tells an installer who
supplies each value, so a destination can mint what it must mint, inject what
it already knows, ask the installing person for the rest — and skip what is
optional — instead of refusing the whole application:
secrets:
ANTHROPIC_API_KEY: ANTHROPIC_API_KEY
AUTH_GOOGLE_ID: AUTH_GOOGLE_ID
AUTH_GOOGLE_SECRET: AUTH_GOOGLE_SECRET
AUTH_SECRET: AUTH_SECRET
ENCRYPTION_KEY: ENCRYPTION_KEY
value_specs:
ANTHROPIC_API_KEY: { source: operator, prompt: "Anthropic API key", docs: "https://console.anthropic.com" }
AUTH_GOOGLE_ID: { source: operator, required: false, group: google }
AUTH_GOOGLE_SECRET: { source: operator, required: false, group: google }
AUTH_SECRET: { source: generated }
ENCRYPTION_KEY: { source: generated, stable: true }
AUTH_URL: { source: destination, kind: appUrl } # standalone — see below
auth:
callbackPath: /api/auth/callback/hap
scopes: [openid, profile, email, offline_access] # omit ⇒ openid profile emailPer-entry fields (validated at publish; each refusal names the entry):
| Field | Applies to | Values / default | Meaning |
|---|---|---|---|
source | all (mandatory) | operator | generated | destination | Who supplies the value: a human at install time; the destination, minted; the destination, already known. |
required | all | default true | false ⇒ the app starts without it. An absent optional value is omitted entirely (never an empty string), with a warning. |
type | all | string (default) | int | float | bool | What the value parses as — lets a config surface validate PORT=yes at entry time, not app startup. |
prompt, docs | operator | — | The question an installer console asks, and where to get the value. |
group | operator | — | Optional values that only work together (a client id + secret): supplied or skipped as a unit. Members must all be required: false. |
stable | generated | default false | Mint once, persist, never re-mint — a destination that loses a stable value refuses to start the app rather than destroy what the value protected. |
bytes, encoding | generated | 32, base64 (hex/base64url allowed) | The exact shape to mint, so every destination mints interchangeable values. |
kind | destination (mandatory there) | appUrl | idpIssuer | idpClientId | idpClientSecret | Which destination-known value this is. appUrl is scheme+host+port — no path, no trailing slash. |
Rules worth knowing:
- A secret with no spec means
source: operator, required: true— exactly the pre-086 behaviour, so existing configs mean what they always meant. operatorspecs must be declared insecrets:(anywhere: top level, a destination, a component).generated/destinationspecs may stand alone — a minted client id or an assigned URL does not exist until install time. On managed destinations a standalone spec still resolves from your.env.<destination>by its own name.- A partially supplied group fails the deploy naming the group — a half-configured integration is broken, not degraded.
auth.callbackPath(must start with/) is the exact path the destination registers as the app's OIDC redirect URI. Without it an installer must guess, and a wrong guess fails at user-login time with OAuth's least helpful error.auth.scopesis the OIDC scope set the destination registers the client with. Omit it and you getopenid profile email— enough to sign in and nothing more. Addoffline_accessif your client library wants a refresh token (Auth.js/NextAuth request it by default): asking for a scope the client was not registered with makes the provider refuse the whole authorize request asinvalid_scope, at the last hop of sign-in, in a redirect nobody reads. The list is validated for shape only —openidmust be present, one scope per entry, no duplicates, no whitespace inside an entry — never for membership, so provider-specific scopes are fine. Declaring exactly the default set (in any order) publishes nothing and does not churn your image digest.wbsp deploy --dry-runprints the resolved set.- On destinations where scopes are create-only (HAP), widening the
declaration on an existing installation makes the platform re-register
the client: fresh
idpClientId/idpClientSecret(re-injected in the same pass), the existing callback set carried forward, and a warning naming the scope that forced it. Narrowing changes nothing.
- On destinations where scopes are create-only (HAP), widening the
declaration on an existing installation makes the platform re-register
the client: fresh
value_specsandauthare public metadata — names, shapes, prompts, never values — published in the image label to exactly the audience that can pull the image, so catalogs can say "installable — you will be asked for an Anthropic key" before anything is downloaded.- These declarations need a current CLI: an older
wbspbinary silently publishes a manifest without them (safe for consumers — they refuse as before — but your declarations go nowhere). Rebuildbin/after upgrading.
wbsp deploy --dry-run shows each declared value's provenance, requiredness,
type, and minting shape — what an installer will see. To supply the VALUES on
a platform (rather than from a local .env), and to check an installation is
completely configured, see
configuring-an-installation.md.
Platform-internal fields
These exist for the platform's own apps and operator workflows; ordinary applications never set them.
| Field | Description |
|---|---|
non_variant: true | Marks a platform-internal (non-catalog) app; exempts it from the variant requirement and keeps the wbsp-<name> image repository. Mutually exclusive with variant. |
destination.<n>.irsa_role_arn | Use a pre-provisioned IAM role for the pod's ServiceAccount instead of the per-app role the platform would create (the operator arranges the OIDC trust policy). |
destination.<n>.cluster_role | Bind the app's ServiceAccount to an existing cluster-scoped Kubernetes ClusterRole (binding wbsp-crb-<sa-name>, created on deploy / deleted on remove). |
Worked example: wbsp.yaml
One config, five destinations — dev, prod-parity, stage, prod, and
standalone. It declares no secret values: AUTH_CLIENT_SECRET is declared
by reference and supplied per destination in the .env files below.
variant: 7a1d8030-1234-4abc-8def-0123456789ab # from `wbsp whoami` — convention: just before `name`
name: my-app
source: ./
dockerfile: ./wbsp.Docker
destination:
dev: # local — run the app from your IDE
type: dev
tenant: acme
prod-parity: # local — full stack in containers, mirrors prod
type: compose
tenant: acme
standalone: # local — everything in one container
type: standalone
tenant: acme
stage: # aws — staging cluster
type: aws
mode: normal
tenant: acme
cluster: staging
enclave: my-app
prod: # aws — production cluster
type: aws
mode: normal
tenant: acme
cluster: main
enclave: my-app
database:
enabled: true
redis:
enabled: true
env:
LOG_LEVEL: info
secrets:
AUTH_CLIENT_SECRET: AUTH_CLIENT_SECRET # value lives only in .env.<destination>, never here
# Single unnamed component (top level):
port: 8080 # the platform's container-port contract
access: public
routes:
stage: { domain: my-app.staging.wbsp-demo.com }
prod: { domain: my-app.acme.com } # routes are ignored on the three local destinationsEnvironment files: one per destination
Every project following these guidelines provides, for each destination:
- a committed
.env.<destination>.exampletemplate — no secret values, except the shared test-HAP credentials on the local destinations (they are demo credentials, safe to share); and - a git-ignored
.env.<destination>that is as ready-to-run as possible — for the local destinations, fully populated (test HAP included) so the app runs with no manual edits.
Add .env* to .gitignore, and un-ignore the templates with !.env.*.example
(otherwise .env* would also ignore .env.dev.example). DATABASE_* / REDIS_*
are platform-injected — do not put them in these files.
Local destinations — pre-wired to the shared test HAP server
dev, prod-parity, and standalone all point at the shared test HAP server, so
sign-in works locally out of the box. The committed .example templates carry the
same values (they are demo credentials).
Any
localhostport works — on a standard callback path. The shared test HAP accepts a redirect URI on any port as long as the host islocalhost, so you are not tied to3000. The path, however, is matched exactly, and the registered set is the standard framework paths:/api/auth/callback/hap(Auth.js/NextAuth — it appends the provider id),/api/auth/callback(hand-rolled),/login/oauth2/code/hap(Spring),/signin-oidc(ASP.NET). SetAUTH_REDIRECT_URIto your framework's standard path on your port (e.g.http://localhost:5173/api/auth/callback/hap) — no HAP-side registration change is needed. A non-standard path (e.g./api/v1/auth/callback) fails withredirect_uri does not match a registered URI.
# .env.dev (git-ignored; commit .env.dev.example with these same values)
# Auth — shared WBSP test HAP server (demo credentials, safe to share)
AUTH_ISSUER=https://hap.wbsp.ai/t/wbsp
AUTH_CLIENT_ID=hap_lIT7x7SZ30e898mr
AUTH_CLIENT_SECRET=W243TiBRYILUrF0Nzt8stFj-5ZuYGqsT2iCO8gOamJ4
AUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback
LOG_LEVEL=debug# .env.prod-parity (type: compose — full stack locally)
AUTH_ISSUER=https://hap.wbsp.ai/t/wbsp
AUTH_CLIENT_ID=hap_lIT7x7SZ30e898mr
AUTH_CLIENT_SECRET=W243TiBRYILUrF0Nzt8stFj-5ZuYGqsT2iCO8gOamJ4
AUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback
LOG_LEVEL=info# .env.standalone (type: standalone — everything in one container)
AUTH_ISSUER=https://hap.wbsp.ai/t/wbsp
AUTH_CLIENT_ID=hap_lIT7x7SZ30e898mr
AUTH_CLIENT_SECRET=W243TiBRYILUrF0Nzt8stFj-5ZuYGqsT2iCO8gOamJ4
AUTH_REDIRECT_URI=http://localhost:3000/api/auth/callback
LOG_LEVEL=infoCloud destinations — real HAP, placeholders in the committed template
For stage and prod the committed .example uses placeholders; your operator
supplies the real AUTH_CLIENT_SECRET into the git-ignored .env.<destination>.
# .env.stage.example (committed — placeholders only, NO real secret)
AUTH_ISSUER=https://auth.staging.example.com/t/<tenant> # real staging HAP issuer (per-tenant path required)
AUTH_CLIENT_ID=REPLACE_WITH_STAGING_CLIENT_ID
AUTH_CLIENT_SECRET=REPLACE_WITH_STAGING_CLIENT_SECRET # operator supplies in .env.stage (git-ignored)
AUTH_REDIRECT_URI=https://my-app.staging.wbsp-demo.com/api/auth/callback
LOG_LEVEL=info# .env.prod.example (committed — placeholders only, NO real secret)
AUTH_ISSUER=https://auth.example.com/t/<tenant> # real production HAP issuer (per-tenant path required)
AUTH_CLIENT_ID=REPLACE_WITH_PROD_CLIENT_ID
AUTH_CLIENT_SECRET=REPLACE_WITH_PROD_CLIENT_SECRET # operator supplies in .env.prod (git-ignored)
AUTH_REDIRECT_URI=https://my-app.acme.com/api/auth/callback
LOG_LEVEL=warnUse your framework's standard callback path (/api/auth/callback for a
hand-rolled OIDC client; /api/auth/callback/hap for Auth.js/NextAuth, which
appends the provider id) — the .env examples above use /api/auth/callback; only
the host differs by destination. Register each destination's exact callback URL with
HAP (see the Authentication guidance) —
HAP matches redirect_uri by exact string. The demo/sandbox destination needs
no registration: the shared demo client already accepts the standard framework paths
under the *.wbsp-demo.com wildcard host (see the demo note in that guidance).
See also
- wbsp-client-guidelines.md — project conventions, secrets, auth, logging.
- configuring-an-installation.md —
wbsp config: supplying the VALUES for thevalue_specsdeclared here, when they cannot come from a.env. - application-creator-guide.md — the full deploy walkthrough and CLI reference.
- path-prefixes.md — routing under a path prefix.
- useful-commands.md — commands for
compose/dev/standalone,aws, and theuniversalappliance publish. - credential-less-deploy.md — deploying with only your platform login; the
universaldestination in depth.