WBSP Platform Architecture
Technical details. How the platform is structured, how its components interact, and how the same application runs across environments. You don't need this page to set up or run a platform: see the Platform Operator Guide.
Related: Application Creator Guide · Platform technical details · The platform model
Table of Contents
- Overview
- Design Principles
- Core Components
- Adaptor Architecture
- Request Flow
- Application Lifecycle
- Local-Machine Deployment
- AWS Environment
- Normal and On-demand Modes
- Environment Comparison
Overview
The WBSP platform deploys and manages multi-tenant web applications. Application creators provide a Docker image and a YAML config file; the platform handles everything else — ingress routing, database provisioning, container orchestration, and environment variable injection.
The platform exposes three interfaces:
wbsp-platformCLI — the operator binary. It provisions and destroys infrastructure, reports platform health, and inventories what is running. It is the only component that reads Terraform outputs.wbspCLI — the application creator binary. It deploys, inspects and removes applications, manages demos, and handles sign-in. It never reads Terraform outputs; for cloud destinations it calls the REST API, and for local-machine destinations it drives Docker directly.- REST API (
wbsp-api) — a Go HTTP server (Chi) running as an application on the cluster. It is the control plane: deploy, lifecycle, identity, and administration (plus, on wbsp.ai, demo launch). It is the default path for cloud deploys, and on wbsp.ai it is what the catalog website calls.
The same application code, the same config file, and the same CLI commands work across all environments. Only the --destination flag changes.
An application config declares a
destination:map (placement) pluscomponents(workloads), and the deploy flag is--destination. See wbsp-yaml-reference.md for the full field reference.
Design Principles
Adaptor-based abstraction
Every external dependency — cloud infrastructure, databases, ingress — is accessed through a Go interface. Each target environment provides its own implementation. Adding a new cloud provider means writing three adaptors (cloud, database, ingress) without touching the core deployment logic.
Spawn on demand
Platform services are only started when an application requires them. If no application declares a database, no database is provisioned. This keeps the local development environment lightweight and cloud costs low.
Multi-tenancy with isolation
Applications belong to tenants. Routing, database names, and container names are all scoped by tenant. Applications within a tenant cannot see or affect applications in another tenant.
Same config everywhere
A single wbsp.yaml file describes an application completely. The platform interprets it identically across environments. Developers test locally with a local-machine destination (type: dev / compose / standalone), then deploy to production with an aws destination — the same file, the same discrete DATABASE_*/REDIS_* contract everywhere.
Core Components
┌─────────────────────────────────────────────────────────────┐
│ wbsp CLI │
│ (and wbsp-api, which runs the same core) │
│ │
└────────────────────────┬────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌────────────┐
│ Platform │ │ Deployer │ │ Router │
│ Provisioner │ │ │ │ │
└──────┬──────┘ └────┬─────┘ └─────┬──────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────┐
│ Adaptor Interfaces │
│ cloud.P compute.P db.P ingress.P │
└───┬──────────┬────────┬──────────┬─────────────────┘
│ │ │ │
┌───┴───┐ ┌───┴───┐ ┌──┴───┐ ┌───┴─────┐
│local/ │ │local/ │ │local/│ │ local/ │
│ aws/ │ │ aws/ │ │ aws/ │ │ aws/ │
│ gcp/ │ │ gcp/ │ │ gcp/ │ │ gcp/ │
└───────┘ └───────┘ └──────┘ └─────────┘Platform Provisioner
Orchestrates the startup and teardown of the entire platform. Calls the cloud provider to create infrastructure, then verifies that the database service and ingress are healthy. Tracks platform state (healthy, degraded, stopped).
Deployer
Manages the application lifecycle: deploy, remove, and transitions between states. Provisions databases, starts containers, configures routes, and writes .env files. For type: dev destinations it brings up only the data-service containers and writes a .env next to the app — the app itself runs from the developer's IDE, so no app container or proxy is started.
Contains three key types:
- Deployer — the orchestrator that coordinates all operations
- Registry — in-memory store of all registered applications and their state
- Lifecycle — state machine defining valid status transitions (pending → deploying → running → removing → removed, plus the
devstate)
Router
Two-layer routing system:
- Resolver — in-memory route table that maps (domain, path) → application ID. Detects conflicts before they reach the ingress.
- Router — coordinates the resolver with the ingress adaptor. When a route is added, the resolver registers it and the ingress provider applies it.
REST API (wbsp-api)
A Chi-based HTTP server deployed onto the cluster as an ordinary WBSP application, with its own RDS database holding the users store, the deploy registry, and hosting records. It is the default path for cloud deploys (--backdoor is the bypass), and it also serves identity (/me) and administration (on wbsp.ai, also the demo-launch API the catalog website calls). Background loops run inside the process: the grant reconciler when capability grants are enabled, and on wbsp.ai the demo reaper.
Running it with WBSP_API_DEPLOYER=disabled (local mode, e.g. wbsp-vm) starts the same server without the AWS provider stack: identity, auth and admin work against Postgres alone, while the deploy/lifecycle routes answer 503 not_configured.
Metrics & Logging
Structured JSON logging via zerolog. Metrics hooks for tracking deployments, failures, and usage.
Adaptor Architecture
Every external dependency is reached through one of four adaptors, and each target environment supplies its own implementation of all four:
- Cloud provider: creates and removes the underlying infrastructure (on AWS, the network, cluster and database, through the two infrastructure layers).
- Compute provider: builds images and starts, stops, lists and reads the logs
of workloads. On AWS it creates Kubernetes Deployments, Services, Namespaces and
NetworkPolicies, or Lambda functions for on-demand applications. Images are
built for
linux/amd64. - Database provider: creates a database, user and credentials per
application inside the platform's database server. Creating is idempotent, and
on redeploy the password is re-synchronised, so a rotated credential heals
itself. On AWS, encrypted connections are required, and the application is told
so with
DATABASE_SSL=true. - Ingress provider: adds and removes routes, each mapping a (domain, path) pair to an application.
Shared databases. An application can use another application's database
through the shared_with field. The deployer looks up the primary application's
stored credentials and gives them to the dependent one, so the primary must be
deployed first.
Request Flow
Traffic reaches an application through Traefik, the platform's ingress controller, on the AWS environment. (The local-machine types do not use Traefik at all — see Local-Machine Deployment.)
Browser
│
▼
ALB / NLB the platform's single public entry point
│
▼
Traefik (EKS, `traefik` ns) matches Host (and any path prefix) against
│ the app's IngressRoute CRD
▼
┌─────────────────────┐
│ app Service → Pod │ forwarded to the container port
└─────────────────────┘ inside the app's own namespaceHow routing is applied
The deployer creates a Traefik IngressRoute CRD (plus any Middleware it needs — path
prefix stripping, rate limits, gateway ForwardAuth) in the application's namespace.
Traefik watches those CRDs cluster-wide and begins routing as soon as they appear.
Certificates are issued per host by the configured ACME resolver.
A route is registered in the platform's own resolver first, which detects a (domain, path) conflict before it reaches the ingress — so two applications cannot silently contend for the same address.
Deployments that register no route
A type: dev destination registers no route at all: only the data services run, and you
reach the application directly from your IDE.
Application Lifecycle
┌─────────┐
│ pending │
└────┬────┘
│
┌───────┴───────┐
▼ ▼
┌────────────┐ ┌─────────┐
│ deploying │ │ dev │◄──────────┐
└─────┬──────┘ └────┬────┘ │
│ │ │
▼ ┌────┴───────────┐ │
┌──────────┐ │ Can transition │ │
│ running │◄─────┤ to deploying │ │
└────┬─────┘ │ or removing │ │
│ └────────────────┘ │
│ │
├──── Can transition to dev ───────┘
│
▼
┌──────────┐ ┌──────────┐
│ removing │─────►│ removed │
└──────────┘ └──────────┘
┌──────────┐
│ failed │──── Can retry: deploying or dev
└──────────┘| Status | Meaning |
|---|---|
pending | Registered but not yet started |
deploying | Container and resources being provisioned |
running | Application is up, serving traffic from a container |
dev | Deployed to a type: dev destination — only data services run in containers; the app runs from the developer's IDE |
failed | Deployment or startup failed |
removing | Being torn down |
removed | Fully cleaned up |
Key transitions:
- running → dev:
devis a deployment type, not a runtime toggle. Deploying an app to atype: devdestination (wbsp deploy --destination <dev>) brings up only the data services (PostgreSQL/Redis) in containers and writes a.envnext to the app; the app itself runs from your IDE. There is no app container and no Traefik proxy. Database preserved. - dev → running: Deploy the app to a container-backed destination (
type: compose,standalone, or a cloudtypesuch asaws) withwbsp deploy. Database preserved. - failed → dev or failed → deploying: Retry from failure.
Local-Machine Deployment
There is no local platform. Feature 063 retired the shared local layer entirely: no local Traefik, no shared PostgreSQL container, no local
wbsp-api.wbsp-platform --provider localreturns an error. Running an application on a laptop is an application-creator activity that needs no operator involvement.
An app creator picks a local-machine destination type in wbsp.yaml and deploys it
with wbsp. Each type is self-contained — its own containers, its own host ports, no
shared infrastructure to provision or keep running.
| Type | What runs locally | Typical use |
|---|---|---|
compose | the whole app stack in containers (app + per-app PostgreSQL/Redis) | production parity |
dev | only the data services in containers; the app runs from the developer's IDE | live-reload development |
standalone | the app and its data services inside a single ephemeral container | throwaway instances; also the shape a demo image takes |
Architecture
┌──────────────────────── Docker (host) ─────────────────────────────┐
│ │
│ type: compose type: dev │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ app :30NNN→app │ │ (no app container) │ │
│ │ postgres :54NNN │ │ postgres :54NNN │ │
│ │ redis :63NNN │ │ redis :63NNN │ │
│ └───────────────────────┘ └───────────┬───────────┘ │
│ │ .env written │
│ type: standalone │ beside the app │
│ ┌───────────────────────┐ ▼ │
│ │ one container: │ your IDE runs the app │
│ │ app + postgres + redis│ │
│ └───────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘No Traefik and no ingress layer. Each type host-publishes its own ports directly;
nothing routes by Host header, so there are no /etc/hosts entries to maintain.
Ports
Host-published ports are derived from projectNo (declared at the top level of
wbsp.yaml, or parsed from the leading numeric prefix of the app directory's name), so
several projects can run side by side: the first two digits of the conventional port
followed by the three-digit, zero-padded project number.
| Service | Conventional | Project 041 | Published |
|---|---|---|---|
| Web app | 3000 | 041 | 30041 |
| PostgreSQL | 5432 | 041 | 54041 |
| Redis | 6379 | 041 | 63041 |
Only the host-published port changes; container-internal ports stay conventional. A
derived value above 65535 folds deterministically into the IANA dynamic range
[49152, 65535], and a host-port clash is reported by the deploy's pre-flight.
The environment contract is identical to AWS
All three types inject the same discrete DATABASE_* / REDIS_* variables an aws
destination does, so application code needs no change between a laptop and the cloud.
Local types simply resolve to localhost (or an in-stack service name) on the derived
ports — the app never hard-codes that, because it comes from the variables.
Database provisioning
Each app gets its own PostgreSQL container (or, for standalone, its own in-container
instance) rather than a database inside a shared server. Data persists across redeploys
and across wbsp stop, and is removed only by an explicit wbsp remove.
Discovery and reconciliation
There is no local registry to consult. wbsp list --provider compose (or
--target local) reconciles from the live Docker daemon, matching containers by
their labels:
wbsp.tenant=<tenant> wbsp.app=<name> wbsp.managed-by=wbsp-platformThe platform-managed types apply these automatically. A hand-written
docker-compose.yaml must apply the same pair to every service in the stack, or the
platform cannot distinguish it from any unrelated Compose project and it will not appear
in the list.
Constraints
- The local providers deploy one top-level component. A multi-component app must use
devor a cloud destination; there is an early-fail guard rather than a partial deploy. - No TLS, no autoscaling, no load balancing across instances.
- One-time host setup:
docker network create wbsp-shared, so acomposestack can reach HAP.
For the full workflow see the Application Creator Guide and useful-commands.md.
AWS Environment
Status: Fully implemented. All CLI commands work with an
awsdestination (type: aws).
Architecture
┌─────────────────────── AWS Region ────────────────────────────────┐
│ │
│ ┌─── VPC ──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─── Public Subnets ─────────────────────────────────────┐ │ │
│ │ │ ALB / NLB (load balancer) │ │ │
│ │ │ NAT Gateway │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─── Private Subnets ────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ ┌── EKS Cluster ──────────────────────────────────┐ │ │ │
│ │ │ │ ┌── traefik namespace ───────────────────────┐ │ │ │ │
│ │ │ │ │ Traefik (Helm chart, ingress controller) │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌── wbsp-<tenant>-<app> namespace ───────────┐ │ │ │ │
│ │ │ │ │ Deployment + Service │ │ │ │ │
│ │ │ │ │ NetworkPolicies (default-deny + allow) │ │ │ │ │
│ │ │ │ │ IngressRoute + Middleware CRDs │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌── ECR ──────────────────────────────────────────┐ │ │ │
│ │ │ │ wbsp-<app> repositories │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌── RDS ──────────────────────────────────────────┐ │ │ │
│ │ │ │ PostgreSQL (managed, multi-AZ) │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘What each local piece becomes on AWS
| Local component | AWS equivalent |
|---|---|
| Docker Compose | EKS (managed Kubernetes) |
docker run | Kubernetes Deployment + Service |
| Docker image (local) | ECR, through the registry gateway |
| PostgreSQL container | RDS PostgreSQL (managed) |
| Traefik (Docker labels) | Traefik IngressRoute resources |
Namespace Isolation Strategy
Each application gets its own Kubernetes namespace (wbsp-<tenant>-<app>). Within each namespace:
- default-deny NetworkPolicy — blocks all ingress and egress by default
- allow-required NetworkPolicy — permits only:
- Ingress from the
traefiknamespace (so Traefik can route traffic to the app) - Egress to DNS (UDP/TCP port 53) for service discovery
- Egress to RDS (TCP port 5432) for database access
- Ingress from the
Deleting a namespace cascades all resources within it (Deployment, Service, NetworkPolicies, IngressRoute CRDs).
Image Management
A deploy builds the application's image on the developer's machine. On the
ordinary path it pushes the image through the platform's registry gateway,
registry.<domain>, which files it per tenant and project
(<cluster>/<tenant>/<owner>/<repo>/<image>) after checking the caller holds
the deploy role in that tenant. A --backdoor deploy pushes straight to the
account's registry with the caller's own AWS credentials. Images are built
--platform linux/amd64.
Each image folder keeps the last ten images, and untagged images expire after
two weeks. Removing the platform with --delete-data removes the platform's
image folders.
Provisioning
wbsp-platform up (or its first stage, run provision) builds the platform in
two infrastructure layers: a persistent layer (network, database, registry,
load balancer, firewall and certificates) that survives a cluster rebuild, and a
cluster layer (Kubernetes, Traefik, the database relay, observability) that
is destroyed and rebuilt with the cluster. The full list is in
Platform technical details.
The tools read the infrastructure outputs afterwards, so operators never export cluster, database or registry details by hand. The first build takes 30–40 minutes end to end.
Database Provisioning on AWS
The RDS provider uses a Kubernetes-based approach to reach the RDS instance in private subnets. It runs SQL statements via a temporary postgres:16-alpine pod (kubectl run --rm -i) that connects to RDS over the VPC's internal network. This avoids exposing the database to the public internet.
The Create() method runs admin statements (CREATE DATABASE, CREATE USER, ALTER USER for password sync) against the admin database, then runs GRANT ALL ON SCHEMA public against the target database — required by PostgreSQL 15+ which revokes default CREATE privileges on the public schema.
Platform Destroy
wbsp-platform destroy makes the operator say what happens to the data:
--keep-dataremoves the cluster layer only. The database, registry, network, domain and certificates stay, anduprebuilds the cluster on top of them.--delete-dataremoves everything. The database is snapshotted first, and the command finishes by listing anything left in the account.
Differences from a local-machine deployment
| Aspect | Local-machine types | AWS |
|---|---|---|
| Container runtime | Docker on the developer's machine | Kubernetes pods on EKS |
| Image registry | the local Docker daemon | ECR, through the registry gateway |
| Isolation | one Compose project per app | per-app K8s namespace + NetworkPolicies |
| Database | a per-app PostgreSQL container | Amazon RDS (managed, backed up) |
| Redis | a per-app Redis container (durable: append-only file on its volume) | ElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instances |
| MemoryDB | a second cluster-mode Redis (stand-in) | Amazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: true |
| Ingress | none — ports published on the host | Traefik IngressRoute CRDs behind an ALB/NLB |
| TLS | none | ACME / ACM certificates |
| DNS | none needed (localhost:<derived port>) | operator-managed records to the load balancer |
| Scaling | single instance | Kubernetes HPA / node autoscaling |
| Secrets | .env.<destination> read at deploy | resolved into a protected Secret object |
| State | Terraform not involved | infrastructure state in an S3 bucket, per install, with locking |
Normal and On-demand Modes
An aws destination's mode: selects how the workload runs. Both modes share the
same platform infrastructure: one network, one cluster, one Traefik, one registry.
| Mode | Destination | Compute | Database | Lifetime |
|---|---|---|---|---|
normal (default) | type: aws | EKS pods | shared RDS | persistent |
on-demand | type: aws, mode: on-demand | Lambda, VPC-attached | shared RDS | persistent, scale-to-zero |
A third mode, demo, runs short-lived demo and sandbox instances of catalog
applications. It is a wbsp.ai facility and isn't set up on other platforms.
Lambda (mode: on-demand)
The Lambda compute provider implements the same
compute.Provider interface as the EKS path, so deploy, status, logs and remove
work identically.
| Interface method | Lambda operation |
|---|---|
Build() | build a container image with the Lambda Web Adapter, push to ECR |
Start() | create the function + its Function URL |
Stop() | delete the function, Function URL, and CloudWatch log group |
Logs() | read /aws/lambda/<function-name> |
List() | list functions tagged wbsp-* |
Status() | function state (Active, Pending, Failed) |
┌─── VPC ──────────────────────────────────────────────────────────┐
│ ┌── EKS ─────────────────────────────────────────────────────┐ │
│ │ Traefik: IngressRoute Host(`app.example.com`) │ │
│ │ → backend: the Function URL │ │
│ └────────────────────┬───────────────────────────────────────┘ │
│ ▼ │
│ ┌── Lambda (VPC-attached) ───────────────────────────────────┐ │
│ │ container image from ECR + Lambda Web Adapter │ │
│ │ Function URL (AuthType: NONE) │ │
│ │ ──► RDS PostgreSQL (private subnets) │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘The Web Adapter is appended to the application's Dockerfile at build time:
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.0 /lambda-adapter /opt/extensions/lambda-adapterIt translates Lambda invoke events into HTTP requests against the application's port, so
application code needs no change. The generated Dockerfile.lambda is built
--platform linux/amd64 and pushed to the same ECR registry the EKS path uses.
Routing goes through Traefik, not the Function URL directly. The deployer creates the function, obtains its Function URL, then creates an IngressRoute mapping the host to that URL as a backend. One ingress point serves every application regardless of compute backend, so domain routing, TLS termination and path-prefix middleware behave identically.
VPC attachment is what gives the function RDS access, using the same RDS provider as
mode: normal — the same managed PostgreSQL, SSL, per-app databases and credential
rotation. The trade-off is a few seconds of extra cold start.
Comparing the modes
| Aspect | normal (EKS) | on-demand (Lambda) |
|---|---|---|
| Compute | Kubernetes pods | Lambda, VPC-attached |
| Database | RDS PostgreSQL | RDS PostgreSQL |
| Redis | ElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instances | ElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instances |
| MemoryDB | Amazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: true | Amazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: true |
| Cold start | always warm | ~5–10s (VPC) |
| Max request duration | unlimited | 15 minutes |
| Data persistence | persistent | persistent (RDS) |
| Ingress | Traefik IngressRoute | Traefik → Function URL |
| Started by | wbsp deploy | wbsp deploy |
Environment Comparison
| Capability | Local-machine | AWS (normal) | AWS (on-demand) |
|---|---|---|---|
| Provisioning | none — self-contained | wbsp-platform up | the same AWS platform |
| Container runtime | Docker | Kubernetes (EKS) | Lambda, network-attached |
| Database | per-app Postgres container | RDS PostgreSQL | RDS PostgreSQL |
| Ingress | none (host ports) | Traefik | Traefik → Function URL |
| TLS | No | Yes | Yes |
| Autoscaling | No | Yes | Yes (automatic) |
| Cost | Free | $$$ | $ (scale-to-zero) |
| Data persistence | Persistent | Persistent | Persistent (RDS) |
| Started by | wbsp deploy | wbsp deploy | wbsp deploy |
The local-machine types (compose, dev, standalone) are developer-machine features:
they need no platform, no operator, and no credentials, and they deliver the same
DATABASE_* / REDIS_* environment contract as the cloud so application code is
unchanged between them.