Skip to content

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#

VariablePurpose
SUPABASE_URLProject URL, server side
SUPABASE_PUBLISHABLE_KEYAnon key, server side
SUPABASE_SERVICE_ROLE_KEYService role. Server only — must never reach a browser.
VITE_SUPABASE_URLSame URL, inlined into the client bundle
VITE_SUPABASE_PUBLISHABLE_KEYSame anon key, client side
ADMIN_EMAILBootstrap superadmin account
VITE_ADMIN_EMAILSame address, for client-side admin affordances
PROVIDER_CREDS_SECRETEncryption key for every stored credential. Back this up — see the warning below.
INTERNAL_RUN_SECRETSigns internal service-to-service calls

PROVIDER_CREDS_SECRET is not recoverable

Every stored credential is encrypted with it, and it lives in the environment rather than the database — so a database dump alone yields no secrets. Lose it and every connector, provider key and MCP token must be re-entered. Keep it wherever you keep your other break-glass secrets, and back it up separately from the database.

Models and search#

VariablePurpose
OPENROUTER_API_KEYZero-config model fallback so a fresh workspace works before anyone connects their own provider.
OPENROUTER_DEFAULT_MODELModel used for that fallback
OPENROUTER_BASE_URLPoint at a compatible gateway instead of OpenRouter
FIRECRAWL_API_KEYWorkspace-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.

VariableDefaultPurpose
WAREHOUSE_MAX_ROWS1000Rows returned when a caller asks for none
WAREHOUSE_ABS_MAX_ROWS5000Hard ceiling no caller can exceed
WAREHOUSE_QUERY_TIMEOUT_MS60000Wall-clock budget for one query
WAREHOUSE_MAX_CONCURRENT8Queries in flight, per instance
WAREHOUSE_MAX_CONCURRENT_PER_USER3Per tenant
WAREHOUSE_POOLonConnection pooling for PostgreSQL/MySQL-family sources. Set off for a connection per query.
WAREHOUSE_POOL_MAX4Sockets per credential set — see the sizing note below
WAREHOUSE_POOL_MAX_KEYS64Distinct credential sets held at once
HTTPS_PROXYForward proxy for all outbound connector traffic. NO_PROXY takes a bypass list.
CONNECTOR_MAX_RETRIES2Retries on 429/503 and transport errors
CONNECTOR_RETRY_500offAlso retry 500s — only for providers that use 500 for throttling
CONNECTION_HEALTH_HOURS12Credential re-validation cadence
CREDENTIAL_MAX_AGE_DAYS90When a credential is badged as old

Size the pool against your database, not this page

Sockets held is roughly 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.

bash
RESEND_API_KEY="re_..."
EMAIL_FROM="AgentSwarms <[email protected]>"
SITE_URL="https://your-domain.com"

Two ways email fails without looking broken

Leaving 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.
VariablePurpose
RESEND_API_KEYResend delivery
SMTP_HOSTSMTP delivery
SMTP_PORT
SMTP_USER
SMTP_PASS
SMTP_SECURETLS on/off
EMAIL_FROMFrom address on outgoing mail
SITE_URLBase URL used in links inside emails
PUBLIC_APP_URLPublic base URL of this instance

Run limits and cost#

VariablePurpose
SWARM_RUN_RATE_LIMIT_PER_MINRequests per API key per minute, then 429
SWARM_RUN_MAX_CONCURRENTSimultaneous runs per key
SWARM_RUN_TIMEOUT_MSWall-clock ceiling for one run
ENFORCE_BUDGET_CAPMakes budget caps BLOCK rather than only alert. Accepts 1, true, yes. Set this on any instance with a public embed — see Budgets.
BUDGET_FAIL_CLOSEDWhat 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

These limits are counted in Postgres, shared by every instance, so the number you set is the number you get however many copies of the app are running. If the database is briefly unreachable each instance falls back to counting locally and logs that it has done so — the limit degrades rather than disappearing.

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.

VariableDefaultPurpose
SWARM_LEVEL_CONCURRENCY4Nodes run in parallel per graph level.
MCP_MAX_CONCURRENT_PER_SERVER8In-flight calls to one published MCP server.
BI_DIRECT_QUERY_RATE_PER_MIN120Live 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_MINUTE10Dataset uploads per user.
INTEGRATION_TEST_PER_MINUTE10Presses of "Test connection" per user.
NOTEBOOK_CELL_TIMEOUT_SECONDS120Wall-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.

