Platform Design Spec
App manifest (app.json), environment-variable model, and the app-runner architecture for the open-source app platform.
PLATFORM_ (env prefix) or
platform (CLI name, package scope), substitute the chosen project name once decided —
the convention is <NAME>_ for env vars, mirroring Railway (RAILWAY_) and Fly (FLY_).
1. Overview & goals
The platform is a self-hosted control plane that deploys, hosts, authenticates, and monitors small apps on a single host. Its north star is "deploy once and don't worry about it": it runs with zero external dependencies by bundling its own database and object storage, and it is built so an AI coding agent can build an app and launch it on the open internet through a documented contract.
Design principles
- Opinionated box, pluggable slots. The control plane, routing, auth, and deploy orchestration are fixed. Database and object storage are per-app choices.
- The platform brokers connection strings; it never touches app data. This is why adding an "external" option is nearly free and why "internal" is just a provisioner.
- One artifact, three deployment modes: (1) local on your own box, (2) self-managed cloud VPS, (3) future managed SaaS as orchestrated single-tenant instances — never multi-tenancy inside the control plane.
- Apps read configuration from injected environment variables. Internal vs external is pure configuration; app code is identical either way.
Per-app resource choices
| Resource | Internal (managed — easiest) | External (BYO) | None |
|---|---|---|---|
| Database | A database on the bundled Postgres, auto-provisioned + injected | User-supplied connection string (Neon / Supabase / Atlas / RDS …) | App has no database |
| Object storage | A bucket/prefix on the bundled S3-compatible store | User-supplied S3 / R2 / Spaces credentials | App stores nothing |
| Auth | platform — login backed by the control plane | (n/a) | public / app handles its own |
2. Architecture topology
The platform ships as a docker compose stack. The same stack runs in all three deployment modes.
┌──────────────── host / VPS ────────────────┐
*.<base-domain> ─TLS──▶ │ caddy reverse proxy + auto-HTTPS │
│ │ serves static frontends (vol) │
│ │ proxies /api/* and Docker apps │
│ ▼ │
│ api control plane (Express) │
│ │ admin API · /verify · webhooks │
│ │ health monitor · orchestration │
│ ▼ │
│ runner clones, builds, runs apps │
│ ├─ PM2 ───▶ node app <slug> (buildpack) │
│ └─ docker ▶ app-<slug> container (Dockerfile) │
│ │
│ postgres bundled DB (CP + internal apps) │
│ objectstore bundled S3-compatible storage │
└───────────────────────────────────────────────┘
volumes: pgdata · objectdata · static (builds) · apps (server code) · repos (clone cache)
network: one bridge net; Caddy resolves app containers by name; runner reachable as "runner"
Services
| Service | Role |
|---|---|
caddy | Reverse proxy, automatic HTTPS, serves static frontends from the static volume, proxies /api/* and Docker-app subdomains. |
api | Control plane: admin API, /verify, GitHub webhooks, health monitor, deploy orchestration. Talks to postgres. |
runner | Has Node + PM2 + git + the Docker CLI. Runs Node buildpack apps as PM2 processes inside itself; builds & runs Dockerfile apps as sibling containers via the mounted Docker socket. (May be merged into api for a minimal deployment; kept separate to isolate the build/exec surface.) |
postgres | Bundled database — the control-plane store and the internal per-app database option. |
objectstore | Bundled S3-compatible object storage — the internal per-app storage option. |
/var/run/docker.sock. Socket access ≈ root on the host — accepted under the trusted-single-operator
threat model, and documented as a known privilege.
3. The app.json manifest
app.json lives at the root of an app's repo (or at repoPath). It is the single declared
source of truth shared by the AI agent (writes it), the CLI (platform apply reads it), the control-plane
API (provisions from it), the admin UI (displays/edits it), and the runner (build & runtime config).
Format is plain JSON (no comments) so any tool can read/write it unambiguously. It carries declared configuration and environment-variable names only — never secret values.
Complete example
{
"schemaVersion": "1",
"slug": "invoice-tool",
"name": "Invoice Tool",
"description": "Generate and store client invoices",
"subdomain": "invoices",
"source": {
"branch": "main",
"repoPath": ""
},
"runtime": {
"type": "node",
"buildCommand": "npm run build"
},
"auth": { "mode": "platform" },
"database":{ "mode": "internal" },
"storage": { "mode": "internal" },
"env": [
{ "key": "OPENAI_API_KEY", "secret": true, "required": true,
"description": "Used to draft line items" },
{ "key": "INVOICE_PREFIX", "secret": false, "required": false,
"default": "INV-", "description": "Prefix for invoice numbers" }
]
}
3.1 Field reference
Top level
| Field | Type | Req | Notes |
|---|---|---|---|
schemaVersion | string | yes | Manifest schema version. Currently "1". |
slug | string | yes | Immutable identity key. Pattern ^[a-z0-9-]+$. Names the PM2 process / container, the internal DB, the storage prefix. |
name | string | yes | Human display name. |
description | string | no | Short description shown in the admin UI. |
subdomain | string | yes | Pattern ^[a-z0-9-]+$. App is served at https://<subdomain>.<base-domain>. |
source | object | yes | Where code comes from. See below. |
runtime | object | yes | How the app is built and run. See below. |
auth | object | yes | Auth mode. See below. |
database | object | yes | Database mode. See below. |
storage | object | yes | Object-storage mode. See below. |
env | array | no | App-declared environment variables (names + metadata only). See §4.2. |
source
| Field | Type | Default | Notes |
|---|---|---|---|
branch | string | "main" | Branch deployed and watched for webhook pushes. |
repoPath | string | "" | Subdirectory in the repo to deploy from. "" = repo root. |
runtime
| Field | Type | Notes |
|---|---|---|
type | enum | "node" (buildpack — PM2 in runner) or "docker" (Dockerfile — sibling container). |
buildCommand | string | node only. Frontend build command. Default "npm run build". |
dockerfile | string | docker only. Path to the Dockerfile relative to repoPath. Default "Dockerfile". |
Node layout: app/ (frontend, builds to app/dist/) and/or server/ (Express, entry server.js, routes under /api/*). A standalone server.js = server-only; a standalone package.json with no server = frontend-only. Docker layout: the container serves the whole subdomain itself on PLATFORM_PORT.
auth / database / storage
| Block | mode values | Effect |
|---|---|---|
auth | platform · public | platform injects PLATFORM_AUTH_URL/APP_ID/APP_SECRET/APP_JWT_SECRET and registers an app secret for /verify. public injects none. |
database | internal · external · none | internal: platform provisions a Postgres DB and generates PLATFORM_DATABASE_URL. external: PLATFORM_DATABASE_URL becomes a required user-supplied secret. none: nothing injected. |
storage | internal · external · none | internal: platform provisions a bucket/prefix + scoped key, injects PLATFORM_STORAGE_*. external: the PLATFORM_STORAGE_* credentials become required user-supplied secrets. none: nothing injected. |
3.2 Apply semantics
platform apply reconciles the platform to match the manifest. It is additive and non-destructive:
- Creates the app if the
slugis new; updates declared structural fields otherwise. - Never overwrites or prints secret values (those are set out-of-band via
platform set-secret/ the admin UI). - Never deletes user-added env vars or platform state that the manifest doesn't mention, unless run with an explicit
--pruneflag. - After reconciling app config, it connects the repo (creating the webhook) and provisions (DB/storage/Caddy) in one pass — no admin clicks.
apply.
3.3 Change & immutability rules
| Field | Change behavior |
|---|---|
slug | Immutable. It is the identity key; changing it means a new app. |
subdomain | Mutable — triggers a Caddy re-provision + new TLS cert. |
runtime.type | Mutable — next deploy switches execution path (PM2 ⇄ container). Old process/container is torn down. |
database.mode / storage.mode | Mutable but non-migrating: switching points the app at a different (or empty) store. No data is copied. Documented as destructive-by-omission. |
3.4 JSON Schema
Validation schema (Draft 2020-12). The CLI and API validate against this; ship it so editors can too.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "app.json",
"type": "object",
"required": ["schemaVersion","slug","name","subdomain","source","runtime","auth","database","storage"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"slug": { "type": "string", "pattern": "^[a-z0-9-]+$", "maxLength": 40 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string" },
"subdomain": { "type": "string", "pattern": "^[a-z0-9-]+$", "maxLength": 40 },
"source": {
"type": "object", "additionalProperties": false,
"properties": {
"branch": { "type": "string", "default": "main" },
"repoPath": { "type": "string", "default": "" }
}
},
"runtime": {
"type": "object", "additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "enum": ["node","docker"] },
"buildCommand": { "type": "string" },
"dockerfile": { "type": "string" }
}
},
"auth": { "type": "object", "additionalProperties": false,
"properties": { "mode": { "enum": ["platform","public"] } } },
"database": { "type": "object", "additionalProperties": false,
"properties": { "mode": { "enum": ["internal","external","none"] } } },
"storage": { "type": "object", "additionalProperties": false,
"properties": { "mode": { "enum": ["internal","external","none"] } } },
"env": {
"type": "array",
"items": {
"type": "object", "additionalProperties": false,
"required": ["key"],
"properties": {
"key": { "type": "string", "pattern": "^(?!PLATFORM_)[A-Z][A-Z0-9_]*$" },
"secret": { "type": "boolean", "default": false },
"required": { "type": "boolean", "default": false },
"default": { "type": "string" },
"description":{ "type": "string" }
}
}
}
}
}
Note the env[].key pattern ^(?!PLATFORM_)… — the schema itself rejects any app-declared variable that uses the reserved prefix.
4. Environment-variable model
There are exactly two categories of environment variable. Keeping them strictly separate is what makes the contract unambiguous for an AI agent.
- Reserved (
PLATFORM_*) — managed by the platform, injected at deploy. The app reads these. Apps may never declare them. - App-declared — declared in
app.jsonenv[], values supplied out-of-band. The app's own config.
4.1 Reserved variables (PLATFORM_*)
Which reserved vars are injected depends on the resource modes. "auto" = generated by the platform; "user-required" = a value the operator must set before deploy.
| Variable | Present when | Source | Meaning |
|---|---|---|---|
PLATFORM_APP_SLUG | always | auto | App slug / identity. |
PLATFORM_APP_NAME | always | auto | Display name. |
PLATFORM_APP_URL | always | auto | Public URL https://<sub>.<domain>. |
PLATFORM_BASE_DOMAIN | always | auto | Platform base domain. |
PLATFORM_PORT | always | auto | The port the server MUST bind. |
PLATFORM_ENV | always | auto | Deploy environment, e.g. production. |
PLATFORM_DATABASE_URL | database ≠ none | auto (internal) / user-required (external) | Full connection string. Same name in both modes. |
PLATFORM_DATABASE_ENGINE | database ≠ none | auto | e.g. postgres. |
PLATFORM_STORAGE_ENDPOINT | storage ≠ none | auto / user-required | S3 endpoint URL. |
PLATFORM_STORAGE_REGION | storage ≠ none | auto / user-required | S3 region. |
PLATFORM_STORAGE_BUCKET | storage ≠ none | auto / user-required | Bucket name. |
PLATFORM_STORAGE_PREFIX | storage = internal | auto | App's key prefix in the shared bucket. |
PLATFORM_STORAGE_ACCESS_KEY | storage ≠ none | auto / user-required | S3 access key. |
PLATFORM_STORAGE_SECRET_KEY | storage ≠ none | auto / user-required | S3 secret key (secret). |
PLATFORM_AUTH_URL | auth = platform | auto | Control-plane /verify base URL (internal service address). |
PLATFORM_APP_ID | auth = platform | auto | = slug, for /verify. |
PLATFORM_APP_SECRET | auth = platform | auto | App secret for /verify (secret). |
PLATFORM_APP_JWT_SECRET | auth = platform | auto | Secret the app uses to sign its own sessions (secret). |
process.env.PORT is near-universal in Node hosting, the runner also exports PORT = PLATFORM_PORT.
This is the single documented unprefixed exception. App code should still prefer PLATFORM_PORT.
PLATFORM_DATABASE_URL (and the PLATFORM_STORAGE_* set) are identical names whether the resource is internal or
external. The app reads one variable and never knows the difference; the platform decides what it points at. Graduating
internal → external is a value change, not a code change.
4.2 App-declared variables
Declared in app.json env[]. Each entry:
| Property | Type | Meaning |
|---|---|---|
key | string | Variable name. Must match ^[A-Z][A-Z0-9_]*$ and must not start with PLATFORM_. |
secret | boolean | If true, the value is write-only — masked in the UI/API, set via set-secret, never stored in the manifest. |
required | boolean | If true, a deploy is blocked until a value exists (see §4.3). |
default | string | Non-secret default value, applied if the operator sets nothing. Not allowed when secret: true. |
description | string | Shown in the admin UI and to the agent. |
4.3 Rules & enforcement
- Reserved-prefix protection. Any app-declared key starting with
PLATFORM_is rejected at manifest validation and at the API. Apps cannot spoof or shadow platform variables. - Required-variable gate (deploy-blocking). Before a deploy starts, the platform computes the set of required variables = (a) every
env[]entry withrequired: trueand no value/default, plus (b) every reserved variable that is user-required because a resource is inexternalmode (PLATFORM_DATABASE_URLfor external DB; thePLATFORM_STORAGE_*credentials for external storage). If that set is non-empty, the deploy is rejected with a clear list of what's missing, and no clone/build/run happens. - Injection order. At deploy, the app's process/container env = reserved vars (computed from modes) + declared vars (defaults then operator-set values) + the
PORTalias. Auth/admin secrets of the platform itself are never injected into app processes. - Secrets never in the repo. The manifest declares secret names; values live only in the platform store and are written to the app's runtime env at deploy.
5. Resource provisioning (internal vs external)
Database
internal → create a database + scoped role on the bundled Postgres, compose a connection string, inject as PLATFORM_DATABASE_URL.
external → mark PLATFORM_DATABASE_URL user-required; inject the operator-set value; do nothing else.
none → inject nothing.
Object storage
internal → create/ensure a bucket + a per-app prefix + a scoped access key on the bundled store; inject PLATFORM_STORAGE_*.
external → mark PLATFORM_STORAGE_* user-required; inject operator-set values.
none → inject nothing.
This replaces the legacy connection-string-rewrite hack with a real provisioner per mode. Because apps speak the same S3 SDK and the same SQL connection string regardless of mode, no app code branches on internal vs external.
6. App runner
The runner owns clone → build → run for both compute paths, and reports progress to a Deployment record (status + streamed log) so deploys are observable by the admin UI and the agent CLI's deploy:watch.
| Concern | Node buildpack | Dockerfile |
|---|---|---|
| Build | npm ci + buildCommand (frontend), npm ci --production (server) | docker build the image, tagged app-<slug>:<commit> |
| Run | PM2 process <slug> inside the runner | docker run sibling container app-<slug> on the compose network |
| Static assets | frontend dist/ rsynced to the static volume, served by Caddy | container serves its own assets |
| Routing | Caddy: static + /api/* → runner:PLATFORM_PORT | Caddy: whole subdomain → app-<slug>:PLATFORM_PORT |
| Supervision | PM2 (restart on crash) + pm2 jlist status | Docker restart policy unless-stopped + docker inspect status |
| Health | HTTP probe to PLATFORM_PORT (any response = alive; /health 200 = healthy). Unified across both paths. | |
6.1 Deploy flows
Node buildpack
1. trigger (webhook push to source.branch, or `platform deploy`) → Deployment(pending)
2. clone/pull repo @ branch into repos volume; resolve deployRoot = repoPath || root
3. required-variable gate — abort if anything missing
4. detect app/ , server/ , or standalone layout
5. app/ → npm ci → buildCommand → rsync app/dist/ → static volume (/)
6. server/ → npm ci --production → copy to apps volume (/) → write env file
7. (re)start PM2 process "" bound to PLATFORM_PORT
8. Caddy already routes → static + /api/* → runner:PLATFORM_PORT
9. health probe → Deployment(success|failed) with full streamed log
Dockerfile
1. trigger → Deployment(pending)
2. clone/pull repo @ branch; resolve deployRoot
3. required-variable gate — abort if anything missing
4. docker build -f -t app-: deployRoot (via mounted socket)
5. docker rm -f old app-; docker run -d --name app- \
--network --restart unless-stopped \
--env-file app-:
6. Caddy routes → app-:PLATFORM_PORT
7. health probe → Deployment(success|failed) with build + run log
6.2 What changes vs today's deploy worker
- Reused: the clone/pull, layout detection, frontend build + static sync, server install, env-file write, PM2 start, deploy-record progress logging.
- Changed: the "start" step branches on
runtime.type(PM2 vsdocker run); env injection uses the new reservedPLATFORM_*set computed from resource modes; the required-variable gate runs before any work. - Added: a Docker build/run path via the socket; a container-aware branch in the health monitor (
docker inspectalongsidepm2 jlist); resource provisioners for internal DB + storage.
7. Sample apps (validation set)
The schema is "settled" when these four express cleanly and map onto the platform's app model.
A. Internal everything (the easy path)
{ "schemaVersion":"1", "slug":"notes", "name":"Notes", "subdomain":"notes",
"source":{"branch":"main"}, "runtime":{"type":"node"},
"auth":{"mode":"platform"}, "database":{"mode":"internal"}, "storage":{"mode":"internal"} }
B. External database + external storage (the heavier path)
{ "schemaVersion":"1", "slug":"crm", "name":"CRM", "subdomain":"crm",
"source":{"branch":"main"}, "runtime":{"type":"node","buildCommand":"npm run build"},
"auth":{"mode":"platform"}, "database":{"mode":"external"}, "storage":{"mode":"external"} }
// deploy blocked until PLATFORM_DATABASE_URL + PLATFORM_STORAGE_* are set via set-secret
C. Static-only, public (no server, no auth)
{ "schemaVersion":"1", "slug":"landing", "name":"Landing", "subdomain":"hello",
"source":{"branch":"main"}, "runtime":{"type":"node","buildCommand":"npm run build"},
"auth":{"mode":"public"}, "database":{"mode":"none"}, "storage":{"mode":"none"} }
D. Dockerfile app (any runtime)
{ "schemaVersion":"1", "slug":"py-tool", "name":"Python Tool", "subdomain":"pytool",
"source":{"branch":"main","repoPath":"service"},
"runtime":{"type":"docker","dockerfile":"Dockerfile"},
"auth":{"mode":"platform"}, "database":{"mode":"internal"}, "storage":{"mode":"none"},
"env":[{"key":"WORKERS","secret":false,"required":false,"default":"2"}] }
8. Open items
- Project name → fixes the env prefix (
PLATFORM_→<NAME>_), CLI name, package scope. - Internal backups → local-only vs optional off-box to an external object store.
- Custom domains per app (Caddy on-demand TLS) — additive to
subdomain. - Non-GitHub deploy — CLI push of a local build, no repo required.
- Resource limits per app (memory/CPU) — trivial for Docker apps, harder for PM2-in-runner.
- License for bundled components (Postgres permissive; object store — prefer Apache-2.0 SeaweedFS over AGPL MinIO).
Draft v0.1 — companion to OPEN_SOURCE.md (Phases 3 & 5). Decisions recorded here are settled unless listed in §8.