Self-Hosting

Environment Variables

Every environment variable Appstrate reads, with defaults and notes.

Primary source of truth: the @appstrate/env Zod schema (packages/env/src/index.ts). It wins on any conflict, and its key set is the one that is validated and fail-fast at boot. This table is a superset, and that claim is CHECKED for two of the three populations it covers and only asserted for the third: bun run verify:env-docs (in bun run check) fails when a schema key, or a key shipped in one of the .env.example files, has no row here. It had been asserted and false: measured at v1.0.0-beta.53 the table was missing two schema keys and seven .env.example keys, which is what motivated the gate. The superset covers two populations beyond the schema: vars read straight from process.env by the sidecar process, the agent container, and the MCP runner resolver, which @appstrate/env never sees; and vars validated by a MODULE's own Zod schema rather than the platform's (the two FIRECRACKER_RUNNER_* entries). Every such row is tagged [not in the Zod schema] in its Notes, and an untagged row is schema-validated. The gate does not reach the first of those two populations. A variable read straight from process.env is in no Zod schema, and unless it also happens to ship in a .env.example it is in no gated population either, so verify:env-docs structurally cannot notice that it has no row. That is not hypothetical: SIDECAR_API_CALL_CONCURRENCY was the one key of the 14 in SIDECAR_OPERATOR_ENV_KEYS (packages/runner-pi/src/container-env.ts) with no row here, sitting undetected beside four sidecar neighbours that had one, until it was added by hand. So: rows for process.env-only variables are held by review, not by CI. When you add a raw process.env read outside @appstrate/env, add its row in the same commit: nothing else will ask you to. What the gate deliberately does NOT require a row for is pure infrastructure credentials that the platform itself never reads (POSTGRES_*, MINIO_ROOT_*, AWS_*, consumed by the Postgres/MinIO containers and the AWS SDK); they are named in the gate's INFRA_ALLOWLIST with that reason. Nothing here is generated: the Notes column carries cross-field boot rules, egress semantics and failure behaviour that no Zod schema encodes, so the gate checks COMPLETENESS and leaves the prose to a human. Linked from AGENTS.md.

getEnv() from @appstrate/env (Zod-validated, cached after first call, fail-fast at startup). Key variables:

VariableRequiredDefaultNotes
MODULESNo"oidc,webhooks,mcp,core-providers,@appstrate/module-chat"Comma-separated module specifiers to load at boot. Default (OSS, API-key only): oidc + webhooks + mcp (platform MCP server) + core-providers (openai/anthropic/openai-compatible API keys) + @appstrate/module-chat (conversational chat over the platform). The two OAuth-subscription modules, @appstrate/module-codex (ChatGPT/Codex) + @appstrate/module-claude-code (Claude Pro/Max/Team), are OPT-IN (append to the list). Powering a product with a personal subscription is an operator-owned grey-zone: Anthropic's Agent SDK docs permit third-party claude.ai-login products only with prior approval, and the 2026 policy flipped repeatedly; OpenAI's stance is undocumented. See docs/architecture/SUBSCRIPTION_COMPLIANCE.md. Any other external npm specifier works for operator-installed providers. Removing a module is the only way to disable its model providers: there is no finer-grained per-providerId knob. MODULES=none boots with zero modules (MODULES= empty resolves to the default set, as the env getter coalesces empty to unset). @appstrate/module-ee (Stripe billing, credit quotas) is OPT-IN too, and source-available rather than Apache-2.0 (see packages/module-ee/LICENSE and its STRIPE_* rows below). It keeps its seven ee_* tables in the PLATFORM database (DATABASE_URL) under its own migration journal (drizzle.ee_migrations), so it needs PostgreSQL: it refuses to start on the tier-0 PGlite adapter. deploy/docker-compose.yml requires it (${MODULES:?}, no fallback to this default) and it must include @appstrate/module-ee there (see deploy/README.md).
MODULE_CONTRACT_ENFORCENofailBoot policy when a loaded module declares an @appstrate/core semver range that this platform's CORE_VERSION does not satisfy (the range in the module's own package.json). fail refuses to boot, naming the module and both versions; warn logs the mismatch and boots anyway. warn is the escape hatch for an operator running a module that has not been republished against the core major this platform ships: it accepts that a stale module can call a platform service whose signature moved under it and fail silently rather than loudly. An unknown/unresolvable range is always a warning (in-tree workspace:* modules are gated by tsc instead). Not to be confused with MODULE_CONTRACT_POLICY, the warn|fail|off knob of scripts/verify-module-contract.ts (contract SHAPE, dev/CI only: never read at boot, absent from the Zod schema). See issue #973
OIDC_INSTANCE_CLIENTSNo"[]"JSON array of instance-level OAuth clients (satellite admin dashboards, second-party web apps). Reconciled at boot by the oidc module: create-only with fail-on-drift. See apps/api/src/modules/oidc/README.md#instance-level-satellite-clients-via-oidc_instance_clients
PLATFORM_RUN_LIMITSNo"{}"JSON object capping EVERY run (classic + inline + scheduled). Keys: timeout_ceiling_seconds (1800), per_org_global_rate_per_min (200), max_concurrent_per_org (50), agent_memory_ceiling_mb (1536 MiB), agent_cpu_ceiling (2 vCPU). Validated strictly at boot by apps/api/src/services/run-limits.ts: unknown keys fail-fast. See Configuring agent resources
INLINE_RUN_LIMITSNo"{}"JSON object capping POST /api/runs/inline. Keys: rate_per_min (60), manifest_bytes (65536), prompt_bytes (200000), max_skills (20), retention_days (30). Full spec: apps/api/src/services/run-limits.ts (the strict Zod validator. There is no docs/specs/ directory in this repo)
LLM_PROXY_LIMITSNo"{}"JSON object capping /api/llm-proxy/*. Keys: rate_per_min (60), max_request_bytes (10 MiB). max_request_bytes also caps /internal/llm-proxy/* (a platform run's inference, exempt from API_BODY_LIMIT_BYTES); rate_per_min does not: that path uses the per-run-token /internal/* limiter. Validated strictly at boot by apps/api/src/services/proxy-limits.ts: unknown keys fail-fast
CHAT_PI_MAX_CONCURRENCYNo6Positive integer: operator cap on concurrent in-process chat sessions. EVERY chat turn runs in-process on the Pi engine, so this is the ceiling on concurrent chats per API process: the engine reserves a bounded slot per turn and 429s when at capacity. Set it from measured capacity before serving real traffic: the default of 6 is a conservative product value, not a sizing recommendation, and a process left at 6 refuses the 7th simultaneous chat. Absent keeps the default (and boot warns); invalid input fails boot. Validated by the module's own Zod schema (packages/module-chat/src/env.ts), not the @appstrate/env core schema [not in the Zod schema: read by @appstrate/module-chat]
CHAT_SELF_ORIGINNohttp://127.0.0.1:$PORTModule @appstrate/module-chat; validated by the module's own schema (packages/module-chat/src/env.ts). Origin of the in-process loopback calls (/api/models, /api/llm-proxy, /api/mcp). MUST be a loopback origin (127.0.0.1, localhost, ::1): this hop forwards the caller's cookie/Authorization, so any other host is refused at first use
CREDENTIAL_PROXY_LIMITSNo"{}"JSON object capping /api/credential-proxy/proxy. Keys: rate_per_min (100), max_request_bytes (10 MiB), max_response_bytes (50 MiB), session_ttl_seconds (3600). Same strict-Zod validation as LLM_PROXY_LIMITS
REDIS_URLNo-Redis connection string. When absent, falls back to in-memory adapters (single-instance only)
DATABASE_URLNo-PostgreSQL connection. When absent, falls back to PGlite (embedded PostgreSQL)
BETTER_AUTH_SECRETYes-Signs until BETTER_AUTH_SECRETS is set; then decrypts data written before the keyring
CONNECTION_ENCRYPTION_KEYYes-32 bytes, base64-encoded. Primary key used for new ciphertexts (v1 envelope: v1:<kid>:<base64(iv|authTag|ciphertext)>)
CONNECTION_ENCRYPTION_KEY_IDNok1Active kid embedded in newly-encrypted credential blobs. Must match /^[A-Za-z0-9_-]{1,32}$/. Stable across deploys; only flip when promoting a freshly-rotated key
CONNECTION_ENCRYPTION_KEYSNo"{}"JSON map { kid: base64-32B-key } of retired keys held for decrypt-only during a rotation window. Validated as Record<string, string> at boot. Rotation procedure: § "Rotating CONNECTION_ENCRYPTION_KEY" below
BETTER_AUTH_SECRETSNo-Auth secret keyring, Better Auth's format <version>:<secret>[,…], current secret first. The first secret encrypts/signs, every listed one decrypts/verifies (JWKS private keys, the cookies the platform signs). Rotate by prepending a new version + restart; any rotation invalidates what Better Auth signs with the current secret only: sessions, in-flight social sign-ins, email links. BETTER_AUTH_SECRET keeps decrypting data written before the list: leave it unchanged. To retire a version (or a leaked BETTER_AUTH_SECRET), delete the jwks rows: the next signature mints a key under the current secret; outstanding JWTs / CLI tokens stop verifying
UPLOAD_SIGNING_SECRETYes-Dedicated HMAC secret for FS upload-sink tokens. Comma-separated keyring (≥16 chars per key): FIRST key signs, ALL keys verify. Rotate by prepending the new key, then dropping the old one once issued tokens expire. Rotates independently of BETTER_AUTH_SECRET
CONNECT_SESSION_SECRETYes-Dedicated HMAC secret for hosted-connect-portal session tokens (issues #769/#905): the short-lived capability tokens behind the integration "Connect" button. Same comma-separated keyring rotation as UPLOAD_SIGNING_SECRET (≥16 chars per key). Rotates independently of the other signing secrets
CONNECT_SESSION_TTL_MSNo600000TTL for hosted-connect-portal session tokens, in milliseconds. Short by design: a connect token is a one-shot capability, not a session
AUTH_DISABLE_SIGNUPNofalseClosed-mode lock: blocks new account creation. 3 exceptions always pass: pending invitation, and AUTH_PLATFORM_ADMIN_EMAILS or AUTH_BOOTSTRAP_OWNER_EMAIL once ownership of the address is proven
AUTH_DISABLE_ORG_CREATIONNofalseRestricts POST /api/orgs to platform admins. Org-less users see "Waiting for invitation" instead of /onboarding/create
AUTH_PLATFORM_ADMIN_EMAILSNo""Comma-separated email allowlist of platform-level admins. Bypasses the two AUTHDISABLE\* gates. The account of a listed address is created only on proof of ownership (AUTH_BOOTSTRAP_TOKEN at /claim, a provider-verified social sign-in or a magic link), never by the email/password sign-up form; an account that already holds the address is not re-examined. Declarative: no UI, no migration, IaC-friendly
AUTH_ALLOWED_SIGNUP_DOMAINSNo""Comma-separated email domain allowlist (case-insensitive, no leading @). Validated at boot to reject malformed entries
AUTH_BOOTSTRAP_OWNER_EMAILNo""Names the account that owns the instance: the root org is created with it as owner when the account is. Idempotent (no-op if already an owner). The account is created only on proof of ownership (AUTH_BOOTSTRAP_TOKEN at /claim, a provider-verified social sign-in or a magic link), never by the email/password sign-up form. Never sent to the browser
AUTH_BOOTSTRAP_ORG_NAMENoDefaultDisplay name of the bootstrap org. Slug derived from the name
AUTH_BOOTSTRAP_TOKENNo""One-shot redemption token proving the person who creates the owner account is the operator (#344 Layer 2b). Generated by appstrate install on every closed install, written to .env, surfaced as AppConfig.features.bootstrapTokenPending. Operator claims ownership by POST'ing the token to /api/auth/bootstrap/redeem (UI: <APP_URL>/claim); with AUTH_BOOTSTRAP_OWNER_EMAIL set it claims that address only. Single-use; dead while any organization exists, even across restarts. Remove it from .env once claimed: it is redeemable again after a restart if every organization is deleted. Format: 22-128 base64url chars
AUTH_SESSION_COOKIE_CACHE_SECONDSNo0TTL of Better Auth's signed session_data cookie. 0 disables it (default): every authenticated request reads the session from Postgres. Above 0, saves that read but DELAYS revocation: a signed-out or revoked session keeps working for up to this many seconds (measured, incl. replayed pre-logout cookie jars, see apps/api/test/integration/auth/session-cookie-cache.test.ts)
AFPS_TRUST_ROOTNo"[]"JSON array of trusted publishers { keyId, publicKey, comment } for .afps-bundle Ed25519 verification
AFPS_SIGNATURE_POLICYNowarnoff (no verification) \
SIDECAR_MAX_REQUEST_BODY_BYTESNo10485760 (10 MiB)Sidecar inbound POST size cap, and the matching client-side check in the runtime resolvers. Hard ceiling 100 MiB; loud-fail at boot on invalid value, and "boot" is wider than it looks: the value is parsed exactly once, in @appstrate/afps-runtime/resolvers, at module init. A malformed or over-ceiling value therefore refuses to start EVERY process importing them (API host, firecracker runner daemon, CLI, agent container, sidecar), not just the run it would have capped [not in the Zod schema: read in any process importing the resolvers, not the sidecar alone; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_MAX_MCP_ENVELOPE_BYTESNo16777216 (16 MiB)MCP envelope cap, sized for base64 inflation. Structured 413 errors carry { reason, scope, limit, actual, envVar, hint } [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
APPSTRATE_MCP_TOOL_TIMEOUT_MSNo(MCP SDK default)Per-call MCP tool timeout, in ms (#779 annex). Forwarded into both the sidecar (integration clients) and the agent container (sidecar client) so one operator knob widens both legs of a tool call. Useful for third-party MCP servers doing a slow cold-start OAuth refresh on their first tool call [not in the Zod schema: read inside the sidecar + agent processes; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_INLINE_TOOL_OUTPUT_TOKENSNo8000Per-call inline cap, in ESTIMATED tokens (ceil(chars / 3.5)), on one {ns}__api_call / MCP text tool output. A body whose estimate exceeds it spills to the run-scoped blob store and the agent receives a resource_link instead of a text block (reason exceeds_inline_cap), with no truncation and no error: the agent reads the bytes on demand via resources/read. Binary bodies always spill. Must stay ≤ SIDECAR_RUN_TOOL_OUTPUT_BUDGET_TOKENS (TokenBudget's constructor throws otherwise), and a third guard can force a spill under the cap when the launcher forwards the model's context window. Parsed with a throw-on-invalid reader on the sidecar's boot path, which is why the platform-side forwarder DROPS a malformed value (with a warning) rather than killing every run [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_RUN_TOOL_OUTPUT_BUDGET_TOKENSNo100000Cumulative ceiling, in estimated tokens, on INLINE tool output for the whole run (one sidecar = one run). Once consumed + estimate would exceed it a response spills to the blob store (exceeds_run_budget) even though it fits under the per-call inline cap, so a long run progressively tightens toward spilling everything. Spilled content is not counted: the agent pays for it only if it reads it. Tightened from 200 K per #427. Must be ≥ SIDECAR_INLINE_TOOL_OUTPUT_TOKENS; same throw-on-invalid parse and forwarder-drop policy as its companion [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_API_CALL_CONCURRENCYNo3Maximum number of {ns}__api_call MCP invocations one run may have in flight at once (pLimit around the handler). Caps fan-out so a single agent turn cannot be stuffed with N parallel-fetched payloads at once (#427): the byte and token caps bound each response, this bounds how many arrive together. Excess calls queue rather than fail. Parsed by readPositiveIntEnv on the sidecar's boot path with no ceiling, so a non-positive or non-integer value THROWS and the sidecar never starts; the platform-side forwarder therefore drops a malformed value (with a warning) rather than killing every run, exactly as it does for the two token budgets [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_LLM_FIRST_RESPONSE_TIMEOUT_MSNo60000 (60 s)TTFB bound in milliseconds on one agent-run /llm/* upstream call: how long the provider may take to return response HEADERS before the sidecar aborts the fetch. Disarmed the instant headers land (an AbortSignal handed to fetch would tear the body down too), so everything after that belongs to SIDECAR_LLM_STREAM_IDLE_TIMEOUT_MS. At the limit the agent sees a generic 502, never the timeout text; pi-ai classifies that as retryable and retries the turn. Sits between the absolute LLM_PROXY_TIMEOUT_MS and the idle bound. Platform-side twin: LLM_PROXY_FIRST_RESPONSE_TIMEOUT_MS. Raise BOTH for a self-hosted baseUrl provider (Ollama / llama.cpp / vLLM) whose cold model load blocks headers for minutes. Read at module scope, so an invalid value refuses to start the sidecar at all [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
SIDECAR_LLM_STREAM_IDLE_TIMEOUT_MSNo120000 (120 s)Inter-chunk silence bound in milliseconds once an agent-run LLM stream is already flowing. The timer races only the PENDING read, so a slow consumer never trips it. Deliberately looser than the TTFB bound: extended thinking and large parallel tool-call payloads buy 15–45 s of legitimate silence. The two paths differ at the limit: /llm/* errors the response body, which the in-container client sees as a TRUNCATED stream (pi-ai treats that as retryable), while the pi-messages path emits a terminal error event with a synthetic 504. Exists because a mapped API shape (pi-messages) ignores pi-ai's own timeoutMs. The default is the shared DEFAULT_LLM_STREAM_IDLE_TIMEOUT_MS, the same constant the platform twin LLM_PROXY_STREAM_IDLE_TIMEOUT_MS reads [not in the Zod schema: read inside the sidecar process; forwarded from the platform host via SIDECAR_OPERATOR_ENV_KEYS]
TOOL_RESULT_BYTE_LIMITNo2048 (2 KiB)Per-tool-result byte cap applied by the RUNNER at write time, before the result reaches the event sink and run_logs, so whatever is cut is unrecoverable through GET /runs/{id}/logs. Strings are cut on a valid UTF-8 boundary and get a …(truncated, N bytes) suffix; other payloads are JSON-serialised and, on overflow, replaced by { __truncated: true, reason: "size", bytes, limit, preview }. Keep it well below 32768: the platform drops any run_logs.data payload over 32 KiB wholesale, which is worse than truncation. A non-positive or non-integer value fails boot. The CLI's -v output is capped separately at a hardcoded 2048 chars, so raising this does not widen it Validated by @appstrate/env, handed to the agent container by buildRuntimePiEnv and parsed there by runtime-pi/env.ts. A local appstrate run reads it from the shell instead, invalid values falling back to the default
MODEL_RETRY_ENABLEDNotruePi SDK retry on transient 429/5xx in the agent container (4 attempts, Retry-After honoured). false turns it off, which is worth it when an outer layer already retries (the sidecar's aliased /llm path does, and the two multiply). Handed to the container by buildRuntimePiEnv and parsed by runtime-pi/env.ts; a local appstrate run reads it from the shell (only false turns it off)
MODEL_COMPACTION_ENABLEDNotruePi SDK context compaction in the agent container. false turns it off, for deployments stacking their own compaction middleware (appstrate#445). Handed to the container by buildRuntimePiEnv and parsed by runtime-pi/env.ts; a local appstrate run reads it from the shell (only false turns it off)
REMOTE_RUN_SINK_DEFAULT_TTL_SECONDSNo7200Default sink TTL (CloudEvents HMAC ingestion) when caller doesn't request one
REMOTE_RUN_SINK_MAX_TTL_SECONDSNo86400Hard ceiling on caller-requested sink TTL
REMOTE_RUN_REPLAY_WINDOW_SECONDSNo600Redis dedup window for webhook-id replay detection (must exceed Standard Webhooks 5-min tolerance)
REMOTE_RUN_BUFFER_FLUSH_MSNo5000Out-of-order event buffer flush window. Terminal events (run.completed/failed/timeout/cancelled) flush immediately
RUN_WAIT_POLL_INTERVAL_MSNo2000Fallback DB-poll cadence (ms) for GET /runs/:id?wait= long-polls: the safety net when the realtime NOTIFY path doesn't deliver. Tuning knob; the test preload shrinks it to 50ms.
REMOTE_RUN_EVENT_LIMITSNo"{}"JSON object: per-run event-route rate limits. Parsed at route-build time; reboot to apply
RUN_HEARTBEAT_INTERVAL_SECONDSNo15Runner-side heartbeat cadence. Watchdog sweeps any open-sink row whose last heartbeat slipped past RUN_STALL_THRESHOLD_SECONDS
RUN_STALL_THRESHOLD_SECONDSNo60Watchdog stall threshold (backstop for hard runner crashes, since cooperative shutdown sends an explicit finalize). Rule of thumb: ≥ 3 × heartbeat interval
RUN_BOOT_DEADLINE_SECONDSNo300Provisioning budget: how long a run may take to reach its FIRST runner event (image pull, sandbox/container boot). Until then the platform attests liveness on the runner's behalf, so RUN_STALL_THRESHOLD_SECONDS cannot kill a still-provisioning run; past this ceiling the run fails with a "never started executing" error. Same split as a Kubernetes startupProbe vs livenessProbe. Also the boot grace added to the platform's safety-net run timer
RUN_WATCHDOG_INTERVAL_SECONDSNo15Watchdog sweep interval
EGRESS_ALLOW_INTERNAL_HOSTSNo(unset)Opt-in, comma-separated hostnames the operator explicitly trusts on private/internal addresses: exempts ONLY the SSRF host blocklist (never the redirect discipline) across every platform egress site. Full semantics in the note below the table. Unset ⇒ every internal host stays blocked (secure default)
OAUTH_REFRESH_WORKER_ENABLEDNofalseOpt-in BullMQ scan that proactively refreshes OAuth credentials whose token is in the lead window. The sidecar's 401-retry + on-demand token resolver cover correctness without it; enable when long dormant credentials would outlive their refresh_token upstream
INTEGRATION_REFRESH_MAX_FAILURESNo5OAuth: consecutive transient token-refresh failures (network / 5xx, not invalid_grant) before the connection is escalated to needs_reconnection, gated by INTEGRATION_REFRESH_GRACE_SECONDS so a still-valid token is never bricked; a successful refresh resets the count. Auths that cannot refresh (api_key, basic, custom, oauth2 without a refresh client): consecutive upstream 401 rejections, counted through the platform credential proxy, the sidecar and its MITM egress alike, however far apart; for a non-OAuth2 connection a successful (2xx) call through the credential resets the count, while an OAuth2 connection's count is cleared only by a reconnect, as is one a local MCP server builds through the dev.appstrate/credential: rejected tool-result meta (e.g. @appstrate/ssh), which has no success signal. An OAuth2 connection holding no refresh token is not counted: its first upstream 401 flags it (410), like a revoked refresh token. The count is a heuristic any caller allowed to use the connection moves both ways: a member proxying through a shared connection (/api/credential-proxy/proxy) or an agent through the sidecar can provoke consecutive 401s on an allowlisted endpoint and get the connection flagged needs_reconnection (its owner reconnects), and a 2xx from an allowlisted endpoint that ignores auth resets it. The same rule applies to an organization's BYOK model-provider API key (upstream 401s and 2xx through the LLM proxy); re-entering the key resets it
INTEGRATION_REFRESH_GRACE_SECONDSNo3600Escalation only fires once the token has been expired longer than this. Prevents a transient upstream outage on a not-yet-expired token from flagging the connection: escalation requires expired-past-grace AND the failure-count threshold
NODE_ENVNodevelopmentdevelopment \
API_BODY_LIMIT_BYTESNo10485760Global request body cap (Hono bodyLimit). Per-route caps still apply on top
S3_PUBLIC_ENDPOINTNo-Opt-out of proxy-upload mode (issue #829). Unset (default): proxy mode. Upload URLs point at APP_URL (PUT /api/uploads/_content) and the platform streams bytes to the bucket server-side, so S3/MinIO can stay fully private. Set: direct presign. Browsers PUT straight to this endpoint (must be publicly reachable, S3 API at the domain root, with bucket CORS allowing the descriptor headers including If-None-Match); offloads upload bytes from the platform for multi-node deployments
LEGAL_TERMS_URLNo-Footer link to terms (optional)
LEGAL_PRIVACY_URLNo-Footer link to privacy policy (optional)
PLATFORM_API_URLNo-How sidecars reach the platform. Leave unset in containerized deployments: the Docker orchestrator auto-detects the platform network and builds http://{platform-container-hostname}:{PORT} so credential traffic stays on the Docker bridge. Empty string is treated as unset. Falls back to http://host.docker.internal:{PORT} only when no Docker network is detected (local dev)
SYSTEM_PROXIESNo"[]"JSON array of system proxy definitions
PROXY_URLNo-Outbound HTTP proxy URL injected into sidecar containers
SYSTEM_PROVIDER_KEYSNo"[]"JSON array of system provider keys with nested models (credentials + model list per provider). A nested model entry MAY set "aliased": true to expose it as a model alias (LLM-gateway alias pattern, issue #727): the entry's id becomes a public vanity name and the real modelId/provider/endpoint stay server-side. An aliased entry MUST carry an explicit "label" (the derived label would name the backing). Misconfigured aliases are skipped (logged) at boot. A modelId outside its provider's offer (the provider's records in Pi's model registry) makes the API refuse to boot, naming the entry; gateways (openai-compatible, anthropic-compatible) and OpenRouter take any id. Every entry's provider must speak an API shape the platform LLM proxy serves (openai-completions, openai-responses, anthropic-messages, mistral-conversations), since runs reach system models through it, or the API refuses to boot, naming the entry. A UUID-shaped provider-key or model id also refuses boot: it would shadow an organization's own row. See docs/architecture/MODEL_ALIASES.md
MODEL_CATALOG_URLNohttps://get.appstrate.dev/model-catalogWhere the live model catalog is read from (http/https): a signed file, one per Pi SDK version (<url>/pi-<version>.json + .sig), listing the models a later Pi registry records and this build can serve, so a new model is selectable without a release. Each API process reads it in the background when it starts (boot never waits on it) and every hour, and holds it in memory: nothing is stored. Two anonymous GETs; nothing about the instance is sent. A file is refused on a bad signature, another Pi version, a serial lower than the one held or an unknown shape, and the one held stays. The signature keeps the channel from forging a file or rolling a running process back, not from withholding a newer one. It only adds models to what an organization can bind with its own credentials: bundled ids, system models (SYSTEM_PROVIDER_KEYS) and featured ids read the bundled registry. A model bound from the catalog runs without its catalog defaults (dialect, limits, price) while a process holds no file: just after a restart, or while the channel cannot be read. off disables the read (an empty value is unset, so it means this default): the instance runs on the bundled registry alone. See docs/architecture/MODEL_CATALOG.md.
SYSTEM_INTEGRATIONSNo"[]"JSON array of integrations the deployment OFFERS out of the box. Membership = the auto-active policy (on by default until an org opts out via a sticky space_packages.enabled = false). Each entry: { "id", "clients"?: [{ "id", "auth_key", "client_id", "client_secret"? }] }. clients is optional: an entry MAY ship one or more shared OAuth clients (the platform's own app, e.g. a verified Google app) so every org connects an auth without registering its own, or NO clients for remote MCP integrations that use Dynamic Client Registration (no static client_id; the client is provisioned on first connect). A custom client (BYO-app) overrides the system one: space client > org client > system client; the minting client is pinned per connection so refresh resolves the right credentials. Shared-quota caveat: a system client funnels every org's API calls through one upstream project quota (e.g. Google per-project Gmail quota). For quota-strict providers prefer per-org BYO-app, or gate behind per-user quota partitioning (follow-up: quotaUser). Validated strictly at boot: an invalid entry, a duplicate integration id, a duplicate client id (client ids are one global keyspace, the connection's client_ref), or a UUID-shaped client id (it would shadow a custom client's UUID) ABORTS BOOT instead of being skipped, because a silently dropped entry surfaces later as an unrelated "not installed" / "no OAuth client registered" error blaming space state. The message names the entry's index in the array, its id, the exact failing path and the offending nested client (secrets redacted). Per client, client_secret is REQUIRED and non-empty unless the entry declares token_endpoint_auth_method: "none" (a public client is declared, never inferred from a missing secret), and "none" must then carry no secret.
LOG_LEVELNoinfodebug\
OTEL_ENABLEDNofalseModule @appstrate/module-observability (must be in MODULES); validated by the module's own Zod schema (packages/module-observability/src/env.ts), not part of the core env schema. Force-enable OpenTelemetry without an explicit endpoint (falls back to the OTLP default http://localhost:4318). Telemetry is also enabled when OTEL_EXPORTER_OTLP_ENDPOINT is set. Disabled = complete no-op (zero spans/metrics/overhead). See docs/architecture/OBSERVABILITY.md [not in the Zod schema: read by @appstrate/module-observability]
OTEL_EXPORTER_OTLP_ENDPOINTNo-Module @appstrate/module-observability (must be in MODULES); validated by the module's own Zod schema (packages/module-observability/src/env.ts), not part of the core env schema. Base OTLP/HTTP collector endpoint (e.g. http://otel-collector:4318); the signal path (/v1/traces, /v1/metrics) is appended per the OTLP spec. Setting it enables telemetry. Standard OTLP env vars (OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_PROTOCOL, …) are honored by the exporters directly [not in the Zod schema: read by @appstrate/module-observability / the OTLP exporters]
OTEL_SERVICE_NAMENoappstrate-apiModule @appstrate/module-observability (must be in MODULES); validated by the module's own Zod schema (packages/module-observability/src/env.ts), not part of the core env schema. service.name resource attribute attached to every span + metric [not in the Zod schema: read by @appstrate/module-observability]
OTEL_TRUST_INCOMING_TRACENofalseModule @appstrate/module-observability (must be in MODULES); validated by the module's own Zod schema (packages/module-observability/src/env.ts), not part of the core env schema. Trust the inbound W3C traceparent header for server-span parenting. Default off: a public-facing API must not let an unauthenticated caller splice the server span into an attacker-chosen trace. When off, a fresh root span is started (a SERVER span is still emitted). Enable only behind a trusted gateway that controls traceparent for external callers [not in the Zod schema: read by @appstrate/module-observability]
EE_RECONCILIATION_INTERVAL_SECONDSNo300Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Cadence of the billing sweep over the platform's llm_usage ledger, in seconds. 0 pauses METERING only: the module's periodic tick keeps running every 300 s to retry the Stripe cancellations a failed org deletion left pending, which would otherwise keep charging customers for deleted organizations. [not in the Zod schema: read by @appstrate/module-ee]
EE_RECONCILIATION_BATCH_SIZENo100Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Maximum NEW ledger rows one sweep pass bills. The pass reads EE_RECONCILIATION_REPLAY_WINDOW rows on top of it, and the platform's usage.list ceiling caps that read at 1000, so the module refuses to boot when the two together exceed 1000: above it the forward slice would silently shrink below the batch size, stalling the sweeper's within-tick drain loop. [not in the Zod schema: read by @appstrate/module-ee]
EE_RECONCILIATION_REPLAY_WINDOWNo200Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. How far below the watermark each pass re-reads, so a row that commits after a higher id is still billed. 0 disables replay and restores the silent-loss window. [not in the Zod schema: read by @appstrate/module-ee]
EE_RECONCILIATION_MAX_GAP_SECONDSNo86400Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. How long the billing sweep may have been ABSENT before the module refuses to boot rather than resume over the gap it left. Re-enabling the module after a window with it off would otherwise bill every ledger row of that window against the organizations' current quotas, irreversibly. The refusal also requires a backlog larger than one tick's drain capacity, so a platform that was merely shut down boots normally. 0 resumes over any gap. [not in the Zod schema: read by @appstrate/module-ee]
STRIPE_SECRET_KEYConditional-Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Stripe API secret key. Required when the module is loaded. [not in the Zod schema: read by @appstrate/module-ee]
STRIPE_WEBHOOK_SECRETConditional-Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Signing secret used to verify POST /api/billing/webhooks. Required when the module is loaded. [not in the Zod schema: read by @appstrate/module-ee]
STRIPE_PRICE_ID_STARTERConditional-Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Stripe price id backing the starter plan. Required when the module is loaded. [not in the Zod schema: read by @appstrate/module-ee]
STRIPE_PRICE_ID_PROConditional-Module @appstrate/module-ee (must be in MODULES); read from process.env by the module and validated by its own Zod schema, not part of the core env schema. Stripe price id backing the pro plan. Required when the module is loaded. [not in the Zod schema: read by @appstrate/module-ee]
PORTNo3000Server port
APP_URLNohttp://localhost:3000Canonical public origin for OAuth callbacks, email links and externally advertised API URLs. Boot-normalized to URL.origin; only absolute http:// or https:// origins are accepted (no credentials, path, query or fragment). Production requires HTTPS except for loopback origins.
TRUSTED_ORIGINSNohttp://localhost:3000,http://localhost:5173CORS origins, comma-separated
TRUST_PROXYNofalseControls how lib/client-ip.ts reads X-Forwarded-For. false = XFF ignored (safest), true/1 = 1 trusted hop, N = N trusted proxy hops. The value must be the number of hops that actually append to X-Forwarded-For: a TLS-terminating L4 load balancer appends nothing and is not a hop. The chain is read from the right, and a chain shorter than the hop count (or an entry that is not an IP address) is distrusted entirely, socket peer instead. Critical for per-IP rate limiters (incl. OIDC /oauth2/token): a hop count higher than the topology lets callers spoof their client IP via XFF. With NODE_ENV=production and an APP_URL that is not plain-http loopback, false refuses to boot: a proxy is in front by construction and every caller would resolve to it
DOCKER_SOCKETNo/var/run/docker.sockPath to Docker socket
RUN_ADAPTERNoprocessExecution backend id, resolved against the orchestrator registry at boot. Core provides docker (containers) and process (Bun subprocesses); modules contribute more, e.g. firecracker (one microVM per run; requires the firecracker module in MODULES plus the platform-side FIRECRACKER_RUNNER_URL/FIRECRACKER_RUNNER_TOKEN pointing at an appstrate-runner daemon. All host-side FIRECRACKER_* vars are daemon-only; see docs/architecture/FIRECRACKER.md). Unknown id = fatal boot error listing the registered backends, and pointing at MODULES for backends a module provides process refuses to spawn source.kind: "local" integrations. A local integration runs third-party bytes as a same-uid child of the sidecar, which can then read the sidecar's whole environ (platform API key, run token, proxy credentials, every connected integration's decrypted tokens) out of /proc; the process adapter has no portable way to drop privilege, so it refuses the spawn instead. This hits the Tier-0 zero-install default: @appstrate/github-git, the single shipped local integration, fails to boot under RUN_ADAPTER=process unless you also set INTEGRATION_RUNTIME_ADAPTER=docker (keeps the run itself in process mode) or move the run to RUN_ADAPTER=docker/firecracker. Integrations whose source.kind is remote or none (65 of the 66 shipped) are unaffected. See docs/architecture/SIDECAR.md
FIRECRACKER_RUNNER_URLConditional-Address at which the platform's firecracker backend dials the appstrate-runner host daemon: unix:///abs/path.sock (recommended, co-located, no TLS to manage) or http(s)://host:port. REQUIRED when RUN_ADAPTER=firecracker, and ONLY then: merely listing the module in MODULES reads nothing, so a different adapter must not fail boot on an unset value. Enforced lazily, and NOT as a boot failure: the orchestrator's initialize() throws, the platform logs, keeps retrying it in the background and continues, so the API still serves but reports checks.agents: degraded on /health and every run fails. Carries a fail-closed TLS gate (SEC-2): a non-loopback plaintext http:// is refused at boot, because this wire carries the bearer token plus per-run credentials, unless FIRECRACKER_RUNNER_TLS_REQUIRED=0 downgrades it to a warning. Malformed forms are refused loudly: the two-slash unix://path typo (a socket path needs three), a relative unix path, a ?query/#fragment suffix, any other protocol. Every other host-side FIRECRACKER_* variable is daemon-only. See docs/architecture/FIRECRACKER.md [not in the @appstrate/env Zod schema: validated by the module's own Zod schema (apps/api/src/modules/firecracker/remote-env.ts) on first use, not at boot]
FIRECRACKER_RUNNER_TOKENConditional-Shared bearer secret authenticating the platform → appstrate-runner wire; minimum 16 characters, no default and deliberately no "auth off" mode: /v1/sidecars carries run tokens and credential bundles, so an unauthenticated daemon is a credential oracle. Sent as Authorization: Bearer on EVERY daemon call and must be byte-identical on both sides; a mismatch surfaces in appstrate doctor as an unauthorized error naming a FIRECRACKER_RUNNER_TOKEN that does not match the daemon. Same conditional requirement and same lazy enforcement as FIRECRACKER_RUNNER_URL (one error names both). appstrate install / appstrate runner install mint it and PRESERVE the existing value across upgrades. Read by two processes with two schemas: the platform's (lazy) and the daemon's own runner/env.ts, which fails fast at daemon boot [not in the @appstrate/env Zod schema: module-local and daemon-local Zod schemas]
INTEGRATION_RUNTIME_ADAPTERNodockerIntegration runtime backend (docker or process) the Docker orchestrator pins onto the sidecar: operator override mirroring RUN_ADAPTER semantics. The process orchestrator reads the raw environment instead and pins process when unset. The firecracker orchestrator always pins process (the sidecar lives inside the guest) Set this to docker when running with RUN_ADAPTER=process and a source.kind: "local" integration (e.g. @appstrate/github-git): the process integration adapter REFUSES such a spawn, because it cannot land the runner on a different uid (see RUN_ADAPTER above)
PI_IMAGENoappstrate-pi:latestDocker image for the Pi agent runtime (override for GHCR / custom registries). Forms a version contract with SIDECAR_IMAGE and with the platform itself: the two refs must always carry the same tag as each other, and when all three values are release versions they must equal the platform's own build (APP_VERSION) too, or boot fails. On Docker the platform additionally warns when the two images on the host carry different org.opencontainers.image.revision build stamps. Exempt from the platform half: a digest-pinned ref (either half), a platform with no release identity (source run, or an image built without APP_VERSION), and refs pinned to one of the alias tag families the release publishes next to the version (latest, 1.0, sha-<sha>): APP_VERSION is a git ref name and can only ever equal a {{version}} tag. In all of those the two images are still checked against each other. Note what that leaves uncovered: :latest on both refs under a released platform is accepted, because it is indistinguishable from the supported all-:latest deployment: only the org.opencontainers.image.revision warning sees that drift. Rebuild both with bun run docker:build:runtime
SIDECAR_IMAGENoappstrate-sidecar:latestDocker image for the sidecar proxy (override for GHCR / custom registries). Must be pinned to the same tag as PI_IMAGE, and to the platform's own APP_VERSION whenever all three are release versions (see that row)
RUNTIME_IMAGE_WARM_INTERVAL_SECONDSNo300Docker only. Cadence of the runtime-image warm sweep: reconcile one long-lived "pin" holder container per image (appstrate-imagepin-*) so docker image prune -a can't delete PI_IMAGE/SIDECAR_IMAGE between runs. Recreating a pin a host janitor removed re-pulls the image if needed, off the run path. Keeps cold pulls off the run-boot critical path. 0 disables the sweep (no pin containers)
RUNNER_IMAGE_NODENoappstrate-mcp-runner-node:latestMCP runner image for AFPS server.type: node. Forwarded to the sidecar (operator-env passthrough); absent => bare :latest default, resolvable only on a host that pre-built it. Set to a GHCR ref for self-hosted/prod [not in the Zod schema: read inside the sidecar process; forwarded via SIDECAR_OPERATOR_ENV_KEYS]
RUNNER_IMAGE_PYTHONNoappstrate-mcp-runner-python:latestMCP runner image for AFPS server.type: python. Same forwarding/override semantics as RUNNER_IMAGE_NODE [not in the Zod schema: read inside the sidecar process; forwarded via SIDECAR_OPERATOR_ENV_KEYS]
RUNNER_IMAGE_BUNNoappstrate-mcp-runner-bun:latestMCP runner image for AFPS server.type: bun (docker mode). Same semantics as RUNNER_IMAGE_NODE [not in the Zod schema: read inside the sidecar process; forwarded via SIDECAR_OPERATOR_ENV_KEYS]
RUNNER_IMAGE_UVNoappstrate-mcp-runner-uv:latestMCP runner image for AFPS server.type: uv (MCPB 0.4). Same semantics as RUNNER_IMAGE_NODE [not in the Zod schema: read inside the sidecar process; forwarded via SIDECAR_OPERATOR_ENV_KEYS]
RUNNER_IMAGE_BINARYNoappstrate-mcp-runner-binary:latestMCP runner image for AFPS server.type: binary. Same semantics as RUNNER_IMAGE_NODE [not in the Zod schema: read inside the sidecar process; forwarded via SIDECAR_OPERATOR_ENV_KEYS]
S3_BUCKETNo-S3 bucket name. When absent, falls back to filesystem storage (FS_STORAGE_PATH)
S3_REGIONNo-S3 region (e.g. us-east-1). Required when S3_BUCKET is set
FS_STORAGE_PATHNo./data/storageFilesystem storage path (used when S3_BUCKET is absent)
FILE_MAX_BYTESNo104857600Per-file ceiling (bytes) for a durable file (materialized upload or agent output). Over-cap writes return 413. Default 100 MiB
ORG_STORAGE_QUOTA_BYTESNoNone (unlimited)Per-org durable-storage quota (bytes), checked synchronously against organizations.files_bytes_used (403 storage_limit_exceeded on over-cap). Absent ⇒ unlimited
RUN_MAX_OUTPUT_BYTESNo268435456Ceiling (bytes) on the total files a single run may publish as output (Phase 2 ingestion). Default 256 MiB
RUN_MAX_FILESNo200Max files a single run may reference as input (uploads + inline + appfile:// refs) AND publish as output. Bounds the per-run file COUNT that the byte caps do not (thousands of tiny files). Enforced at input-parse (413) and at agent-output commit under the org FOR UPDATE lock (413 file_count_exceeded)
STORAGE_DELETION_WORKER_INTERVAL_MSNo60000Poll cadence (ms) for the transactional storage-deletion worker: the outbox that physically purges S3/FS objects after their DB row is gone. Each pass claims a bounded batch of due storage_deletion_jobs; failures back off exponentially and retry forever (deletion is never abandoned)
UPLOAD_RETENTION_HOURSNo24Post-consume reuse window (hours) for a staged upload: the same upload can feed another run (re-trigger after cancel, rerun_from) without a byte-identical re-upload. Once it elapses the GC sweep drops the row and its storage object. 0 restores single-use semantics
UPLOAD_MAX_ACTIVE_PER_ACTORNo50Max ACTIVE (unconsumed, unexpired) staged uploads one principal may hold at once: a 429 on the (N+1)th create. Computed from the live uploads rows (no separate counter), so a consumed or expired upload frees the budget immediately
UPLOAD_STAGING_MAX_BYTES_PER_ORGNo2147483648Ceiling on the summed DECLARED sizes of an org’s ACTIVE staged uploads. A create that would push the org over it is rejected (403 storage_limit_exceeded). Bounds the ephemeral uploads bucket footprint per org before GC, distinct from the durable ORG_STORAGE_QUOTA_BYTES. Default 2 GiB
WORKSPACE_INIT_IMAGENobusybox:1.37Minimal image (~5 MB) used once per run to chown the freshly created workspace volume to UID 1001 (the agent’s pi user). Override only if your environment cannot pull from Docker Hub or you want a pre-baked busybox
WORKSPACE_TMPFS_SIZE_MBNo512Tmpfs size cap (MB) for the per-run workspace volume. Tmpfs is RAM-backed, fast to allocate/destroy and self-quota’d. Set to 0 to fall back to the local volume driver (host disk, no built-in quota). Max 8192
WORKSPACE_MAX_FILES_BYTESNo268435456Ceiling on the total bytes of input files a single run may carry into its workspace. Each file is streamed to disk out-of-band, so this is a policy limit rather than a memory-safety floor: it also bounds what the platform buffers while consuming uploads. Default 256 MiB
FILE_RETENTION_DAYSNoNone (permanent)Default retention (days) applied as expires_at at file creation. Absent ⇒ files never auto-expire
USERCONTENT_URLNoNone (same-origin on APP_URL)Separate origin for serving untrusted agent-generated HTML previews. Boot-validated: an absolute URL whose host MUST differ from APP_URL's (plus https:// in production). When set, GET /api/files/:id mints its preview_url on this origin (point a second registrable domain (eTLD+1) at the same server), giving the preview its own cookie jar, storage partition and process (site isolation). It does not change WHEN agent HTML is served as active content: that requires a proven iframe load (Sec-Fetch-Dest: iframe) in every mode: a top-level navigation to the shared preview_url degrades to inert text/plain source, set or unset, because a top-level agent document can navigate itself and so cannot be contained by any response header. Its value is defence in depth for the render that does happen, but not against a stripped CSP header: the only context that renders active HTML is the SPA's <iframe sandbox="allow-scripts">, and that attribute survives header stripping, so the document stays opaque-origin either way. What a separate host is genuinely the last layer against is a UA that ignores sandboxing altogether (it ignores the attribute too), and a future app-origin page that frames the preview without the attribute: frame-ancestors permits any app-origin embedder, so there the response header is the only control. Plus the ordinary partition: its own cookie jar, storage and process. Fail-boot because none of that is visible at runtime. Setting it also retargets the SPA document's Content-Security-Policy: frame-src at this origin (it is 'self' when unset), the directive that bounds where a preview frame may navigate itself, and note that 'self' is the whole app origin, so in the default mode the frame may navigate itself to any app-origin page (harmless: opaque origin ⇒ no SameSite cookies, and frame-src is re-enforced across a 302). The two values are derived from one variable, so there is nothing to keep in sync. See docs/architecture/FILES.md (D5)
PGLITE_DATA_DIRNo./data/pglitePGlite data directory (used when DATABASE_URL is absent)
S3_ENDPOINTNo-Custom S3 endpoint (for MinIO/R2/other S3-compatible)
RUN_TOKEN_SECRETYes-Dedicated HMAC secret for run bearer tokens. Comma-separated keyring (≥16 chars per key): FIRST key signs, ALL keys verify. Rotate by prepending the new key, then dropping the old one once in-flight runs drain. Rotates independently of UPLOAD_SIGNING_SECRET
GOOGLE_CLIENT_IDNo-Google OAuth client ID (enables Google sign-in when both Google vars are set)
GOOGLE_CLIENT_SECRETNo-Google OAuth client secret
GITHUB_CLIENT_IDNo-GitHub OAuth App client ID (enables GitHub sign-in when both GitHub vars are set)
GITHUB_CLIENT_SECRETNo-GitHub OAuth App client secret
COOKIE_DOMAINNo-Cookie domain for cross-subdomain auth
SMTP_HOSTNo-SMTP server host (enables email verification when all SMTP vars are set)
SMTP_PORTNo587SMTP server port
SMTP_USERNo-SMTP authentication username
SMTP_PASSNo-SMTP authentication password
SMTP_FROMNo-Sender email address for verification emails
WEBHOOK_TIMESTAMP_TOLERANCE_SECONDSNo300Standard Webhooks timestamp tolerance. Cross-field rule: must be < REMOTE_RUN_REPLAY_WINDOW_SECONDS (validated at boot)
LLM_PROXY_CACHE_MODENooffoff \
LLM_PROXY_CACHE_MAX_AGENo3600LLM proxy cache max age (seconds), when LLM_PROXY_CACHE_MODE=simple
LLM_PROXY_FIRST_RESPONSE_TIMEOUT_MSNo60000 (60 s)TTFB bound in milliseconds on a STREAMING /api/llm-proxy/* or /internal/llm-proxy/* upstream call: how long the provider may take to return response HEADERS. Detached the moment headers arrive, so a slow-but-healthy body is never cut by it; inter-chunk silence after that is LLM_PROXY_STREAM_IDLE_TIMEOUT_MS. On expiry guardedFetch aborts with a TimeoutError and the caller receives a generic RFC 9457 500: there is no tailored timeout status. Non-streaming calls use a separate hardcoded 600 000 ms bound that is NOT operator-overridable. Declared .optional() rather than .default() so the number lives in exactly one place (apps/api/src/services/llm-proxy/helpers.ts), and read into a module-level const, so it is frozen at first import. The sidecar's twin is SIDECAR_LLM_FIRST_RESPONSE_TIMEOUT_MS
LLM_PROXY_STREAM_IDLE_TIMEOUT_MSNo120000 (120 s)Inter-chunk silence bound in milliseconds once an /api/llm-proxy/* or /internal/llm-proxy/* SSE body is flowing (no fetch-level signal can express this without also capping total duration). Enforced on BOTH tee() branches (the client stream and the metering tap) because tee() only cancels its source when both branches cancel. At the limit the client stream is closed CLEANLY, not errored: the caller sees a TRUNCATED SSE stream (Pi classifies that as retryable) and only the server logs the timeout, while the metering tap meters whatever usage it managed to parse (usually none). Looser than the TTFB bound by design: extended thinking and large parallel tool-call payloads buy 15–45 s of legitimate silence. The default is DEFAULT_LLM_STREAM_IDLE_TIMEOUT_MS from @appstrate/connect, hence .optional() and not .default(): @appstrate/env cannot import connect, which depends on it. Sidecar twin: SIDECAR_LLM_STREAM_IDLE_TIMEOUT_MS
APP_VERSIONNo-Deployed build identity, stamped into the image at build time (Dockerfile ARG → ENV, fed by the release workflow). Read-only: surfaced on /health and in the SPA footer. Absent in dev/source runs → the UI shows dev. Do not set this by hand in .env. Also the platform's half of the runtime-image version contract: when this and both image tags are release versions they must all be equal, or boot fails. Anything that is not a release version (dev, absent, or a build stamp like health-container-e2e) takes the platform out of that comparison, as does an image pinned to an alias tag family (see the PI_IMAGE row)
GIT_SHANo-Short git SHA of the build commit, companion to APP_VERSION, same build-time provenance, same "don't set by hand" rule

Rotating CONNECTION_ENCRYPTION_KEY

A ciphertext names the key that wrote it (v1:<kid>:…) and is never rewritten on its own, so a retired key stays in CONNECTION_ENCRYPTION_KEYS until something re-encrypts what it wrote:

  1. Generate the new key (openssl rand -base64 32) and pick a new kid.
  2. Set CONNECTION_ENCRYPTION_KEY to the new key, CONNECTION_ENCRYPTION_KEY_ID to its kid, and add the old pair to CONNECTION_ENCRYPTION_KEYS ({"k1":"<old key>"}). Deploy, and wait until no process runs the old env: from then on every write uses the new kid and the old one only decrypts.
  3. With that env, run bun scripts/rekey-encrypted-columns.ts --apply: it re-encrypts, under the active kid, every ciphertext the retired kids wrote.
  4. Run it again without --apply: its per-kid inventory must show nothing under the old kid, and it exits 0.
  5. Remove the old kid from CONNECTION_ENCRYPTION_KEYS and deploy.

The credential-proxy cookie jars in Redis are not rewritten: they expire with their session, and one still under a dropped kid reads as an empty jar.

EGRESS_ALLOW_INTERNAL_HOSTS: full semantics

Opt-in, comma-separated hostnames the operator explicitly trusts on private/internal addresses. Exempts only the SSRF host blocklist (never the redirect discipline) across every platform egress site that consults it: OAuth token exchange/refresh/discovery, LLM upstream baseUrl, org proxies, org model tests, and remote MCP servers (spawn validation allows plain http:// for these hosts). For an api_call (the platform credential proxy and a run's sidecar) listing a host is necessary, not sufficient: the integration's authorized_uris must also name that host literally (docs/architecture/SIDECAR.md, "Internal hosts"). An integration naming an internal host reaches it only once the host is listed here, and any org's integration that names a listed host reaches it: list only hosts every organization of the instance may call. The value is forwarded to the sidecar under the same name at launch, so the sidecar's own gates honour the same allowlist. An API-key model's inference is dialed by the platform LLM proxy, inside the API process, so a model on a private or local endpoint (Ollama, a LAN vLLM) needs its host listed here, and that host must resolve and be reachable from the API process's own network: localhost is the API's loopback, not the agent's or the sidecar's; without the entry the run fails: before provisioning, with an error naming this variable, when the host is literally blocked (a private or loopback address, localhost, host.docker.internal), otherwise on its first model call, once the proxy has resolved it.

Redirect chains are checked per hop: a trusted host redirecting to a second internal host requires that host to be listed too, and a cross-host redirect still strips credentials and the request body (on an api_call, unless the integration's authorized_uris names that origin). Unset ⇒ every internal host stays blocked (the secure default).

The platform knows this variable by one name and no other, and nothing anywhere recognises the pre-rename spelling. An .env still carrying it is not read and not reported: the key is stripped as unknown and this setting falls back to its default (every internal host stays blocked). Renaming it is an operator task (see the release notes for the version that made the change).

On this page