VariableDefaultPurpose
LOCAL_ENGINE_MEMORY_MB512Memory ceiling for one query.
LOCAL_ENGINE_THREADS2Threads per query.
LOCAL_ENGINE_TIMEOUT_MS30000Wall-clock ceiling for one query.

Warehouse connection pool

VariableDefaultPurpose
WAREHOUSE_POOL_IDLE_MS30000Before an idle socket is closed.
WAREHOUSE_POOL_TTL_MS300000Before a whole pool is dropped.
WAREHOUSE_QUEUE_TIMEOUT_MS30000How long a query waits for a free slot before failing.
CONNECTOR_RETRY_BASE_MS400First backoff step on a retryable error.
CONNECTOR_RETRY_MAX_MS8000Caps any single backoff wait.
HTTP_PROXYunsetRoutes 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.

VariableDefaultPurpose
PARQUET_MIRRORoffEnables mirroring.
PARQUET_MIN_ROWS5000Below this a table is not worth mirroring.
PARQUET_MAX_ROWS5000000Above this a table is not mirrored.
PARQUET_CACHE_DIRtemp dirWhere mirrored files live.
PARQUET_CACHE_MAX_BYTES2 GiBCache ceiling on disk.
MIRROR_BUDGET_BYTESunlimitedTotal 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.

VariableDefaultPurpose
JS_SANDBOX_URLunset (auto-discovered)External sandbox service. Without one, function nodes are refused.
JS_SANDBOX_MAX_TIMEOUT_MS5000Ceiling on one function node.
JS_SANDBOX_MAX_CONCURRENT4Simultaneous executions.
JS_SANDBOX_MEM_MB128Memory ceiling per execution.
JS_SANDBOX_MAX_BODY_BYTES1000000Largest payload in or out.

Network egress#

VariablePurpose
BLOCK_PRIVATE_NETWORK_FETCHRefuse outbound requests to private, loopback and link-local addresses, including cloud metadata endpoints.
ALLOW_PRIVATE_NETWORK_FETCHThe escape hatch, for when a warehouse or MCP server genuinely lives on a private network.
TRUSTED_PROXY_HOPSHow 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

Set 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

Allowing private-network fetches means a URL chosen by a model — from 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#

VariablePurpose
METRICS_TOKENBearer token guarding the metrics endpoint
OTEL_EXPORTER_OTLP_ENDPOINTOTLP collector endpoint
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTTraces-specific override
OTEL_EXPORTER_OTLP_HEADERSExtra headers for the collector
OTEL_SERVICE_NAMEService name reported in traces
AUDIT_ARCHIVE_ON_PURGEArchive audit events instead of dropping them at retention
PERSIST_PROMPT_BODIESWhether full prompt and response bodies are stored on traces. Rich for debugging, heavier and more sensitive — decide deliberately.

Scheduling#

VariablePurpose
DISABLE_INPROCESS_SCHEDULERTurn off the in-process scheduler on the web tier — see scaling below.
BI_CRON_TOKENToken an external cron presents to the BI cron endpoint
NOTEBOOK_CRON_TOKENSame, for the notebook reaper

Document renderer#

VariablePurpose
DOCGEN_SERVICE_URLOnly when the renderer runs somewhere unusual. Leave empty — the app probes docgen:8099 and localhost:8099 and uses whichever answers.
DOCGEN_TOKENShared bearer token between the app and the renderer

Notebook runtime#

VariablePurpose
NOTEBOOK_RUNTIME_ENABLEDTurn the server runtime on
NOTEBOOK_RUNTIME_SECRETSession-token signing key. Omit and the server generates one.
NOTEBOOK_RUNTIME_BACKENDdocker | k8s | e2b
NOTEBOOK_RUNTIME_IMAGEKernel image to launch
NOTEBOOK_GATEWAY_URLWebsocket 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.

bash
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.

bash
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

Then turn on invite-only under Admin → IAM so the login page stops accepting new signups. See Access control.

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.

bash
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

Give every embed key and swarm API key its own cap under Budgets. The per-user cap is not enough on its own: it is what a leaked key drains, and a per-credential cap is what stops it.

Regulated or air-gapped#

No outbound anything, evidence retained, and the audit trail shipped somewhere the app cannot rewrite.

bash
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:refresh

Note

Audit retention is set in the product, not the environment — Admin → IAM, default 365 days. Deleting a user no longer deletes their trail: the row is kept with the actor's email so an investigation still has something to read.

Rotating the credential key is supported

Changing 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

With 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.

bash
# 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="..."