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)
FieldTypeRequiredDescription
variantstring (UUID)yesYour 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).
namestringyesDNS-safe application name ([a-z0-9][a-z0-9-]*).
imagestringyes¹Image reference / local build tag.
source + dockerfilestringyes¹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).
destinationmapyesOne or more named placements; each declares its own tenant.
database / redis / s3mapnoOpt in to a managed PostgreSQL / Redis / per-app S3 bucket (see Managed data stores).
envmapnoNon-secret config (committed).
secretsmapnoSecret env vars declared by reference (values live in .env.<destination>).
release / release_timeoutstring / intnoOne-shot per-deploy command, run before the workload starts (see Release).
port / access / command / routes / resourcesnoThe single unnamed component's fields (see Components).
componentsmapnoMulti-component app: one entry per workload (see Components).
ports / useslistnoCross-app service ports exposed / consumed (see Inter-app access).
gatewayslistnoAuthenticated gateways: git, registry, ssh-git (see Gateways).
health_checkmapnopath: probed by wbsp list --health / info instead of the route root — for apps that legitimately 404 at /.
sample_datamapfor demoSeed data + TTL for the reserved demo destination (see Demo placements).
projectNointnoThree-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, …).

FieldTypeApplies toDescription
typestringallRequired. compose, dev, standalone, or aws. Not permitted on a reserved destination.
tenantstringallRequired, 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.
modestringaws, composeaws: normal (default), on-demand, gvisor, katademo 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.
clusterstringawsCluster this placement targets. Required for aws; rejected on the local-machine types. Not permitted on a reserved destination.
enclavestringawsApplication-isolation group within the cluster. Required for aws; accepted but informational on the local-machine types. Not permitted on a reserved destination.
envmapallPer-destination env overrides, folded into the top-level env: with the destination winning on conflict.
secretsmapallPer-destination secret-reference overrides (same precedence as env).
gatewayslistawsPer-destination gateway overrides — and the required operator opt-in for cloud-granting / L4 gateways (see Gateways).
releasestringallPer-placement override of the top-level release:. Unset ⇒ inherit; release: ""disable for this placement (e.g. a dev destination reusing a shared DB).
release_timeoutintallPer-placement override of the release timeout (0 ⇒ inherit).
irsa_role_arnstringawsPlatform-internal (see Platform-internal fields).
cluster_rolestringawsPlatform-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 .env beside the app with discrete DATABASE_* / REDIS_* values on localhost.
  • 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 (its port is reachable by other apps in the same enclave), or none (internal / sibling-only, the default).
  • routes: a map keyed by destination name, required iff access: public on aws, and ignored on the local-machine types (there a public component 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 demo destination (demo/sandbox instances always get PORT=8080 from the standalone runner).
  • Without it, wbsp deploy prints 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:

FieldTypeDefaultDescription
domainstringrequiredFully qualified domain name.
path_prefixstring/URL path prefix for sub-routing. Must start with /.
strip_prefixbooleantrueStrip the prefix before forwarding to your app — see path-prefixes.md.

Multi-component apps (components:)

FieldTypeDefaultDescription
image or dockerfilestringThe component's image or how to build it.
commandstringEntrypoint override (shell-style quoting).
portintContainer port this component serves on.
accessstringnonepublic / enclave / none, as above.
replicasint1Fixed instance count (mutually exclusive with autoscale).
autoscalemap{ min, max, targetCPU } (mutually exclusive with replicas).
useslist of stringsPeer references in convenience-addressing form (below).
routesmapPer-destination route map, as above.
env / secretsmapPer-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-component autoscale is 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)
FieldTypeDefaultDescription
database.enabledboolfalseDedicated PostgreSQL database + per-app role; injects DATABASE_HOST/_PORT/_NAME/_USER/_PASSWORD (+ DATABASE_SSL on aws).
database.shared_withstringUse 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.extensionslist of stringPostgreSQL 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.enabledboolfalseManaged Redis with a per-app ACL/namespace; injects REDIS_* (stay within REDIS_NAMESPACE).
redis.durableboolfalsefalse = cache mode (treat as ephemeral). true = persistent store for data that must survive a restart.
s3.enabledboolfalsePer-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 on aws (RDS), on the dev/compose containers, and in the bundled engine behind standalone, demo and sandbox.

PostGIS is available too, but only if you declare it. extensions: [postgis] (or any of postgis_topology, postgis_raster, postgis_tiger_geocoder, address_standardizer) selects a spatial variant of the bundled engine for standalone/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_geocoder needs fuzzystrmatch) brings its own prerequisites — you declare what your schema uses, not what those things need.

