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,validateAppConfigsrc/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:
backify check: the app'stypecheck,lint,testandbuildscripts, thenbackify lockin check mode; it fails ifbackify.lock.jsonis missing or stale.docker build.- The verification gate.
- Push to the registry.
- 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)
- Loads and validates
backify.config.ts(validateAppConfig). - Bundles
server.entrywith esbuild (packages external, inline source maps). - Runs the static declaration rules (
analyzeAppDeclarationsinpackages/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. - Runs
backify-run describe <bundle>with onlyPATH/home/temp variables,NODE_ENV=productionandBACKIFY_PROCESS_ROLE=describe: noDATABASE_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 }. - Derives capabilities: everything
configuredCapabilities(config)returns, pluslogsalways,backendwhen a backend is declared,jobswhen jobs exist,eventsfor subscriptions oreventsimports, anddatabasewhenever jobs or events exist (they live inbackify_runtime). - Refuses to write the file when server code imports a capability that
backify.config.tsdoes not configure (storage, ai, mail, http, integrations, secrets, database), aweb/apiapp declares no backend, or aworkerapp declares a backend or no jobs. - Hashes
database.migrations(defaultdb/migrations,*.sql, sorted) and writes the lockfile withserializeLockfile(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 IDslocal;DATABASE_URLandBACKIFY_MIGRATION_DATABASE_URLfor a private cluster in.backify/dev/postgreswith the rolesapp_migrate,app_runtimeandapp_inspect(same grants and connection limits as section 5, no TLS on loopback);BACKIFY_BROKER_URLandBACKIFY_WORKLOAD_TOKENfor a local broker on the same port as the local gateway;BACKIFY_USER_TRUSTfor 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, revokesPUBLIC, and grantsCONNECTto runtime and inspect. migrate, running as the owner:- revokes
CREATEonpublicfromPUBLIC - installs
backify_runtime - applies the app's migrations, each in its own transaction, recorded in
backify_runtime.migrationswith its SHA-256; an edited, already-applied file fails the release - grants runtime and inspect access, including default privileges for future tables, through
runtimeGrants()inpackages/sdk/src/app/runtime-schema.ts
- revokes
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.apiPrefixroutes →origin.- Everything else → static files; with
spa, unknown paths serveindex.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 claimssub,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 sameaudasX-Backify-User, valid 60 seconds, claimsclass(human|verified-bot|ai-crawler|suspicious),challenge: { action, passedAt } | nullandip(the client address the gateway resolved). Signed withPROTECTION_SIGNING_KEY(kidPROTECTION_SIGNING_KEY_ID); the public key is published in the trust JWKS apps already receive. While the contract is unreleased, renameBACKIFY_USER_TRUSTtoBACKIFY_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'sdynamicRules.versionis newer than the replica's copy.POST /internal/gateway/protection-eventsevery 30 seconds →{ events: [{ appId, environmentId, hour, counts: [{ class, outcome, count }], top: { paths, userAgents, ips }, samples, ruleHits }] }.top.ipsare 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).