Environment variables
Agent Zero reads credentials and deployment policy exclusively from the environment. Endpoint URLs and credentials can never be named or embedded in .agent-zero.yml, so untrusted repository policy cannot redirect a secret.
One env file per process
Each process reads exactly one .env, and that file sits next to the process that reads it. Nothing is shared implicitly: a file is loaded only by the process it belongs to, by that process's own tooling.
| File | Read by | Loaded by | Holds |
|---|---|---|---|
.env | the zero CLI (aube run zero …) | Node's --env-file-if-exists=.env in the root script | Model providers, subscription transports, GITHUB_TOKEN |
apps/dashboard/.env | the dashboard: UI, control plane, webhooks, auth, mail | Nuxt, from beside nuxt.config.ts | Control plane, webhook ingress, DATABASE_URL, authentication, mail, site URL |
apps/docs/.env | the documentation site | Nuxt, from beside nuxt.config.ts | NUXT_APP_BASE_URL |
packages/database/.env | drizzle-kit (db:generate, db:migrate) | drizzle-kit, from its own working directory | DATABASE_URL |
Each has a checked-in .env.example next to it; copy the ones you need:
cp .env.example .env
cp apps/dashboard/.env.example apps/dashboard/.env
cp apps/docs/.env.example apps/docs/.env
cp packages/database/.env.example packages/database/.env
Every file is optional. A deployment that sets real environment variables ships no .env at all, and real variables always win over the file.
A variable two processes both read is written in both files on purpose, rather than one process inheriting the other's configuration:
DATABASE_URLis read by the dashboard (the only process that opens the database) and by drizzle-kit when migrating. Point both at the same database —drizzle.config.tsand the server resolve it through the same function precisely so migrations cannot target one store while the dashboard opens another.- The model-provider keys are read by the CLI and, when the hosted control plane executes runs, by the dashboard.
server/auth.config.ts resolves DATABASE_URL at module load, so a dashboard started without it fails on the first request (missing required environment variable: DATABASE_URL) rather than degrading quietly.
Model providers
| Variable | Purpose |
|---|---|
OPENAI_API_KEY | Credential for openai (and legacy fallback for openai-compatible) |
ANTHROPIC_API_KEY | Credential for anthropic |
GOOGLE_GENERATIVE_AI_API_KEY | Credential for google |
AI_GATEWAY_API_KEY | Credential for ai-gateway (or Vercel OIDC) |
OPENAI_COMPATIBLE_API_KEY | Credential for openai-compatible |
AGENT_ZERO_MODEL | Default model name |
AGENT_ZERO_MODEL_BASE_URL | Operator-owned base URL for custom gateways and self-hosted endpoints |
Each provider reads only its documented variable. See Model providers for the full matrix.
Control plane
The control-plane API (/rpc/** and /api/v1/**) fails closed: without AGENT_ZERO_CONTROL_PLANE_TOKENS every mutation is rejected while reads stay open.
| Variable | Purpose |
|---|---|
AGENT_ZERO_CONTROL_PLANE_TOKENS | Comma-separated name:token bearer credentials |
AGENT_ZERO_CONTROL_PLANE_REPOSITORIES | Comma-separated repository paths tasks.create may target |
AGENT_ZERO_CONTROL_PLANE_MODES | Comma-separated name:mode|mode execution-mode grants; without one a principal may only request the non-writable observe and suggest modes |
AGENT_ZERO_CONTROL_PLANE_ORIGINS | Comma-separated origins allowed to read /api/v1/** cross-origin via CORS; empty by default |
See Protect endpoints for how these are enforced.
Webhooks
POST /webhooks/github fails closed (503, nothing ingested) until both variables are set.
| Variable | Purpose |
|---|---|
GITHUB_WEBHOOK_SECRET | HMAC secret for GitHub webhook authentication |
AGENT_ZERO_CHECKOUT_PATH | Checkout the webhook route binds incoming events to |
Status publishing reads one fixed variable per provider (GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_CLOUD_TOKEN, BITBUCKET_DATA_CENTER_TOKEN, GITEA_TOKEN) — see Source-control providers.
Authentication
Registration and GitHub OAuth are off until you turn them on, so a fresh deployment cannot be signed up for by a stranger.
| Variable | Required | Default | Purpose |
|---|---|---|---|
NUXT_BETTER_AUTH_SECRET | yes | – | Session signing secret. Required under this name in production; BETTER_AUTH_SECRET is a development-only fallback |
DATABASE_URL | yes | – | Postgres connection string; the pre-split AUTH_DATABASE_URL is still read when this is unset |
AUTH_ENABLE_SIGNUP | no | false | Set to true to allow self-registration |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | no | – | Enables the GitHub button when both are set |
AUTH_ENABLE_ORGANIZATIONS | no | false | Enables organizations; requires a working mail transport |
AUTH_ALLOW_ORGANIZATION_CREATION | no | false | Whether any signed-in user may create an organization |
AUTH_ENABLE_DEVICE_AUTHORIZATION | no | false | Set to true to let zero login obtain a session through the RFC 8628 device flow |
OAUTH_PROXY_PRODUCTION_URL / OAUTH_PROXY_SECRET | no | – | Routes a preview or local origin's OAuth round trip through production; both required, and the secret must match across environments |
NUXT_PUBLIC_SITE_URL | no | – | Base URL override for a custom domain or deterministic OAuth callbacks; auto-detected from the request otherwise |
Hosted infrastructure
Cloud-managed deployments only. @better-auth/infra's sentinel() and dash() plugins are registered only when all three are set; a self-hosted install leaves them empty and gets neither.
| Variable | Required | Default | Purpose |
|---|---|---|---|
BETTER_AUTH_API_URL | no | – | Dash API origin |
BETTER_AUTH_KV_URL | no | – | KV service origin; also what the browser-side sentinel client identifies visitors against |
BETTER_AUTH_API_KEY | no | – | Project API key |
sentinel() reports sign-in and sign-up attempts for scoring, and the browser-side sentinel client fingerprints every visitor and identifies them against BETTER_AUTH_KV_URL — which is why that client plugin is loaded only when these variables are configured.dash() mounts roughly 79 endpoints under /api/auth/dash/**, including execute-adapter, impersonate-user, delete-many-users, and export-users. All but two require a JWT signed by the hosted service, verified against its JWKS with a five-minute maximum age, whose apiKeyHash claim must also match a hash of BETTER_AUTH_API_KEY — so controlling the API origin alone does not admit a caller.dash()'s accept-invitation and complete-invitation endpoints cannot carry that JWT guard, because the invitee holds no API key. They are authorized by an invitation token validated against the hosted API, and they create a user with emailVerified: true, optionally a password account, and a session — through the internal adapter, so they bypass both AUTH_ENABLE_SIGNUP and the invitation flow gated by AUTH_ENABLE_INVITATIONS.In other words: enabling dash() delegates account creation in your database to whoever can mint an invitation in the hosted console. Treat access to that console as equivalent to AUTH_ENABLE_SIGNUP=true.AUTH_ENABLE_SIGNUP, the GitHub OAuth credentials, or the BETTER_AUTH_* hosted-infrastructure variables, rebuild the app, or the login page will keep advertising the old capabilities (the server still enforces its own policy either way).console logs instead of delivering and is the default when neither MAIL_PROVIDER nor RESEND_API_KEY is configured, so an unconfigured deployment cannot silently attempt real delivery. If MAIL_PROVIDER is absent, a Resend credential selects Resend automatically; an explicit provider always wins.
| Variable | Purpose |
|---|---|
MAIL_PROVIDER | Optional explicit console, resend, or smtp selector |
MAIL_FROM | Sender address |
RESEND_API_KEY | Required for Resend; also selects it when MAIL_PROVIDER is absent |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD | Required when MAIL_PROVIDER=smtp |
SMTP_SECURE | Implicit TLS: true on 465, false on 587 |
See Mails.
Dashboard
| Variable | Purpose |
|---|---|
PORT | Dashboard port (default 3000) |
Deployment target
Build-time only: these pick the deployment preset the dashboard build emits, and with it the KV
driver its task history resolves. Unset means the self-hosted node-server bundle in .output/.
| Variable | Purpose |
|---|---|
NITRO_PRESET | Deployment target as the Nitro preset each plan pins: vercel, node-server, cloudflare-module, deno-deploy, netlify |
VITEHUB_HOSTING | The same target as a ViteHub plan name: vercel, node, cloudflare, deno, netlify; takes precedence over NITRO_PRESET |
KV_REST_API_URL / KV_REST_API_TOKEN | Upstash credentials the KV store reads at runtime on a host without a writable filesystem |
Each variable is matched against its own vocabulary and nothing else: a value ViteHub would refuse
to build under — vercel-edge or a bare node in NITRO_PRESET, say — is rejected up front
rather than resolved to the neighbouring target.
See Deployment.
Build metadata
Every Nuxt app resolves what build it is — version, commit, branch, deploy channel, deploy URL —
and publishes it under runtimeConfig.public.buildInfo, read with useBuildInfo(). The resolution
lives in packages/build-env and runs in two passes.
The first pass runs during the build. It asks the hosting provider first and the checkout second:
a provider knows the branch a detached CI checkout cannot name, and knows whether the deploy is
production. VERCEL_GIT_COMMIT_SHA and friends on Vercel, COMMIT_REF and CONTEXT on Netlify,
CF_PAGES_COMMIT_SHA on Cloudflare Pages, GITHUB_SHA on a GitHub Actions runner. None of these
are set by you; they are set by the platform, and are listed in turbo.jsonc so a build never
restores a cached bundle that reports a different commit.
The second pass runs in the deployed server, and is what every target that is not Vercel needs. A
container image built in CI has none of the platform variables in scope while it is being built, so
its first pass resolves what git can tell it and leaves the rest as unknown. Those fields — and
only those — are completed when the server starts, from the environment the host actually runs it
in. A field the build resolved is never overwritten: the commit a bundle was compiled from is a
property of the bundle, not of the machine serving the request.
For a deployment on no recognised platform, or one that wants to state outright what it is:
| Variable | Purpose |
|---|---|
AGENT_ZERO_BUILD_COMMIT | Full commit SHA the bundle was built from |
AGENT_ZERO_BUILD_BRANCH | Git branch |
AGENT_ZERO_BUILD_PR_NUMBER | Pull request number, for a pull-request deploy |
AGENT_ZERO_BUILD_URL | URL of this deploy |
AGENT_ZERO_BUILD_PRODUCTION_URL | URL of the production domain |
AGENT_ZERO_BUILD_ENV | Deploy channel: dev, preview, canary, or release |
Setting any of them takes precedence over every auto-detected platform, so an operator's answer
always wins over an inferred one. AGENT_ZERO_BUILD_ENV is the field detection can never work out
on its own: a self-hosted staging deployment is a preview in every way that matters to the people
looking at it, and nothing about a plain node .output/server/index.mjs says so.
Individual fields can also be overridden through Nuxt's own public runtime config channel —
NUXT_PUBLIC_BUILD_INFO_COMMIT, NUXT_PUBLIC_BUILD_INFO_BRANCH, and so on — which applies before
the server's own pass and needs no rebuild. There is no _SHORT_COMMIT variant: the dashboard
derives the abbreviated form from commit wherever it is displayed, rather than declaring it as
its own field that could drift from the commit it abbreviates.
A prerendered route has no server left to ask, so on apps/marketing the second pass runs while
the page is prerendered rather than while it is served. On Vercel that changes nothing: the build
already resolved every field.
Documentation
| Variable | Purpose |
|---|---|
NUXT_APP_BASE_URL | Base path (default /; use /<repository>/ on Pages) |