Declare the ones your schema usesCREATE EXTENSION is 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_dump writes CREATE 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 on demo/sandbox they do not run at all (see release). Keep your own CREATE EXTENSION IF NOT EXISTS if 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)
FieldTypeDefaultDescription
releasestringCommand run once per deploy, before the workload starts and before any route flip. Tokenized with shell-style quoting (malformed quoting fails validation).
release_timeoutint600Seconds 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 deploy already skips already-applied migrations.
  • The image must contain your migration tooling. For a standalone Next.js build, add the prisma CLI + prisma/ to the runner stage (the default trace may omit them).
  • Per-destination override / disable. Under destination.<name>: set release: to a different command, or release: "" to disable it for that placement (e.g. a dev destination reusing a shared database). Unset ⇒ inherit the top-level release:.
  • Where it runs: aws → a one-shot Kubernetes Job in the app's namespace under the app's ServiceAccount/IRSA; compose / standalone / devdocker compose run --rm.
  • demo / sandbox never run it, and warn that they don't. A demo/sandbox instance is one ephemeral pod: its entrypoint starts the bundled Postgres, creates database.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 full pg_dump, not --data-only); otherwise the seed fails against tables nothing created. When a seed does fail, the instance log names the pg_dump flag that would have prevented it — wbsp logs is where to look, since from outside the only symptom is a pod that never becomes ready. Declaring release: is still correct — the aws destination uses it — it is simply inert on this lane, and wbsp deploy --dry-run says so.
  • universal is the exception, and accepts one. It deploys nothing, so nothing in the cloud runs the command — the appliance does, after pulling the image. Declaring release: under universal: 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: any opens it to any tenant; access: public to 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 to any.
  • 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 }
FieldTypeDescription
namestringRequired. git, registry, or ssh-git.
enabledboolActivation flag. See the opt-in rule below.
protocol / forward_auth / key_lookup / upstreamAdvanced 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.ecrboolregistry only — grants the app cloud (ECR) access and injects WBSP_ECR_*.
  • Gateways are provisioned on aws only (the local-machine types have no gateway provider; declarations are harmless no-ops there).
  • Opt-in rule: a gateway that grants cloud access (registry with aws.ecr: true) or opens a public TCP port (ssh-git) is activated only by an enabled: true under destination.<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-git host 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 nameWhat it doestypemodetenantclusterenclave
demoPublishes the promoted, publicly launchable demoawsdemodemomaindemo
sandboxPublishes the pre-promotion sandbox ("Try in sandbox")awsdemodemomaindemo
universalPublishes an appliance-runnable image and deploys nothinguniversal

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_KEY

An 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:

  1. the sandbox: block and .env.sandbox, if you have declared them;
  2. 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 universal last. A cloud deploy of an app that declares universal: prints a reminder.
  • universal accepts a release: 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.

FieldTypeDefaultDescription
sample_data.pathstringrequiredDirectory of standard .sql seed files (generate with wbsp dump-data) baked into the demo image.
sample_data.timeout_minutesint10Auto-cleanup timeout (instance TTL).
sample_data:
  path: examples/sample-data/crm
  timeout_minutes: 10

Local 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 configenv: (committed); override per destination under destination.<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 its value_specs entry says required: false, in which case it is omitted entirely with a warning (see the next section).
  • Platform-injectedDATABASE_* / REDIS_*, provided automatically when database.enabled / redis.enabled are 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 typeThe deploy reads
compose, dev, standaloneYour .env.<destination>, directly — it runs on your machine
aws, aws.demo, aws.on-demandThe 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
universalNothing — 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 set stays 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 with wbsp 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 email

Per-entry fields (validated at publish; each refusal names the entry):

FieldApplies toValues / defaultMeaning
sourceall (mandatory)operator | generated | destinationWho supplies the value: a human at install time; the destination, minted; the destination, already known.
requiredalldefault truefalse ⇒ the app starts without it. An absent optional value is omitted entirely (never an empty string), with a warning.
typeallstring (default) | int | float | boolWhat the value parses as — lets a config surface validate PORT=yes at entry time, not app startup.
prompt, docsoperatorThe question an installer console asks, and where to get the value.
groupoperatorOptional values that only work together (a client id + secret): supplied or skipped as a unit. Members must all be required: false.
stablegenerateddefault falseMint 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, encodinggenerated32, base64 (hex/base64url allowed)The exact shape to mint, so every destination mints interchangeable values.
kinddestination (mandatory there)appUrl | idpIssuer | idpClientId | idpClientSecretWhich 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.
  • operator specs must be declared in secrets: (anywhere: top level, a destination, a component). generated/destination specs 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.scopes is the OIDC scope set the destination registers the client with. Omit it and you get openid profile email — enough to sign in and nothing more. Add offline_access if 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 as invalid_scope, at the last hop of sign-in, in a redirect nobody reads. The list is validated for shape only — openid must 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-run prints 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.
  • value_specs and auth are 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 wbsp binary silently publishes a manifest without them (safe for consumers — they refuse as before — but your declarations go nowhere). Rebuild bin/ 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.

FieldDescription
non_variant: trueMarks 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_arnUse 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_roleBind 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 destinations

Environment files: one per destination

Every project following these guidelines provides, for each destination:

  1. a committed .env.<destination>.example template — no secret values, except the shared test-HAP credentials on the local destinations (they are demo credentials, safe to share); and
  2. 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 localhost port works — on a standard callback path. The shared test HAP accepts a redirect URI on any port as long as the host is localhost, so you are not tied to 3000. 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). Set AUTH_REDIRECT_URI to 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 with redirect_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=info

Cloud 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=warn

Use 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