Self-hosting
Install & deploy · Configuration
Every environment variable, grouped by what it configures, and the settings that fit each way of running the platform — a laptop, one team, public embeds, a regulated network, a fleet behind a load balancer.
Part of the Install & deploy guide. This page is the reference: every environment variable, grouped by what it configures, then the settings that fit each way of running the platform.
Environment reference#
Every variable the app reads, grouped by what it does. Only the first group is required; everything else changes behaviour you may not need.
Required — Supabase and identity#
| Variable | Purpose |
|---|---|
SUPABASE_URL | Project URL, server side |
SUPABASE_PUBLISHABLE_KEY | Anon key, server side |
SUPABASE_SERVICE_ROLE_KEY | Service role. Server only — must never reach a browser. |
VITE_SUPABASE_URL | Same URL, inlined into the client bundle |
VITE_SUPABASE_PUBLISHABLE_KEY | Same anon key, client side |
ADMIN_EMAIL | Bootstrap superadmin account |
VITE_ADMIN_EMAIL | Same address, for client-side admin affordances |
PROVIDER_CREDS_SECRET | Encryption key for every stored credential. Back this up — see the warning below. |
INTERNAL_RUN_SECRET | Signs internal service-to-service calls |
PROVIDER_CREDS_SECRET is not recoverable
Models and search#
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY | Zero-config model fallback so a fresh workspace works before anyone connects their own provider. |
OPENROUTER_DEFAULT_MODEL | Model used for that fallback |
OPENROUTER_BASE_URL | Point at a compatible gateway instead of OpenRouter |
FIRECRAWL_API_KEY | Workspace-wide web search and JavaScript-rendered page fetching for web_search / web_browse. Optional: without it, web_browse uses the built-in fetcher (server-rendered pages only) and web_search falls back to DuckDuckGo entity lookups. |
Data connections#
None of these are required — the defaults are the recommended settings. They exist for tuning against a particular warehouse or network.
| Variable | Default | Purpose |
|---|---|---|
WAREHOUSE_MAX_ROWS | 1000 | Rows returned when a caller asks for none |
WAREHOUSE_ABS_MAX_ROWS | 5000 | Hard ceiling no caller can exceed |
WAREHOUSE_QUERY_TIMEOUT_MS | 60000 | Wall-clock budget for one query |
WAREHOUSE_MAX_CONCURRENT | 8 | Queries in flight, per instance |
WAREHOUSE_MAX_CONCURRENT_PER_USER | 3 | Per tenant |
WAREHOUSE_POOL | on | Connection pooling for PostgreSQL/MySQL-family sources. Set off for a connection per query. |
WAREHOUSE_POOL_MAX | 4 | Sockets per credential set — see the sizing note below |
WAREHOUSE_POOL_MAX_KEYS | 64 | Distinct credential sets held at once |
HTTPS_PROXY | — | Forward proxy for all outbound connector traffic. NO_PROXY takes a bypass list. |
CONNECTOR_MAX_RETRIES | 2 | Retries on 429/503 and transport errors |
CONNECTOR_RETRY_500 | off | Also retry 500s — only for providers that use 500 for throttling |
CONNECTION_HEALTH_HOURS | 12 | Credential re-validation cadence |
CREDENTIAL_MAX_AGE_DAYS | 90 | When a credential is badged as old |
Size the pool against your database, not this page
WAREHOUSE_POOL_MAX × WAREHOUSE_POOL_MAX_KEYS × your replica count, and each replica keeps its own pools. Check that product against the database’s max_connections before raising either number. Pooling is worth having — it took a query from 30.7ms to 2.9ms in measurement — but an oversized pool exhausts a warehouse’s connection limit instead.Email delivery#
Carries welcome mail, budget alerts, BI alerts, scheduled reports, approval requests and the contact form. Use either Resend or SMTP. Auth emails (confirmation, password reset) are separate — Supabase sends those, configured in its own dashboard.
Resend needs a verified domain, not just a key. In Resend: create an API key for RESEND_API_KEY, then Domains → Add Domain, publish the SPF and DKIM records it gives you at your DNS host, and press Verify. Then set EMAIL_FROM to an address on that domain.
RESEND_API_KEY="re_..."
EMAIL_FROM="AgentSwarms <[email protected]>"
SITE_URL="https://your-domain.com"Two ways email fails without looking broken
EMAIL_FROM empty falls back to [email protected], which Resend rejects — every app email fails while the app carries on normally. And until your domain is verified, Resend sends only from [email protected] and delivers only to the address that owns the Resend account; mail to anyone else is accepted by the API and never arrives. Both outcomes are recorded in email_send_log, which is where to look when nobody is receiving anything.| Variable | Purpose |
|---|---|
RESEND_API_KEY | Resend delivery |
SMTP_HOST | SMTP delivery |
SMTP_PORT | — |
SMTP_USER | — |
SMTP_PASS | — |
SMTP_SECURE | TLS on/off |
EMAIL_FROM | From address on outgoing mail |
SITE_URL | Base URL used in links inside emails |
PUBLIC_APP_URL | Public base URL of this instance |
Run limits and cost#
| Variable | Purpose |
|---|---|
SWARM_RUN_RATE_LIMIT_PER_MIN | Requests per API key per minute, then 429 |
SWARM_RUN_MAX_CONCURRENT | Simultaneous runs per key |
SWARM_RUN_TIMEOUT_MS | Wall-clock ceiling for one run |
ENFORCE_BUDGET_CAP | Makes budget caps BLOCK rather than only alert. Accepts 1, true, yes. Set this on any instance with a public embed — see Budgets. |
BUDGET_FAIL_CLOSED | What to do when the spend lookup itself fails, as opposed to coming back under cap. Unset (the default) allows the call — governance should not be the reason legitimate work breaks. Set true when the cap must hold even if the figure cannot be established. Either way the failure is logged. |
Note
Performance and concurrency tuning#
Every value here has a working default; an instance runs without setting any of them. They exist for the two situations where the defaults stop fitting — a small box that needs ceilings lowered, and a busy instance where one user's work should not crowd out everyone else's. The defaults below are the ones the code applies when the variable is unset.
| Variable | Default | Purpose |
|---|---|---|
SWARM_LEVEL_CONCURRENCY | 4 | Nodes run in parallel per graph level. |
MCP_MAX_CONCURRENT_PER_SERVER | 8 | In-flight calls to one published MCP server. |
BI_DIRECT_QUERY_RATE_PER_MIN | 120 | Live warehouse queries per dashboard OWNER — a shared dashboard bills its owner's budget, so the limit follows the owner rather than the viewer. |
UPLOAD_PER_MINUTE | 10 | Dataset uploads per user. |
INTEGRATION_TEST_PER_MINUTE | 10 | Presses of "Test connection" per user. |
NOTEBOOK_CELL_TIMEOUT_SECONDS | 120 | Wall-clock ceiling on one notebook cell. |
Local query engine
The server-side engine that runs local datasets and scheduled refreshes. Lower these on a small VM; raising them past what the host has does not make queries faster, it makes them fail later.
| Variable | Default | Purpose |
|---|---|---|
LOCAL_ENGINE_MEMORY_MB | 512 | Memory ceiling for one query. |
LOCAL_ENGINE_THREADS | 2 | Threads per query. |
LOCAL_ENGINE_TIMEOUT_MS | 30000 | Wall-clock ceiling for one query. |
Warehouse connection pool
| Variable | Default | Purpose |
|---|---|---|
WAREHOUSE_POOL_IDLE_MS | 30000 | Before an idle socket is closed. |
WAREHOUSE_POOL_TTL_MS | 300000 | Before a whole pool is dropped. |
WAREHOUSE_QUEUE_TIMEOUT_MS | 30000 | How long a query waits for a free slot before failing. |
CONNECTOR_RETRY_BASE_MS | 400 | First backoff step on a retryable error. |
CONNECTOR_RETRY_MAX_MS | 8000 | Caps any single backoff wait. |
HTTP_PROXY | unset | Routes outbound connector traffic through a proxy. |
Parquet mirror
Optional. Large local datasets can be mirrored to Parquet so repeat queries read a columnar file instead of re-reading rows. Unset, nothing is mirrored and everything still works.
| Variable | Default | Purpose |
|---|---|---|
PARQUET_MIRROR | off | Enables mirroring. |
PARQUET_MIN_ROWS | 5000 | Below this a table is not worth mirroring. |
PARQUET_MAX_ROWS | 5000000 | Above this a table is not mirrored. |
PARQUET_CACHE_DIR | temp dir | Where mirrored files live. |
PARQUET_CACHE_MAX_BYTES | 2 GiB | Cache ceiling on disk. |
MIRROR_BUDGET_BYTES | unlimited | Total bytes mirroring may write. |
JavaScript sandbox
Backs the swarm function node. User-authored code never runs in the app process, which holds the service-role key and every decrypted provider credential. Leaving JS_SANDBOX_URL unset does not mean in-process: the app probes http://js-sandbox:8091, then http://127.0.0.1:8091, and uses whichever answers. Set the variable only to point somewhere else. If nothing answers, the node is refused with a message explaining how to enable it.
| Variable | Default | Purpose |
|---|---|---|
JS_SANDBOX_URL | unset (auto-discovered) | External sandbox service. Without one, function nodes are refused. |
JS_SANDBOX_MAX_TIMEOUT_MS | 5000 | Ceiling on one function node. |
JS_SANDBOX_MAX_CONCURRENT | 4 | Simultaneous executions. |
JS_SANDBOX_MEM_MB | 128 | Memory ceiling per execution. |
JS_SANDBOX_MAX_BODY_BYTES | 1000000 | Largest payload in or out. |
Network egress#
| Variable | Purpose |
|---|---|
BLOCK_PRIVATE_NETWORK_FETCH | Refuse outbound requests to private, loopback and link-local addresses, including cloud metadata endpoints. |
ALLOW_PRIVATE_NETWORK_FETCH | The escape hatch, for when a warehouse or MCP server genuinely lives on a private network. |
TRUSTED_PROXY_HOPS | How many reverse proxies of yours sit in front of the app. Decides which entry of X-Forwarded-For is treated as the caller — the header is appended to, so only the entries your own proxies added cannot be forged. Default 1 (a single reverse proxy); use 2 behind a CDN in front of that proxy. Only MCP key IP allow-lists depend on it. |
Careful
TRUSTED_PROXY_HOPS to match your actual topology before relying on an MCP key's IP allow-list. Too low reads your proxy's address instead of the caller's and the allow-list never matches; too high reads a value the caller supplied, which is the bypass the setting exists to close. It is clamped to the length of the chain, so it can never walk past the end.Careful
web_browse, a swarm HTTP node, or a prompt-injected instruction — can reach inside your network. If you must enable it, do so on an instance with no public embeds.Observability and audit#
| Variable | Purpose |
|---|---|
METRICS_TOKEN | Bearer token guarding the metrics endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP collector endpoint |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Traces-specific override |
OTEL_EXPORTER_OTLP_HEADERS | Extra headers for the collector |
OTEL_SERVICE_NAME | Service name reported in traces |
AUDIT_ARCHIVE_ON_PURGE | Archive audit events instead of dropping them at retention |
PERSIST_PROMPT_BODIES | Whether full prompt and response bodies are stored on traces. Rich for debugging, heavier and more sensitive — decide deliberately. |
Scheduling#
| Variable | Purpose |
|---|---|
DISABLE_INPROCESS_SCHEDULER | Turn off the in-process scheduler on the web tier — see scaling below. |
BI_CRON_TOKEN | Token an external cron presents to the BI cron endpoint |
NOTEBOOK_CRON_TOKEN | Same, for the notebook reaper |
Document renderer#
| Variable | Purpose |
|---|---|
DOCGEN_SERVICE_URL | Only when the renderer runs somewhere unusual. Leave empty — the app probes docgen:8099 and localhost:8099 and uses whichever answers. |
DOCGEN_TOKEN | Shared bearer token between the app and the renderer |
Notebook runtime#
| Variable | Purpose |
|---|---|
NOTEBOOK_RUNTIME_ENABLED | Turn the server runtime on |
NOTEBOOK_RUNTIME_SECRET | Session-token signing key. Omit and the server generates one. |
NOTEBOOK_RUNTIME_BACKEND | docker | k8s | e2b |
NOTEBOOK_RUNTIME_IMAGE | Kernel image to launch |
NOTEBOOK_GATEWAY_URL | Websocket gateway address |
Configuration by use case#
The reference above lists every setting. These are the combinations that actually go together, as complete blocks you can paste into .env. Each one names the risk it is answering, because the defaults are chosen for a single trusted operator and stop being right as soon as anyone else can reach the instance.
Evaluating it on a laptop#
Nothing is exposed, so nothing needs hardening. This is the default and you can ignore every other recipe until someone else can reach the app.
SUPABASE_URL="https://<project>.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="..."
SUPABASE_PUBLISHABLE_KEY="..."
VITE_SUPABASE_URL="https://<project>.supabase.co"
VITE_SUPABASE_PUBLISHABLE_KEY="..."
# One key and you can use everything; per-user keys can come later.
OPENROUTER_API_KEY="sk-or-..."
ADMIN_EMAIL="[email protected]"
VITE_ADMIN_EMAIL="[email protected]"An internal tool for one team#
Reachable on your network or a private domain, no anonymous visitors. The work here is closing signup and making sure a runaway agent cannot bill you indefinitely.
SITE_URL="https://agents.internal.example.com"
PUBLIC_APP_URL="https://agents.internal.example.com"
# Encrypts stored warehouse/SaaS credentials. Generate once and keep it;
# rotating needs PROVIDER_CREDS_SECRET_OLD (see below). openssl rand -hex 32
PROVIDER_CREDS_SECRET="..."
# Caps stop being advisory.
ENFORCE_BUDGET_CAP="true"
# One reverse proxy in front (Caddy/nginx).
TRUSTED_PROXY_HOPS="1"
# Self-hosted Ollama or an in-cluster MCP server lives on a private address,
# so leave private-network fetches allowed — cloud metadata stays blocked
# either way.Note
Public embeds on a marketing site#
The hardest case, because anonymous visitors spend your credits and you cannot authenticate them. Every setting here bounds what a stranger — or a leaked embed key — can cost you.
SITE_URL="https://www.example.com"
PUBLIC_APP_URL="https://app.example.com"
PROVIDER_CREDS_SECRET="..."
# Refuse calls past the cap instead of emailing about them afterwards.
ENFORCE_BUDGET_CAP="true"
# If spend cannot be established, refuse rather than assume zero.
BUDGET_FAIL_CLOSED="true"
# A public embed has no reason to reach anything inside your network.
BLOCK_PRIVATE_NETWORK_FETCH="true"
TRUSTED_PROXY_HOPS="1"
# Tighten the public surface below the defaults (30/min chat, 10/min ask).
MCP_RATE_LIMIT_PER_MIN="30"
SWARM_RUN_RATE_LIMIT_PER_MIN="10"
SWARM_RUN_MAX_CONCURRENT="2"Careful
Regulated or air-gapped#
No outbound anything, evidence retained, and the audit trail shipped somewhere the app cannot rewrite.
BLOCK_PRIVATE_NETWORK_FETCH="true"
ENFORCE_BUDGET_CAP="true"
BUDGET_FAIL_CLOSED="true"
# Traces to your own collector; nothing leaves for a vendor.
OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector.internal:4318"
OTEL_SERVICE_NAME="agentswarms-prod"
# Expiring audit rows are printed as NDJSON before deletion, so a log
# shipper keeps them past the database's own retention.
AUDIT_ARCHIVE_ON_PURGE="1"
# No model prices are fetched at runtime; the table is vendored in the repo
# and refreshed deliberately with: npm run prices:refreshNote
Rotating the credential key is supported
PROVIDER_CREDS_SECRET on its own does strand everything already encrypted, which is why the snippet above says to set it once. Rotating it properly needs no downtime: move the outgoing value to PROVIDER_CREDS_SECRET_OLD (comma-separated, accepted for decryption only), put the new one in PROVIDER_CREDS_SECRET, restart, then run Admin → IAM → Settings → Re-encrypt to current key. Clear PROVIDER_CREDS_SECRET_OLD once nothing is left on the old key. Stored ciphertext carries a fingerprint of the key that wrote it, so the platform knows which secret decrypts which row.Or keep the key in Vault
KMS_PROVIDER=vault the key that encrypts credentials is a data key wrapped by a HashiCorp Vault Transit key that never leaves Vault, unwrapped once per process start — the app holds a permission to decrypt, not the key, and a process that cannot unwrap refuses to start. Set KMS_KEY_REF and the VAULT_* variables, create the data key on Admin → IAM → Settings → Credential encryption key, then switch the provider and run the same re-encrypt sweep; the env secret keeps decrypting old rows until it has moved them. Details and the failure modes in docs/KEY_MANAGEMENT.md.Autoscaled behind a load balancer#
Several app instances against one Supabase project. Rate limits and concurrency slots are counted in Postgres, so the numbers you set are the numbers you get — but two settings need to match the topology.
# MUST be set: instances resolve their own origin from this, never from the
# request's Host header.
PUBLIC_APP_URL="https://app.example.com"
# A dedicated secret for server-to-server calls, so the database master key
# stays out of outbound headers. openssl rand -hex 32
INTERNAL_RUN_SECRET="..."
# CDN in front of the load balancer? Then two hops, not one.
TRUSTED_PROXY_HOPS="2"
# Alerts, refreshes and purges run in-process. A cross-instance lease stops
# them double-firing, but the tidier arrangement on a fleet is to disable
# them on the web tier and drive /api/bi/cron from one external scheduler.
DISABLE_INPROCESS_SCHEDULER="true"
BI_CRON_TOKEN="..."