ASTRODOCK documentation Home GitHub

Platform Design Spec

App manifest (app.json), environment-variable model, and the app-runner architecture for the open-source app platform.

Status: Draft v0.1 Audience: implementers & AI agents Working prefix: PLATFORM_
Naming The project is not yet named. Everywhere this spec writes 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

ResourceInternal (managed — easiest)External (BYO)None
DatabaseA database on the bundled Postgres, auto-provisioned + injectedUser-supplied connection string (Neon / Supabase / Atlas / RDS …)App has no database
Object storageA bucket/prefix on the bundled S3-compatible storeUser-supplied S3 / R2 / Spaces credentialsApp stores nothing
Authplatform — login backed by the control plane(n/a)public / app handles its own
Decided Bundled engine = Postgres (control plane + internal app DBs). Bundled object store = an S3-compatible service (leaning SeaweedFS, Apache-2.0). Compute = Hybrid: Node buildpack default + optional Dockerfile.

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

ServiceRole
caddyReverse proxy, automatic HTTPS, serves static frontends from the static volume, proxies /api/* and Docker-app subdomains.
apiControl plane: admin API, /verify, GitHub webhooks, health monitor, deploy orchestration. Talks to postgres.
runnerHas 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.)
postgresBundled database — the control-plane store and the internal per-app database option.
objectstoreBundled S3-compatible object storage — the internal per-app storage option.
Decided Node apps run as PM2 processes inside the runner; Dockerfile apps run as sibling containers built and started via a mounted /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

FieldTypeReqNotes
schemaVersionstringyesManifest schema version. Currently "1".
slugstringyesImmutable identity key. Pattern ^[a-z0-9-]+$. Names the PM2 process / container, the internal DB, the storage prefix.
namestringyesHuman display name.
descriptionstringnoShort description shown in the admin UI.
subdomainstringyesPattern ^[a-z0-9-]+$. App is served at https://<subdomain>.<base-domain>.
sourceobjectyesWhere code comes from. See below.
runtimeobjectyesHow the app is built and run. See below.
authobjectyesAuth mode. See below.
databaseobjectyesDatabase mode. See below.
storageobjectyesObject-storage mode. See below.
envarraynoApp-declared environment variables (names + metadata only). See §4.2.

source

FieldTypeDefaultNotes
branchstring"main"Branch deployed and watched for webhook pushes.
repoPathstring""Subdirectory in the repo to deploy from. "" = repo root.

runtime

FieldTypeNotes
typeenum"node" (buildpack — PM2 in runner) or "docker" (Dockerfile — sibling container).
buildCommandstringnode only. Frontend build command. Default "npm run build".
dockerfilestringdocker 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

Blockmode valuesEffect
authplatform · publicplatform injects PLATFORM_AUTH_URL/APP_ID/APP_SECRET/APP_JWT_SECRET and registers an app secret for /verify. public injects none.
databaseinternal · external · noneinternal: platform provisions a Postgres DB and generates PLATFORM_DATABASE_URL. external: PLATFORM_DATABASE_URL becomes a required user-supplied secret. none: nothing injected.
storageinternal · external · noneinternal: 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 slug is 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 --prune flag.
  • After reconciling app config, it connects the repo (creating the webhook) and provisions (DB/storage/Caddy) in one pass — no admin clicks.
Precedence The manifest is the source of truth for declared structural fields (routing, runtime, resource modes, env var declarations). The admin UI / CLI own secret values and ad-hoc, non-manifest overrides. On conflict for a declared field, the manifest wins on the next apply.

3.3 Change & immutability rules

FieldChange behavior
slugImmutable. It is the identity key; changing it means a new app.
subdomainMutable — triggers a Caddy re-provision + new TLS cert.
runtime.typeMutable — next deploy switches execution path (PM2 ⇄ container). Old process/container is torn down.
database.mode / storage.modeMutable 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.

  1. Reserved (PLATFORM_*) — managed by the platform, injected at deploy. The app reads these. Apps may never declare them.
  2. App-declared — declared in app.json env[], 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.

VariablePresent whenSourceMeaning
PLATFORM_APP_SLUGalwaysautoApp slug / identity.
PLATFORM_APP_NAMEalwaysautoDisplay name.
PLATFORM_APP_URLalwaysautoPublic URL https://<sub>.<domain>.
PLATFORM_BASE_DOMAINalwaysautoPlatform base domain.
PLATFORM_PORTalwaysautoThe port the server MUST bind.
PLATFORM_ENValwaysautoDeploy environment, e.g. production.
PLATFORM_DATABASE_URLdatabase ≠ noneauto (internal) / user-required (external)Full connection string. Same name in both modes.
PLATFORM_DATABASE_ENGINEdatabase ≠ noneautoe.g. postgres.
PLATFORM_STORAGE_ENDPOINTstorage ≠ noneauto / user-requiredS3 endpoint URL.
PLATFORM_STORAGE_REGIONstorage ≠ noneauto / user-requiredS3 region.
PLATFORM_STORAGE_BUCKETstorage ≠ noneauto / user-requiredBucket name.
PLATFORM_STORAGE_PREFIXstorage = internalautoApp's key prefix in the shared bucket.
PLATFORM_STORAGE_ACCESS_KEYstorage ≠ noneauto / user-requiredS3 access key.
PLATFORM_STORAGE_SECRET_KEYstorage ≠ noneauto / user-requiredS3 secret key (secret).
PLATFORM_AUTH_URLauth = platformautoControl-plane /verify base URL (internal service address).
PLATFORM_APP_IDauth = platformauto= slug, for /verify.
PLATFORM_APP_SECRETauth = platformautoApp secret for /verify (secret).
PLATFORM_APP_JWT_SECRETauth = platformautoSecret the app uses to sign its own sessions (secret).
Convenience alias Because 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.
Why one name for internal & external 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:

PropertyTypeMeaning
keystringVariable name. Must match ^[A-Z][A-Z0-9_]*$ and must not start with PLATFORM_.
secretbooleanIf true, the value is write-only — masked in the UI/API, set via set-secret, never stored in the manifest.
requiredbooleanIf true, a deploy is blocked until a value exists (see §4.3).
defaultstringNon-secret default value, applied if the operator sets nothing. Not allowed when secret: true.
descriptionstringShown 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 with required: true and no value/default, plus (b) every reserved variable that is user-required because a resource is in external mode (PLATFORM_DATABASE_URL for external DB; the PLATFORM_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 PORT alias. 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.

ConcernNode buildpackDockerfile
Buildnpm ci + buildCommand (frontend), npm ci --production (server)docker build the image, tagged app-<slug>:<commit>
RunPM2 process <slug> inside the runnerdocker run sibling container app-<slug> on the compose network
Static assetsfrontend dist/ rsynced to the static volume, served by Caddycontainer serves its own assets
RoutingCaddy: static + /api/*runner:PLATFORM_PORTCaddy: whole subdomain → app-<slug>:PLATFORM_PORT
SupervisionPM2 (restart on crash) + pm2 jlist statusDocker restart policy unless-stopped + docker inspect status
HealthHTTP 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 vs docker run); env injection uses the new reserved PLATFORM_* 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 inspect alongside pm2 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.