App runtime contract (rebuild v1)

This is the interface that the SDK and CLI, the control plane, the gateway and the portal all build against during the app rebuild. Anything not described here is an implementation detail of one side.

All of it is cloud-neutral. Cloud-specific authentication (managed identity, ARM) exists only inside control-plane provider adapters.

The types come from packages/platform:

  • src/app-config.ts: BackifyAppConfig, validateAppConfig
  • src/lockfile.ts: AppLockfile, validateLockfile, diffLockfiles

The C# mirror lives in packages/domain/AppLockfile.cs.

1. Build output (CI → control plane)

CI runs these steps in order:

  1. backify check: the app's typecheck, lint, test and build scripts, then backify lock in check mode; it fails if backify.lock.json is missing or stale.
  2. docker build.
  3. The verification gate.
  4. Push to the registry.
  5. One request:
POST /api/apps/{appId}/releases            (CI identity: GitHub OIDC → Backify)
Content-Type: multipart/form-data
  image      text   registry/repo@sha256:<64 hex>        immutable reference, required
  commit     text   40-hex source commit
  lockfile   file   backify.lock.json (≤ 2 MiB)
  migrations file   migrations.tar (db/migrations/*.sql, ≤ 10 MiB), optional
  frontend   file   frontend.tar.gz (built static files), web apps only, ≤ 100 MiB

The control plane responds 202 { releaseId, previewDeploymentId } and then:

  • validates the lockfile with the same rules as validateLockfile
  • checks that every migration file's SHA-256 matches lockfile.database.migrations
  • stores the frontend archive per release in the static asset store
  • starts the preview deployment

Promotion reuses the release unchanged: same image, lockfile, migrations and frontend.

How backify lock builds the lockfile (packages/cli/src/app/lock.ts)

  1. Loads and validates backify.config.ts (validateAppConfig).
  2. Bundles server.entry with esbuild (packages external, inline source maps).
  3. Runs the static declaration rules (analyzeAppDeclarations in packages/analyzer) over the bundled sources. These are errors: a declaration (defineBackend, defineJob, schedule, on, api.get/post/put/patch/delete) outside module top level, inside a branch or block, or with a computed name, path, cron or auth. A public POST/PUT/PATCH/DELETE route is a warning.
  4. Runs backify-run describe <bundle> with only PATH/home/temp variables, NODE_ENV=production and BACKIFY_PROCESS_ROLE=describe: no DATABASE_URL, secrets, workload token or broker URL. The last stdout line is the catalog (packages/sdk/src/app/catalog.ts, catalogVersion: 1): { sdkVersion, backend?: { title, health, routes, schemas, openapi }, jobs, schedules, subscriptions }.
  5. Derives capabilities: everything configuredCapabilities(config) returns, plus logs always, backend when a backend is declared, jobs when jobs exist, events for subscriptions or events imports, and database whenever jobs or events exist (they live in backify_runtime).
  6. Refuses to write the file when server code imports a capability that backify.config.ts does not configure (storage, ai, mail, http, integrations, secrets, database), a web/api app declares no backend, or a worker app declares a backend or no jobs.
  7. Hashes database.migrations (default db/migrations, *.sql, sorted) and writes the lockfile with serializeLockfile (sorted keys, LF, trailing newline). The output is byte-for-byte deterministic; CRLF checkouts still compare equal.

Worker apps never get the backend capability.

2. Process roles

One image runs as several roles. The entrypoint is backify-run <role>, provided by @backifydev/sdk.

Role Hosted as Behaviour
web Container App, HTTP ingress (internal only; reachable from the gateway), scale 0..N Serves the backend routes from server.entry on 0.0.0.0:$PORT (default 8080). Answers server.health (default /healthz) without touching dependencies.
worker Event-triggered Container Apps Job (or equivalent), one per app-environment and process role Claims due work from backify_runtime: queued jobs, due schedules, undelivered events. Runs it, then exits after BACKIFY_WORKER_IDLE_SECONDS (default 30) with nothing claimable.
migrate One-off job during a release Applies the backify_runtime schema, then the app's migrations, using the migration role. Exits 0 on success.
describe CI only, no credentials Imports the entry (backify-run describe <entry>) with declarations captured instead of started; prints the declaration catalog as one JSON line on stdout. Server code that connects to anything at import time fails here. Used by backify lock.

Trigger for the worker: the control plane polls (or configures the KEDA PostgreSQL scaler with) this query, using the inspection role:

SELECT backify_runtime.claimable_count();

claimable_count() counts jobs that are pending and available now, plus schedules that are due.

3. Environment variables (set by the platform, never by the app)

Variable Roles Meaning
BACKIFY_APP_ID, BACKIFY_ENVIRONMENT_ID, BACKIFY_ENVIRONMENT_KIND (production/preview/local) all Scope
BACKIFY_RELEASE_ID, BACKIFY_PROCESS_ROLE all Release and role
PORT web Listen port
DATABASE_URL web, worker Runtime role, sslmode=verify-full
BACKIFY_MIGRATION_DATABASE_URL migrate Migration role
BACKIFY_BROKER_URL web, worker Base URL of the Backify broker
BACKIFY_WORKLOAD_TOKEN web, worker Backify-issued JWT (section 4)
BACKIFY_USER_TRUST web JWKS JSON with the public keys that sign forwarded user tokens (section 6)
BACKIFY_OTEL_ENDPOINT, BACKIFY_OTEL_HEADERS all OTLP export target (optional)
BACKIFY_SECRET_<NAME> web, worker One variable per secret named in config.secrets
BACKIFY_CONFIG all JSON of effective non-secret config values
BACKIFY_ENTRY web, worker, describe Built server entry module inside the image (set by the Dockerfile; default dist/server/index.js)
BACKIFY_HEALTH_PATH web lockfile.backend.health (default /healthz)
BACKIFY_WORKER_PROCESS worker Process role whose jobs this worker runs (default worker)
BACKIFY_RUNTIME_ROLE, BACKIFY_INSPECT_ROLE migrate Role names that receive grants after migrations
BACKIFY_MIGRATIONS_DIR migrate Directory of the release's SQL migrations inside the job (default db/migrations)

The SDK reads only these variables. backify dev sets the same ones locally (packages/cli/src/app/dev.ts):

  • BACKIFY_ENVIRONMENT_KIND=local, app, environment and release IDs local;
  • DATABASE_URL and BACKIFY_MIGRATION_DATABASE_URL for a private cluster in .backify/dev/postgres with the roles app_migrate, app_runtime and app_inspect (same grants and connection limits as section 5, no TLS on loopback);
  • BACKIFY_BROKER_URL and BACKIFY_WORKLOAD_TOKEN for a local broker on the same port as the local gateway;
  • BACKIFY_USER_TRUST for a per-run development signing key;
  • BACKIFY_SECRET_<NAME> from .backify/secrets.local.json.

4. Workload token and broker

The workload token

  • An RS256 JWT issued by the control plane.
  • Header typ: backify-workload+jwt.
  • Claims: iss (https://api.backify.dev), sub (workload:{environmentId}:{role}), aud (urn:backify:broker), app, env, role, rel, exp (at most 24h), jti.
  • It is renewed through POST {BROKER}/v1/token/renew (returns { token, expiresAt }) when less than one third of its lifetime is left. Renewal is refused once the environment or release is retired.

Broker endpoints. All are Authorization: Bearer <workload token>, JSON in and out, and return errors as { error: { code, message, requestId } }.

Endpoint Request Response
POST /v1/storage/credentials { bucket, access: "read" | "write" } { provider: "azure-blob" | "s3" | "gcs", endpoint, container, prefix, credential, expiresAt }, valid for at most 1 hour. backify dev answers provider: "local" (a bearer credential for its own /storage endpoint); the cloud broker never does.
POST /v1/storage/sign { bucket, key, operation: "upload" | "download", contentType?, expiresInSeconds ≤ 3600 } { url, method, headers, expiresAt }
POST /v1/ai/{generate|extract|classify|embed} The existing AI request shapes The existing AI response shapes
POST /v1/mail/send The existing mail request shape { id }
POST /v1/http/fetch { url, method, headers, body }, checked against config.http.allow { status, headers, body }
POST /v1/integrations/{connection}/{operation} Operation input Operation output
POST /v1/token/renew {} { token, expiresAt }

Storage scoping

  • Keys inside a bucket are free-form (/-separated, at most 1024 characters).
  • The broker scopes every credential to {appId}/{environmentId}/{bucket}/ within the provider's storage, so apps can't reach each other's objects.
  • Public buckets are additionally readable through the gateway at /files/{bucket}/{key}.

5. Database

Server and database

  • One database per app-environment, named app_{appId}_{environmentKey}.
  • It lives on the pool server for the environment kind: production → prod pool, everything else → non-prod pool.

Roles (password authentication, SCRAM-SHA-256, TLS required):

Role Grants Used by
{db}_migrate Owns schemas public and backify_runtime; CONNECT The migrate role
{db}_runtime CONNECT; DML on public tables (default privileges from _migrate); USAGE + DML on backify_runtime tables; EXECUTE on its functions web, worker
{db}_inspect CONNECT; SELECT on public and backify_runtime; INSERT on backify_runtime.commands The control plane (portal views, KEDA query)

None of these roles has SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS or any role membership. ManagedPostgresIsolationPolicy enforces this.

Limits per role

  • CONNECTION LIMIT: runtime 10, migrate 2, inspect 3.
  • statement_timeout: 30s for runtime and inspect.
  • idle_in_transaction_session_timeout: 60s.

Password rotation: set the new password, roll out a new revision, then apply VALID UNTIL to the old one.

Who creates what

  • The control plane creates the database owned by {db}_migrate, creates the three login roles, revokes PUBLIC, and grants CONNECT to runtime and inspect.
  • migrate, running as the owner:
    1. revokes CREATE on public from PUBLIC
    2. installs backify_runtime
    3. applies the app's migrations, each in its own transaction, recorded in backify_runtime.migrations with its SHA-256; an edited, already-applied file fails the release
    4. grants runtime and inspect access, including default privileges for future tables, through runtimeGrants() in packages/sdk/src/app/runtime-schema.ts

The backify_runtime schema v1 is owned by the SDK (packages/sdk/src/app/runtime-schema.ts, verified against real PostgreSQL in runtime.postgres.test.ts) and applied by migrate before app migrations:

backify_runtime.schema_version(version int)                       -- single row, 1
backify_runtime.migrations(file text pk, sha256 text, release_id text, applied_at timestamptz)
backify_runtime.jobs(
  id uuid pk, name text, payload jsonb, status text               -- pending|running|completed|failed|cancelled
  , attempts int, max_attempts int, timeout_seconds int
  , available_at timestamptz, created_at timestamptz, started_at timestamptz, finished_at timestamptz
  , lease_expires_at timestamptz, last_error text                  -- ≤ 4000 chars, no stack traces with secrets
  , origin text                                                   -- api|schedule|event|portal
  , origin_ref text                                               -- schedule name / event id / user id
  , release_id text, idempotency_key text unique null)
backify_runtime.schedules(
  name text pk, cron text, timezone text, job text
  , paused boolean, cron_override text null
  , next_run_at timestamptz, last_run_at timestamptz)
backify_runtime.events(                                           -- transactional outbox
  id uuid pk, name text, payload jsonb, created_at timestamptz, dispatched_at timestamptz null)
backify_runtime.commands(                                         -- written by the control plane, consumed by the worker
  id uuid pk, kind text                                           -- run_job|retry_job|cancel_job|pause_schedule|resume_schedule|set_cron
  , target text, payload jsonb, requested_by text, created_at timestamptz, applied_at timestamptz null, result text null)
backify_runtime.claimable_count() returns bigint

Retention: the worker deletes completed and cancelled jobs after 30 days, and dispatched events after 7 days. Failed jobs are kept 90 days.

6. Gateway and identity

Route lookup

GET /internal/gateway/routes/{host}            (header X-Backify-Gateway-Key)
→ { kind: "app"|"offline"|"pending",
    appId, environmentId, releaseId,
    static: { prefix, spa } | null,                 // web apps
    origin: "https://<internal web FQDN>" | null,   // apps with a backend
    apiPrefix: "/api" | "/",                        // "/" for kind api apps
    publicBuckets: ["avatars"] }

Request handling

  • /.backify/auth/* → the control-plane sign-in broker (unchanged, host-scoped __Host- cookies).
  • /files/{bucket}/{key} → public bucket objects, using a signed download URL from the broker.
  • apiPrefix routes → origin.
  • Everything else → static files; with spa, unknown paths serve index.html.

Forwarded identity

  • When the request carries a valid app session cookie, the gateway adds X-Backify-User: <JWT>. It's an RS256 token, typ: backify-user+jwt, aud: urn:backify:application:{appId}:environment:{environmentId}, valid 5 minutes, with claims sub, email, name, roles, permissions. The gateway caches one per session.
  • The gateway always strips any client-supplied X-Backify-* header.
  • Apps verify the token against BACKIFY_USER_TRUST, never trusting the header's presence alone.
  • Web Container Apps accept traffic only from the gateway (internal ingress).

Protection

When the app's protection capability is resolved for the environment, kind: "app" routes carry a protection field (otherwise it's absent and the gateway applies nothing). The control plane resolves it from the active release's lockfile, the environment's portal overrides and organization/workspace locks, and changes version whenever any value changes (the gateway caches compiled policies by it). See Protection.

protection: {
  version: string, environmentKind: "preview" | "production",
  mode: "off" | "monitor" | "standard" | "strict",
  verifiedBots: "allow" | "block", aiCrawlers: "allow" | "block" | { [token | "*"]: "allow" | "block" },
  rateLimits: [{ id, path, methods?, limit: "60/min", source }],          // includes route-level ones
  challenges: [{ id, path, methods?, action, source }],
  ipRules:    [{ id, cidr, action: "allow" | "block", source }],          // source: code | portal | organization | workspace
  access: { mode: "monitor" | "enforce", allow: [cidr], exempt: [path], denied: "403" | "404", dynamicAllows: boolean } | null,
  dynamicRules: { version: integer } | null,                              // present when the release declares dynamicRules
  robots: "generated" | "app",                                            // app = the release ships its own robots.txt
  trustedProxy: "cloudflare" | null,                                      // per host
  page: { appName, title?, message?, contact?, custom: boolean },         // custom = release ships /.backify/blocked.html
  bypass: { sha256 } | null                                               // preview only; ignored for production
}

Gateway → app headers (added to every request proxied to origin, after stripping client X-Backify-*):

  • X-Backify-Protection: RS256 JWT, typ: backify-protection+jwt, the same aud as X-Backify-User, valid 60 seconds, claims class (human | verified-bot | ai-crawler | suspicious), challenge: { action, passedAt } | null and ip (the client address the gateway resolved). Signed with PROTECTION_SIGNING_KEY (kid PROTECTION_SIGNING_KEY_ID); the public key is published in the trust JWKS apps already receive. While the contract is unreleased, rename BACKIFY_USER_TRUST to BACKIFY_GATEWAY_TRUST, since it now covers both tokens.
  • X-Backify-Request-Id: the reference shown on blocked pages and stored with telemetry samples.

Gateway → control plane (header X-Backify-Gateway-Key):

  • GET /internal/gateway/protection-rules/{environmentId}?since={version} → { version, full, rules: [{ id, cidr, action, expiresAt }], removed: [id] }. Called when the route's dynamicRules.version is newer than the replica's copy.
  • POST /internal/gateway/protection-events every 30 seconds → { events: [{ appId, environmentId, hour, counts: [{ class, outcome, count }], top: { paths, userAgents, ips }, samples, ruleHits }] }. top.ips are HMAC hashes; samples (at most 20 per environment per flush) keep the plain IP for 7 days so an admin can allow or block it.

Broker (§4, workload token with the protection.rules.write scope, granted only when the lockfile declares dynamicRules): POST /broker/protection/rules { ip | cidr, action, ttlSeconds?, reason }, DELETE /broker/protection/rules { ip | cidr }, GET /broker/protection/rules.

Gateway endpoints on the app host: /.backify/protection/challenge, /.backify/protection/challenge.json and POST /.backify/protection/verify (sets __Host-backify_protection). The gateway strips __Host-backify_* cookies before proxying.

Client address: the gateway takes the entry TRUSTED_PROXY_HOPS places from the right of X-Forwarded-For (1 = Container Apps ingress only, the default; 2 while a platform Front Door is also in front), and only uses CF-Connecting-IP when trustedProxy is cloudflare and the connecting address is in Cloudflare's ranges. Local gateway. backify dev runs a gateway with the same routing and header rules (packages/cli/src/app/local-gateway.ts): development sign-in at /.backify/auth/login (any email, chosen roles and permissions), a host-only HttpOnly session cookie, a forwarded X-Backify-User token signed with the run's key, /files/{bucket}/{key} for public buckets only, /api (or everything for api apps) and the health path to the web role, and everything else to Vite.

7. Control-plane APIs for the portal

All take the app ID and environment and check app read access. Mutations need the matching manage permission and are audited.

Endpoint Returns / does
GET /api/apps/{id}/environments/{env}/backend { releaseId, health, routes: LockRoute[], schemas, scale: { web, worker }, status }
GET /api/apps/{id}/environments/{env}/backend/openapi.json The release's OpenAPI document
GET /api/apps/{id}/environments/{env}/jobs { jobs: [LockJob & { lastRun?, failures24h }], schedules: [LockSchedule & { paused, cronOverride, nextRunAt, lastRunAt }], subscriptions: LockSubscription[] }
GET /api/apps/{id}/environments/{env}/jobs/runs?job=&status=&cursor= Paged runs from backify_runtime.jobs (inspection role)
POST /api/apps/{id}/environments/{env}/jobs/commands { kind, target, payload? } → inserts into backify_runtime.commands; returns { commandId }
GET /api/apps/{id}/environments/{env}/database/tables and .../tables/{t}/rows?cursor= Schema, and rows (requires data.read, masks sensitiveColumns, at most 1000 rows, audited)

Realtime topics: backend and jobs (topic only, no data).