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

  1. Overview
  2. Design Principles
  3. Core Components
  4. Adaptor Architecture
  5. Request Flow
  6. Application Lifecycle
  7. Local-Machine Deployment
  8. AWS Environment
  9. Normal and On-demand Modes
  10. 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-platform CLI — 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.
  • wbsp CLI — 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) plus components (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 dev state)

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 namespace

How 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
└──────────┘
StatusMeaning
pendingRegistered but not yet started
deployingContainer and resources being provisioned
runningApplication is up, serving traffic from a container
devDeployed to a type: dev destination — only data services run in containers; the app runs from the developer's IDE
failedDeployment or startup failed
removingBeing torn down
removedFully cleaned up

Key transitions:

  • running → dev: dev is a deployment type, not a runtime toggle. Deploying an app to a type: dev destination (wbsp deploy --destination <dev>) brings up only the data services (PostgreSQL/Redis) in containers and writes a .env next 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 cloud type such as aws) with wbsp 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 local returns 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.

TypeWhat runs locallyTypical use
composethe whole app stack in containers (app + per-app PostgreSQL/Redis)production parity
devonly the data services in containers; the app runs from the developer's IDElive-reload development
standalonethe app and its data services inside a single ephemeral containerthrowaway 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.

ServiceConventionalProject 041Published
Web app300004130041
PostgreSQL543204154041
Redis637904163041

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-platform

The 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 dev or 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 a compose stack 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 aws destination (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 componentAWS equivalent
Docker ComposeEKS (managed Kubernetes)
docker runKubernetes Deployment + Service
Docker image (local)ECR, through the registry gateway
PostgreSQL containerRDS 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 traefik namespace (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

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-data removes the cluster layer only. The database, registry, network, domain and certificates stay, and up rebuilds the cluster on top of them.
  • --delete-data removes everything. The database is snapshotted first, and the command finishes by listing anything left in the account.

Differences from a local-machine deployment

AspectLocal-machine typesAWS
Container runtimeDocker on the developer's machineKubernetes pods on EKS
Image registrythe local Docker daemonECR, through the registry gateway
Isolationone Compose project per appper-app K8s namespace + NetworkPolicies
Databasea per-app PostgreSQL containerAmazon RDS (managed, backed up)
Redisa per-app Redis container (durable: append-only file on its volume)ElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instances
MemoryDBa second cluster-mode Redis (stand-in)Amazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: true
Ingressnone — ports published on the hostTraefik IngressRoute CRDs behind an ALB/NLB
TLSnoneACME / ACM certificates
DNSnone needed (localhost:<derived port>)operator-managed records to the load balancer
Scalingsingle instanceKubernetes HPA / node autoscaling
Secrets.env.<destination> read at deployresolved into a protected Secret object
StateTerraform not involvedinfrastructure 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.

ModeDestinationComputeDatabaseLifetime
normal (default)type: awsEKS podsshared RDSpersistent
on-demandtype: aws, mode: on-demandLambda, VPC-attachedshared RDSpersistent, 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 methodLambda 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-adapter

It 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

Aspectnormal (EKS)on-demand (Lambda)
ComputeKubernetes podsLambda, VPC-attached
DatabaseRDS PostgreSQLRDS PostgreSQL
RedisElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instancesElastiCache: wbsp-<tenant> (cache) and wbsp-<tenant>-dur (durable), separate instances
MemoryDBAmazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: trueAmazon MemoryDB wbsp-<tenant>, only when an app declares memorydb.enabled: true
Cold startalways warm~5–10s (VPC)
Max request durationunlimited15 minutes
Data persistencepersistentpersistent (RDS)
IngressTraefik IngressRouteTraefik → Function URL
Started bywbsp deploywbsp deploy


Environment Comparison

CapabilityLocal-machineAWS (normal)AWS (on-demand)
Provisioningnone — self-containedwbsp-platform upthe same AWS platform
Container runtimeDockerKubernetes (EKS)Lambda, network-attached
Databaseper-app Postgres containerRDS PostgreSQLRDS PostgreSQL
Ingressnone (host ports)TraefikTraefik → Function URL
TLSNoYesYes
AutoscalingNoYesYes (automatic)
CostFree$$$$ (scale-to-zero)
Data persistencePersistentPersistentPersistent (RDS)
Started bywbsp deploywbsp deploywbsp 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.