Environment Variables
All configuration is done through environment variables, defined in your .env.prod file. This page documents every variable.
Database
Section titled “Database”| Variable | Default | Required | Description |
|---|---|---|---|
POSTGRES_USER |
breeze |
PostgreSQL username | |
POSTGRES_PASSWORD |
— | Yes | PostgreSQL password. Also used to derive the unprivileged request-pool credential (see below) unless DATABASE_URL_APP or BREEZE_APP_DB_PASSWORD is set. |
POSTGRES_DB |
breeze |
Database name | |
POSTGRES_PORT |
5432 |
PostgreSQL port | |
DATABASE_URL |
— | Auto | Full connection string (constructed from above in Docker) |
DATABASE_URL_APP |
— | One of these three (prod) | Explicit connection string for the unprivileged request role (breeze_app). Preferred for direct (non-Compose) deployments and for deploy/docker-compose.prod.yml (managed Postgres), and required for multi-host / high-availability DATABASE_URL values, which cannot be derived automatically. Leave it empty on the bundled root docker-compose.yml — see the callout below. |
BREEZE_APP_DB_PASSWORD |
— | One of these three (prod) | Password for the breeze_app role, used to derive its connection string from DATABASE_URL. |
In production, Breeze runs request handlers as an unprivileged, row-level-security-enforced database role (breeze_app) rather than as the database owner. The API refuses to start unless it can configure that role — set one of DATABASE_URL_APP, BREEZE_APP_DB_PASSWORD, or POSTGRES_PASSWORD. This check runs whenever NODE_ENV=production, including when AUTO_MIGRATE=false. The API also refuses to start if it detects it is running as a SUPERUSER or BYPASSRLS role — if you were previously running as the postgres superuser, switch to a NOSUPERUSER/NOBYPASSRLS role. After doing so you may notice rows that were previously visible to a tenant “disappear”; that is row-level security working correctly, not data loss. See the request-database-role runbook.
| Variable | Default | Required | Description |
|---|---|---|---|
REDIS_URL |
redis://localhost:6379 |
Yes (prod) | Redis connection URL. Must carry a password in production (e.g., redis://:password@localhost:6379) — the API refuses to boot if it doesn’t. |
REDIS_PORT |
6379 |
Redis port | |
REDIS_PASSWORD |
— | Yes (prod) | Password for Redis authentication. Set in docker-compose and include in REDIS_URL. |
REDIS_PASSWORD_FILE |
— | Path to a file containing the Redis password (Docker secret style). Used in place of REDIS_PASSWORD when secrets are mounted as files. |
Authentication & Security
Section titled “Authentication & Security”| Variable | Default | Required | Description |
|---|---|---|---|
JWT_SECRET |
— | Yes | JWT signing key (min 32 chars). Generate: openssl rand -base64 64 |
PARTNER_API_CURSOR_SIGNING_KEY |
— | Yes (prod) | Dedicated key that signs the pagination cursors returned by the Partner Export API. Must be canonical (standard) base64 — not base64url or hex — decoding to at least 32 bytes, and must not reuse JWT_SECRET. Generate: openssl rand -base64 32. The API refuses to start in production without it. |
AGENT_ENROLLMENT_SECRET |
— | Yes | Shared secret for agent enrollment. Generate: openssl rand -hex 32 |
APP_ENCRYPTION_KEY |
— | Yes | AES encryption key for sensitive data at rest |
APP_ENCRYPTION_KEY_ID |
— | Optional identifier for the active encryption key (letters, digits, ./_/-). Setting it enables versioned, context-bound ciphertext for newly stored secrets. From v0.106.0 it is required whenever M365_GRAPH_ACTIONS_TOOLS_ENABLED=true, and the API refuses to start without it — the write-action reveal path seals its temporary credential with key-bound ciphertext and fails closed, so the credential would be discarded and never revealable. Not present in the stock compose file: add it to .env and to the api service environment: block. |
|
MFA_ENCRYPTION_KEY |
— | Yes | Encryption key for MFA secrets |
ENROLLMENT_KEY_PEPPER |
— | Yes | HMAC pepper for enrollment key hashing |
MFA_RECOVERY_CODE_PEPPER |
— | Yes | HMAC pepper for recovery code hashing |
ENROLLMENT_KEY_DEFAULT_TTL_MINUTES |
60 |
Default enrollment key expiry | |
CHILD_ENROLLMENT_KEY_TTL_MINUTES |
1440 |
Fresh TTL (minutes) given to a “child” enrollment key minted at installer-download, installer-link, or short-code redemption time, measured from mint time rather than the parent key’s remaining lifetime | |
INSTALLER_BOOTSTRAP_TOKEN_TTL_MINUTES |
1440 |
Base TTL (minutes) for a freshly-issued installer bootstrap token when no explicit ttlMinutes is supplied. The token’s own expiry, not the parent enrollment key’s, governs how long an installer has to complete enrollment — see Enrollment Keys |
|
INSTALLER_PARENT_MIN_REMAINING_SECONDS |
60 |
Minimum remaining lifetime (seconds) a parent enrollment key must have to be used as an installer source. Guards against building an installer from a parent that expires before the download reaches the target machine | |
ENROLLMENT_KEY_CLEANUP_ENABLED |
true |
Set to false to disable the daily sweep that purges expired enrollment keys |
|
ENROLLMENT_KEY_PURGE_AFTER_DAYS |
7 |
Grace period (days past expiry) before an expired enrollment key is purged | |
SESSION_SECRET |
— | Yes | Session signing secret |
Access-token (15m) and refresh-token (7d) lifetimes are fixed in code
(apps/api/src/services/jwt.ts) — short-lived access tokens backed by
refresh-token rotation — and are intentionally not configurable via
deployment env vars.
Server
Section titled “Server”| Variable | Default | Required | Description |
|---|---|---|---|
NODE_ENV |
production |
Environment mode | |
API_PORT |
3001 |
API server port | |
WEB_PORT |
4321 |
Web dashboard port | |
PUBLIC_API_URL |
https://${BREEZE_DOMAIN} (bundled compose only) |
Public API URL used for generated agent installers and shareable links. The bundled docker-compose.yml derives this from BREEZE_DOMAIN. If you use a custom compose file, you must set PUBLIC_API_URL in .env and map it into the api service environment: block, or Generate Link / Download Installer will fail with Server URL not configured. |
|
API_URL |
— | Legacy fallback for PUBLIC_API_URL, honored only by enrollment, installer, and MCP-invite code paths. Auto-update, dev-push, and a few other routes read PUBLIC_API_URL only — prefer setting PUBLIC_API_URL. |
|
BREEZE_DOMAIN |
— | Yes (prod) | Domain for Caddy TLS provisioning |
ACME_EMAIL |
— | Yes (prod) | Email for Let’s Encrypt certificate notifications |
CORS_ALLOWED_ORIGINS |
— | Comma-separated allowed CORS origins | |
IS_HOSTED |
— | Yes (prod) | true for hosted SaaS edition, false for self-hosted. Must be set explicitly — the API refuses to boot otherwise. Controls signup gating, billing, email-verification policy, and the default for signup-abuse detection. |
ABUSE_SIGNALS_ENABLED |
value of IS_HOSTED |
Signup-abuse detection switch. Defaults to the value of IS_HOSTED — on for a hosted deployment, off for a self-hosted one (its heuristics police untrusted public signups and are noise on a single-team install; when off, the only artifact is one [AbuseSignals] Disabled line at boot). Set true to opt a genuinely multi-tenant self-hosted service in, or false to switch a hosted deployment off. When set, it must be a boolean (true/false, 1/0, yes/no, on/off) — validated at boot. |
|
TRUST_PROXY_HEADERS |
— | Yes (prod) | true when behind a reverse proxy (Caddy, Cloudflare). Must be set explicitly in production. |
TRUSTED_PROXY_CIDRS |
— | When TRUST_PROXY_HEADERS=true |
Comma-separated CIDRs of trusted reverse proxies (e.g., 10.0.0.0/8,172.16.0.0/12). Required when proxy headers are trusted. |
TRUST_CF_CONNECTING_IP |
false |
true / false, 1 / 0, yes / no, on / off (empty or unset is allowed) |
Set to true only when Breeze is fronted by Cloudflare. Cloudflare overwrites the CF-Connecting-IP header at its edge, but a non-Cloudflare reverse proxy (including the bundled Caddy) does not strip it — so trusting it without a Cloudflare front would let a client spoof the header to defeat IP allowlists and per-IP rate limits. When off, client IPs are resolved from X-Forwarded-For instead. If you are behind Cloudflare and leave this unset, client-IP attribution (rate limits, audit logs, IP allowlists) falls back to X-Forwarded-For; the API logs a startup warning in that case. From v0.106.0 a value outside the list above stops the API from starting — previously a typo here was silently read as “off”, quietly attributing every client IP to the wrong address. |
IP_ALLOWLIST_ENFORCEMENT_MODE |
enforce |
Partner dashboard IP allowlist mode. Use off only as a break-glass switch if an allowlist locks everyone out. The allowlist only enforces when a partner has entries configured and proxy trust is working through TRUST_PROXY_HEADERS plus TRUSTED_PROXY_CIDRS. Only enforce, off or an empty/whitespace-only value (treated as unset) are accepted — anything else refuses startup rather than silently enforcing while you believe enforcement is off. |
|
DASHBOARD_URL |
— | URL for links in emails | |
PUBLIC_APP_URL |
— | Public-facing app URL | |
PUBLIC_PORTAL_URL |
https://${BREEZE_DOMAIN}/portal (bundled compose only) |
Public origin for customer-portal links in outbound email (e.g. invoice emails). Falls back to PUBLIC_APP_URL if unset. The portal is served under /portal on the main domain. |
|
PORTAL_BASE_PATH |
/portal |
Base path the customer portal is served under. Baked into the portal image at build time — changing it requires rebuilding the portal image and keeping the Caddy /portal route in sync. |
|
OFFBOARDING_DRAIN_WINDOW_HOURS |
72 |
Grace window (hours, minimum 1) an organization stays in the offboarding state before it is finalized to churned. Setting an org’s status to offboarding locks its users out immediately but keeps its agents reachable for this window so a queued uninstall can actually be delivered and run; once the fleet drains or the window closes, remaining commands are cancelled and the tenant is closed out. |
| Variable | Default | Description |
|---|---|---|
EMAIL_PROVIDER |
auto |
Provider: auto, resend, smtp, or mailgun |
RESEND_API_KEY |
— | Resend API key |
EMAIL_FROM |
noreply@breeze.local |
Sender address |
SMTP_HOST |
— | SMTP server hostname |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
— | SMTP username |
SMTP_PASS |
— | SMTP password |
SMTP_FROM |
noreply@breeze.local |
SMTP-specific sender address |
SMTP_SECURE |
false |
Use TLS for SMTP |
MAILGUN_API_KEY |
— | Mailgun API key |
MAILGUN_DOMAIN |
— | Mailgun sending domain |
MAILGUN_BASE_URL |
https://api.mailgun.net |
Mailgun API base URL |
MAILGUN_FROM |
noreply@breeze.local |
Mailgun-specific sender address |
SMS (Twilio)
Section titled “SMS (Twilio)”| Variable | Default | Description |
|---|---|---|
TWILIO_ACCOUNT_SID |
— | Twilio Account SID |
TWILIO_AUTH_TOKEN |
— | Twilio Auth Token |
TWILIO_VERIFY_SERVICE_SID |
— | Twilio Verify service SID (for SMS MFA) |
TWILIO_MESSAGING_SERVICE_SID |
— | Twilio Messaging Service SID (for alert SMS) |
TWILIO_PHONE_NUMBER |
— | Twilio phone number for outbound SMS |
Mobile Push Notifications (iOS)
Section titled “Mobile Push Notifications (iOS)”Push notifications to the iOS app go directly through Apple’s push service (APNs). All four values are optional but all-or-none — setting any one makes the other three required, so a half-configured setup fails at startup rather than silently at first send. Leave them all unset to disable push.
| Variable | Default | Description |
|---|---|---|
APNS_AUTH_KEY |
— | Contents of the Apple .p8 push auth key |
APNS_KEY_ID |
— | Apple push key ID |
APNS_TEAM_ID |
— | Apple developer team ID |
APNS_BUNDLE_ID |
— | iOS app bundle identifier |
APNS_ENVIRONMENT |
production |
production or sandbox. An empty value is treated as unset. |
Binary Distribution
Section titled “Binary Distribution”| Variable | Default | Required | Description |
|---|---|---|---|
BINARY_SOURCE |
github |
Download source: local (serve from disk, optional S3) or github (redirect to GitHub Releases) |
|
BINARY_EDITION |
self-host |
Agent edition this deployment serves: self-host (default — today’s behavior, leave unset on self-hosted installs) or hosted. In production, hosted requires BINARY_SOURCE=local and an explicit RELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYS, or the API refuses to boot. An unrecognized value logs a warning and falls back to self-host. |
|
BINARY_GITHUB_REPOSITORY |
lanternops/breeze |
GitHub owner/repository used for all release consumption in github mode — download redirects, release sync, and manifest validation. Shape-validated at boot in every environment (owner/repository; no ./.. segments). Point it at your signing repo to serve self-signed packages (see Sign Your Own Agent Packages). In production, overriding it requires RELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYS to be explicitly set to your manifest key — leaving the official Breeze key as the only trust root while pointing at a non-official repo is refused at boot (every sync would otherwise fail closed and silently freeze your fleet). |
|
AGENT_BINARY_DIR |
./agent/bin |
Local directory containing agent binaries | |
VIEWER_BINARY_DIR |
./viewer/bin |
Local directory containing viewer installers | |
HELPER_BINARY_DIR |
./agent/bin |
Local directory containing helper binaries. The bundled Docker Compose sets this to /data/binaries/helper. |
|
GITHUB_REPO |
— | Deprecated alias for BINARY_GITHUB_REPOSITORY, kept so a deployment that set it before the release-source unification does not silently revert to the official repo on upgrade. It is honoured only when BINARY_GITHUB_REPOSITORY is unset, logs a deprecation warning, and counts as an override for the production key requirement below. Migrate to BINARY_GITHUB_REPOSITORY. |
|
BINARY_VERSION_FILE |
— | Path to VERSION file for local mode DB registration (set automatically in Docker Compose) |
|
BINARY_VERSION |
— | Release tag used for both the download redirect and boot-time GitHub sync (falls back to BREEZE_VERSION, then latest). Point it at the newest release that is actually published on GitHub — a pin to a tag with no published release registers no agent binaries at all, and does not fall back to latest. |
|
RELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYS |
yzx8ftmcls6uBetFC5SYnZhBo+cbur3IX50TbBthTso= (official releases) |
Yes (prod) | Comma-separated raw base64 Ed25519 public keys that sign release manifests. Use the published default for official Breeze releases (it is a public key, safe to commit); only change it if you sign your own binaries. When BINARY_GITHUB_REPOSITORY points at your own signing repository, this must be your own manifest key (the workflow run summary prints it). The API refuses to start in production without it. BREEZE_RELEASE_ARTIFACT_MANIFEST_PUBLIC_KEYS is accepted as an alias. |
AGENT_AUTO_PROMOTE |
true |
Controls whether a newly registered/synced agent release immediately becomes the fleet upgrade target. true (default): syncing a release promotes it to the fleet at once. Boot-time sync is pinned to the release matching your own BREEZE_VERSION (or BINARY_VERSION), so a newly published GitHub release does not move your fleet until you upgrade your server images. false: new binaries stay downloadable but the fleet only advances when you explicitly promote a version (platform admin + MFA) — see Controlled agent fleet rollout. |
See Binary Distribution for details on local vs GitHub mode and S3 offloading.
Object Storage
Section titled “Object Storage”| Variable | Default | Description |
|---|---|---|
S3_ENDPOINT |
— | S3-compatible endpoint (MinIO, R2, AWS). Uses path-style addressing. |
S3_ACCESS_KEY |
— | Access key |
S3_SECRET_KEY |
— | Secret key |
S3_BUCKET |
— | Bucket name |
S3_REGION |
us-east-1 |
Bucket region |
S3_PRESIGN_TTL |
900 |
Presigned URL expiration in seconds (15 min) |
MINIO_API_PORT |
9000 |
MinIO API port (Docker only) |
MINIO_CONSOLE_PORT |
9001 |
MinIO web console port (Docker only) |
WebRTC / TURN
Section titled “WebRTC / TURN”The Breeze stack includes a coturn TURN server for WebRTC relay. Without TURN, remote desktop connections fail when either peer is behind symmetric NAT or a restrictive firewall.
| Variable | Default | Required | Description |
|---|---|---|---|
TURN_HOST |
— | Only with the turn profile |
Public IP or hostname of the TURN server. Must be reachable by agents and viewers. |
TURN_PORT |
3478 |
TURN listening port (UDP and TCP) | |
TURN_SECRET |
— | Only with the turn profile |
Shared secret for TURN credential generation. Generate: openssl rand -hex 32. Not needed to bring the stack up: the bundled Compose file only starts coturn under the turn profile, and when it is enabled coturn refuses to start on an empty secret rather than running as an open relay. The value is mounted as a file rather than passed on the command line. |
TURN_REALM |
breeze.local |
TURN authentication realm |
Monitoring
Section titled “Monitoring”| Variable | Default | Description |
|---|---|---|
METRICS_SCRAPE_TOKEN |
— | Bearer token for /metrics/scrape |
METRICS_INCLUDE_ORG_ID |
false |
Include org IDs in Prometheus labels |
METRICS_SCRAPE_IP_ALLOWLIST |
— | Restrict metrics scraping by IP |
LOG_LEVEL |
info |
Log verbosity: debug, info, warn, error |
LOG_JSON |
true |
Structured JSON logging |
GRAFANA_ADMIN_USER |
admin |
Grafana admin username |
GRAFANA_ADMIN_PASSWORD |
— | Grafana admin password |
GRAFANA_PORT |
3000 |
Grafana web UI port (monitoring stack) |
GRAFANA_ROOT_URL |
http://localhost:3000 |
Grafana public root URL (monitoring stack) |
PROMETHEUS_PORT |
9090 |
Prometheus web UI port (monitoring stack) |
ALERTMANAGER_PORT |
9093 |
Alertmanager web UI port (monitoring stack) |
LOKI_PORT |
3100 |
Loki log aggregation API port (monitoring stack) |
DEVICE_METRICS_RETENTION_DAYS |
30 |
Retention for the raw device_metrics series, clamped to 1–365. History charts are served from metric_rollups and are unaffected. On first run after upgrading, older raw metrics are pruned — set this before starting the new version if you need more than 30 days. |
Event-loop lag monitor
Section titled “Event-loop lag monitor”The API samples its own event-loop delay so that a stalled main thread shows up
as a stalled main thread. Without it, a stall surfaces as unrelated downstream
errors — most confusingly a Postgres write CONNECT_TIMEOUT <host>:<port>, which
names the database even though the database and the network were both healthy.
The monitor runs by default; you do not need to set any of these.
| Variable | Default | Description |
|---|---|---|
EVENT_LOOP_MONITOR_INTERVAL_MS |
1000 |
Sampling interval for event-loop delay. Values above EVENT_LOOP_STARVATION_WARN_MS leave a blind spot one interval wide — stalls shorter than one interval cannot be observed and are reported as cause unknown. The API logs a startup warning if you configure it that way. |
EVENT_LOOP_STARVATION_WARN_MS |
1000 |
Lag at or above which a reading counts as starvation. Drives the [event-loop] Main thread blocked… warning and the breeze_nodejs_eventloop_starved gauge. |
EVENT_LOOP_MONITOR_DISABLED |
— | Set to 1, true, yes, or on to turn the monitor off. With it off, every Postgres CONNECT_TIMEOUT is classified unknown, because starvation can be neither ruled in nor out. |
At startup the API prints one line stating whether it can see its own loop and at
what settings — [event-loop] Lag monitor started (interval …ms, warn threshold …ms, CONNECT_TIMEOUT attribution threshold …ms), or a warning if the monitor is
disabled. The two thresholds can differ: CONNECT_TIMEOUT attribution caps its
own threshold at the 10s connect budget, so raising the warn threshold above 10s
cannot cause a timeout to be mis-attributed.
Lag is exported on /metrics/scrape (see Monitoring Stack).
It is deliberately not reported by /health/ready: that endpoint is
unauthenticated, and a live load gradient plus the configured threshold would let
an unauthenticated prober measure whether its own traffic is starving the
instance. Readiness is also not gated on lag — starvation is a load symptom, and
failing readiness under load would shift traffic onto peers and starve those too.
Database pool-health watchdog
Section titled “Database pool-health watchdog”The API’s Postgres driver (postgres.js) can leave a pooled connection permanently
unable to reconnect if that connection’s socket dies while a write is still
buffered. The affected pool slot then retries forever, failing every time with
write CONNECT_TIMEOUT — against a database that is completely healthy. Slots
are lost one at a time, so the pool decays over hours until most queries fail at
connect. Nothing in the API can repair it: only restarting the API process
recovers the pool.
The watchdog exists because that failure is very easy to misread as a database
outage. When the CONNECT_TIMEOUT rate breaches the threshold, it opens one
brand-new connection to the same database, outside the pool. A fresh connection
is unaffected by the defect, so the result separates the two causes:
- the fresh connection succeeds → the database is fine and the pool is broken. Restart the API.
- the fresh connection also fails → a genuine database, network, TLS, or auth fault. Restarting the API will not help.
The full verdict — with counts, rates and probe timing — is written to the
container log. Sentry receives a throttled event carrying the
db_pool_health_verdict tag only; Breeze strips message text and structured
extras from every outgoing Sentry event, so treat Sentry as the alert channel
and the log or /metrics as the detail. The numbers are exported on
/metrics/scrape:
| Series | Meaning |
|---|---|
breeze_db_pool_health{verdict="…"} |
1 for the current verdict, 0 for the others. Verdicts are below-threshold, pool-degraded, database-unreachable, unknown. |
breeze_db_connect_timeouts_total{cause="…"} |
Connect timeouts by diagnosed cause (connectivity, event-loop-starvation, unknown). |
breeze_db_connect_timeout_rate_per_min |
The rate the watchdog evaluates. |
breeze_db_pool_health_last_check_timestamp_seconds |
When the last evaluation completed. 0 = never. |
breeze_db_pool_health_check_failures |
Evaluations that threw before producing a verdict. |
breeze_db_pool_health_probe_close_failures |
Probe clients whose close failed — each a possible leaked connection. |
Alert on breeze_db_pool_health{verdict="pool-degraded"} == 1. There is
deliberately no healthy verdict: the quiet one is below-threshold, because
the underlying count only sees timeouts that reach an error handler and the
watchdog never observes pool slot occupancy. It can tell you something is wrong;
it cannot certify that nothing is. For the same reason, all verdict series read
0 before the first evaluation and after a failed one — never infer health from
the absence of a bad verdict, and pair any alert with a freshness check on
breeze_db_pool_health_last_check_timestamp_seconds.
The watchdog runs by default; you do not need to set any of these.
| Variable | Default | Description |
|---|---|---|
DB_POOL_HEALTH_INTERVAL_MS |
60000 |
How often the watchdog evaluates. Floored at 5000. |
DB_POOL_HEALTH_WINDOW_MS |
300000 |
Trailing window the CONNECT_TIMEOUT count is taken over. Floored at 30000. |
DB_POOL_HEALTH_MIN_TIMEOUTS |
10 |
Timeouts within the window that trigger the fresh-connection probe. Below this, no probe runs and no connection is opened. |
DB_POOL_HEALTH_PROBE_TIMEOUT_MS |
5000 |
Hard bound on the probe. Keep it well under the interval so a slow probe cannot overlap the next tick. |
DB_POOL_HEALTH_CAPTURE_THROTTLE_MS |
900000 |
Minimum gap between Sentry captures of the same verdict. A degraded pool stays degraded until restart, so an unthrottled capture would report it every tick. 0 disables the throttle. Console logging is never throttled. |
DB_POOL_HEALTH_DISABLED |
— | Set to 1, true, yes, or on to turn the watchdog off. With it off, a poisoned pool decays silently until someone notices the 503s. |
Like event-loop lag, these figures are kept off the unauthenticated
/health/ready for the same reason — they describe how close the instance is to
falling over.
Sentry
Section titled “Sentry”Both API and web Sentry integrations are off by default. Leave the DSN variables blank to disable. See Error Tracking & Privacy for what gets collected and how scrubbing works.
| Variable | Default | Description |
|---|---|---|
SENTRY_DSN |
— | API Sentry DSN. Leave blank to disable server-side error tracking. |
SENTRY_ENVIRONMENT |
production |
Sentry environment tag. In a multi-region deployment use a region-qualified value (production-us, production-eu) — a bare production on every region makes them indistinguishable in Sentry, and server_name is a per-deploy container hash, so nothing else can attribute an error to a region. |
SENTRY_RELEASE |
— | Sentry release tag (e.g. git SHA) |
SENTRY_TRACES_SAMPLE_RATE |
0.1 |
Sentry performance trace sample rate (0.0-1.0) |
SENTRY_PROFILES_SAMPLE_RATE |
— | API performance-profiling sample rate (0.0-1.0). Leave blank to disable profiling. |
PUBLIC_SENTRY_DSN_WEB |
— | Web Sentry DSN. Leave blank to disable browser error tracking and on-error session replay. Inlined into the web bundle at build time. |
SENTRY_AUTH_TOKEN |
— | Sentry auth token used during the web build to upload source maps. If unset, source map upload is skipped and the build still succeeds. |
Rate Limiting
Section titled “Rate Limiting”Per-user and per-endpoint rate limits (login, MFA, dashboard usage) are fixed in code and not configurable via environment variables. The one deployment-tunable limit is the per-org agent cap, which bounds how much traffic a single tenant’s fleet can generate, even with thousands of agents.
| Variable | Default | Description |
|---|---|---|
AGENT_ORG_RATE_LIMIT_PER_MIN |
600 |
Floor for the per-organization sliding-window rate limit on agent-authenticated endpoints. The effective ceiling scales with the org’s enrolled device count (see AGENT_ORG_RATE_LIMIT_PER_DEVICE), so this value only applies to small orgs. Returns 429 with a Retry-After derived from the window reset. Not mapped in the stock compose file — to change it, add it to the api service environment: block as well as .env. |
AGENT_ORG_RATE_LIMIT_PER_DEVICE |
12 |
Requests per minute allowed per enrolled device when sizing the per-organization ceiling. A steady-state agent uses ~2 req/min, so the default leaves roughly 6x headroom for synchronized bursts, manual scans and command traffic. |
AGENT_ORG_RATE_LIMIT_MAX |
20000 |
Hard platform ceiling for the per-organization limit, regardless of device count. Keeps the per-tenant guardrail meaningful for very large fleets. |
Database
Section titled “Database”| Variable | Default | Description |
|---|---|---|
DB_POOL_MAX |
30 |
Maximum postgres-js connection pool size. Tune up if you see cascading 504s during heartbeat storms; confirm Postgres max_connections has headroom (default 100 is fine for a single API replica). |
Remote Sessions & Storage
Section titled “Remote Sessions & Storage”| Variable | Default | Description |
|---|---|---|
MAX_ACTIVE_REMOTE_SESSIONS_PER_ORG |
10 |
Concurrent remote sessions per org |
MAX_ACTIVE_REMOTE_SESSIONS_PER_USER |
5 |
Concurrent remote sessions per user |
PATCH_REPORT_STORAGE_PATH |
./data/patch-reports |
Patch compliance report storage |
Rollout Modes
Section titled “Rollout Modes”Required in production (and when DEPLOYMENT_ENV=staging). The API refuses to
boot without them, and the shipped compose files use ${VAR:?} so the container
stops before it starts. In development they fall back to the defaults shown.
Full explanation of what each one is for: Rollout Modes & Compatibility Switches.
| Variable | Default | Description |
|---|---|---|
EVENT_PERMISSION_EPOCH_MODE |
compat |
compat accepts event-WebSocket tickets from pre-upgrade servers; enforce rejects them. Move to enforce ≥60s after the last old server stops. A malformed value falls back to enforce (fails closed). |
REMOTE_WS_AUTH_MODE |
post_upgrade |
post_upgrade accepts remote-session tickets minted by pre-upgrade servers; pre_upgrade rejects them and applies full authorization before the WebSocket upgrade. Stay on post_upgrade until every legacy viewer token has expired. |
REMOTE_ACCESS_ADMISSION_MODE |
open |
Remote-access barrier. closed returns 503 for every terminal/desktop/VNC/tunnel endpoint at the proxy. Used during the lease cutover and for maintenance. |
REMOTE_WS_REDIS_TOPOLOGY |
standalone-single-primary |
Attestation that Redis is a single primary with AOF on, noeviction, cluster disabled. Only accepted value. Sentinel/Cluster/HA Redis will freeze remote admission by design. |
REMOTE_WS_LEGACY_TICKET_WRITER_DRAINED_AT |
— | Timestamp the last pre-upgrade ticket writer stopped. Used to compute the barrier-reopen deadline. |
REMOTE_WS_LEGACY_VIEWER_ISSUER_DRAINED_AT |
— | Timestamp the last legacy viewer-token issuer stopped. Gates the move to pre_upgrade. |
OAUTH_AUTH_EPOCH_ENFORCE_AFTER |
— | Required when MCP OAuth is enabled in production/staging. Absolute UTC timestamp ending the compatibility window for access tokens issued before the live-revocation release. Must be ≥1800s after new token writers start. Choose once; never extend on restart. |
Security Enforcement (opt-in)
Section titled “Security Enforcement (opt-in)”These ship disabled. Nothing changes on upgrade — each is a separate decision with its own rollout gate. See Rollout Modes.
| Variable | Default | Description |
|---|---|---|
AGENT_MTLS_BINDING_MODE |
off |
audit counts certificate/device binding mismatches without denying; enforce denies them. Requires a validating proxy. Never inferred from NODE_ENV/IS_HOSTED/CF_MTLS_*. |
MANAGED_SOFTWARE_POLICY_MODE |
compat |
enforce requires an upgraded agent for every managed-software command, public destinations included. Check your not-yet-upgraded device count first — they are denied outright. |
AGENT_REQUIRE_MANIFEST_SIGNING_KEY_ID |
false |
Requires update manifests to carry a signing key ID. Not fleet-wide: older agent builds ignore the pushed value. Only enable once the missing-ID count has been zero for 7 consecutive days. |
Feature Flags
Section titled “Feature Flags”| Variable | Default | Description |
|---|---|---|
ENABLE_REGISTRATION |
true |
Allow new user registration |
PENDING_ACCOUNT_MEETING_URL |
— | Optional absolute http(s) URL to a scheduling page. When set, a partner whose account is still pending sees it as a secondary link beside the billing call-to-action on the account-inactive screen, so a partner who cannot complete payment is not left at a dead end. Values that are not http/https are ignored. |
PENDING_ACCOUNT_MEETING_LABEL |
Book a call with us |
Optional link text for the pending-account booking link above. |
ENABLE_2FA |
true |
Enable two-factor authentication |
ENABLE_API_DOCS_UI |
on outside production, off in production | Serve the interactive Swagger UI and OpenAPI spec at /api/v1/docs. The UI loads its scripts and styles from unpkg, which is why it defaults off in production. (The former ENABLE_API_DOCS variable was never read and has been removed.) |
USE_AGENT_SDK |
— | Use Claude Agent SDK for AI chat |
PORTAL_STATE_BACKEND |
memory |
Portal state backend: memory or redis (auto redis in production) |
PUBLIC_ENABLE_EDR_INTEGRATIONS |
false |
Surface the EDR UI: the /security/edr fleet page, the device Security-tab EDR panel, and the Security dashboard summary cards. Set true at web image build time to enable. |
PUBLIC_ENABLE_NETWORK_DEVICES_IN_LIST |
false |
Surface network-discovered assets alongside agent endpoints in the Devices list view. Set true at web image build time to enable. |
BREEZE_WORKSPACE_ENABLED |
false in the production compose files (true in the dev override) |
Load the built-in AI Workspace extension. Off by default — leave it unset unless you want Workspace. Requires a PostgreSQL image that provides the pgvector extension (see the aside below). |
MCP Server
Section titled “MCP Server”| Variable | Default | Description |
|---|---|---|
MCP_SSE_RATE_LIMIT_PER_MINUTE |
30 |
SSE connection rate limit per API key |
MCP_MESSAGE_RATE_LIMIT_PER_MINUTE |
120 |
Message rate limit per API key |
MCP_MAX_SSE_SESSIONS_PER_KEY |
5 |
Max concurrent SSE sessions per API key |
MCP_REQUIRE_EXECUTE_ADMIN |
false |
Require ai:execute_admin scope for Tier 3 tools |
MCP_EXECUTE_TOOL_ALLOWLIST |
— | Comma-separated allowed Tier 3 tools (empty = deny all) |
Cloudflare mTLS
Section titled “Cloudflare mTLS”| Variable | Default | Description |
|---|---|---|
CLOUDFLARE_API_TOKEN |
— | Cloudflare API token with Client Certificates permission |
CLOUDFLARE_ZONE_ID |
— | Cloudflare zone ID for your domain |
Cloudflare Access JWT trust
Section titled “Cloudflare Access JWT trust”| Variable | Default | Description |
|---|---|---|
CF_ACCESS_TRUST_ENABLED |
false |
Set true to short-circuit POST /auth/login when a valid Cf-Access-Jwt-Assertion header is present. Off by default. See Cloudflare Access trust. |
CF_ACCESS_TEAM_DOMAIN |
— | Required when trust is enabled. Bare hostname of your Cloudflare team domain, e.g. example.cloudflareaccess.com. No scheme. |
CF_ACCESS_AUD |
— | Required when trust is enabled. AUD tag for the Cloudflare Access application protecting Breeze. |
CF_ACCESS_TRUSTS_MFA |
false |
Treat a valid CF Access JWT as MFA-satisfied for the minted Breeze session. Only enable if your CF Access policy actually requires step-up MFA. |
Cloud-to-Cloud Backup (M365)
Section titled “Cloud-to-Cloud Backup (M365)”| Variable | Default | Description |
|---|---|---|
C2C_M365_CLIENT_ID |
— | Azure AD app (client) ID for multi-tenant M365 backup. When set with the secret below, enables one-click admin consent flow for connecting Microsoft 365 backups. |
C2C_M365_CLIENT_SECRET |
— | Azure AD app client secret for multi-tenant M365 backup |
Inbound email-to-ticket (Mailgun)
Section titled “Inbound email-to-ticket (Mailgun)”These power the native inbound address — the partner@yourdomain address shown on the Inbound email card, which customers mail directly (or which you forward an existing support mailbox to). This is the self-hosted equivalent of what we configure once, centrally, on hosted Breeze. It is independent of the Microsoft 365 mailbox path below: you can run either, both, or neither.
| Variable | Default | Description |
|---|---|---|
TICKETS_INBOUND_DOMAIN |
— | The domain inbound ticket addresses live on, e.g. tickets.yourmsp.com. Each partner’s address is <local part>@<this domain>. Until this is set, the Inbound email card reports that no platform inbound domain is configured (naming this variable and linking back here on self-hosted instances) and no address can be generated. |
MAILGUN_INBOUND_SIGNING_KEY |
— | Mailgun’s HTTP webhook signing key, used to HMAC-verify deliveries. The inbound webhook is unauthenticated by design — this signature is its only security — so when the key is unset every delivery is rejected with 401. |
Mailgun setup
Section titled “Mailgun setup”Setting the variables is only half of it — mail has to reach Breeze:
- Add
tickets.yourmsp.comto Mailgun as a receiving domain and publish the MX records it gives you. - Create a Mailgun Route matching that domain (
catch_all(), ormatch_recipient(".*@tickets.yourmsp.com")) with the actionforward("https://<PUBLIC_URL>/api/v1/webhooks/tickets/email-inbound"). - Copy the domain’s HTTP webhook signing key into
MAILGUN_INBOUND_SIGNING_KEY. - Set
TICKETS_INBOUND_DOMAIN, restart the API, then set the local part and enable email-to-ticket under Settings → Ticketing → Inbound email.
Two failure modes are silent from the sender’s side, so check these first if mail vanishes:
- Every delivery returns
401—MAILGUN_INBOUND_SIGNING_KEYis unset or wrong. Mailgun treats this as permanent and will not retry. - Deliveries return
429— the webhook’s per-IP rate limiter fails closed, so this is also what you get when Redis is unreachable. ConfirmTRUSTED_PROXY_CIDRSmatches your reverse proxy, or the limiter keys every request to the proxy’s own IP.
Breeze trusts the Authentication-Results header only when Mailgun itself stamped it, and requires DMARC pass to mark a sender verified — SPF and DKIM alone are not sufficient.
Ticket Mailbox (M365 email-to-ticket)
Section titled “Ticket Mailbox (M365 email-to-ticket)”These enable partners to connect a Microsoft 365 shared support mailbox so customer email becomes tickets and replies are sent from that mailbox. This is a separate Azure app registration from the Cloud-to-Cloud Backup app above, and an alternative to the Mailgun inbound path above — it needs no MX records or DNS changes.
| Variable | Default | Description |
|---|---|---|
TICKET_MAILBOX_M365_CLIENT_ID |
— | Azure AD app (client) ID for the Breeze Ticketing app. When set with the secret, the Microsoft 365 mailbox card appears under Settings → Ticketing → Inbound Email. |
TICKET_MAILBOX_M365_CLIENT_SECRET |
— | Client secret for the Breeze Ticketing Azure app. |
PUBLIC_TICKET_MAILBOX_APP_ID |
— | Build-time, browser-exposed app id shown in the Application Access Policy PowerShell snippet on the settings card. Safe to publish – it is the app’s public client id. |
Customer Microsoft 365 Graph-read consent (optional)
Section titled “Customer Microsoft 365 Graph-read consent (optional)”This feature is off by default and safe to leave unset — every variable below stays empty in a normal deployment. The API only reads and validates them when you turn the feature on. Concretely, the API refuses to boot only when M365_CUSTOMER_GRAPH_READ_ONBOARDING_ENABLED=true and one of the required variables is missing or malformed; while the flag is off (or absent) these variables are never parsed and any values you set are ignored.
Customer Graph-read lets a customer administrator grant read-only Microsoft Graph consent to Breeze’s dedicated multi-tenant application without handing you a reusable secret. The Entra certificate and Key Vault access live entirely inside an isolated executor sidecar — the API holds only an opaque, version-pinned reference and a private key it uses to sign short-lived internal requests to that sidecar. See Customer Graph Read for the admin-facing side.
| Variable | Required when enabled | Format / example | Meaning |
|---|---|---|---|
M365_CUSTOMER_GRAPH_READ_ONBOARDING_ENABLED |
— (the switch) | true / false (default false) |
Master switch for new consent initiation only. Existing connection status, retest, and disconnect flows are independent of it. When false, none of the variables below are validated. |
M365_CUSTOMER_GRAPH_READ_ONBOARDING_ORG_IDS |
Yes | Literal * or comma-separated canonical org UUIDs (e.g. * or 1111...,2222...) |
Which Breeze organizations may start consent. * = all organizations; otherwise an allowlist of canonical UUIDs. Boot fails if enabled and this is empty. |
M365_CUSTOMER_GRAPH_READ_CLIENT_ID |
Yes | Canonical UUID | Entra application (client) ID of the shared multi-tenant Graph-read app. |
M365_CUSTOMER_GRAPH_READ_CREDENTIAL_VERSION |
Yes | Exactly 32 lowercase hex characters | Pins which stored credential version the executor must use. Must equal the version segment of M365_CUSTOMER_GRAPH_READ_VAULT_REF. |
M365_CUSTOMER_GRAPH_READ_VAULT_REF |
Yes | akv://<vault-host>/m365-customer-graph-read/<32-lowercase-hex-version> |
Opaque Key Vault reference to the app’s certificate material. The trailing version segment must match M365_CUSTOMER_GRAPH_READ_CREDENTIAL_VERSION, or boot fails. |
M365_GRAPH_READ_EXECUTOR_URL |
Yes | Origin-only HTTPS, e.g. https://m365-executor.internal (no path, query, fragment, or userinfo) |
Private base URL of the executor sidecar. Anything other than a bare https://host[:port] origin is rejected at boot. |
M365_GRAPH_READ_EXECUTOR_AUDIENCE |
Yes | Literal m365-graph-read-executor |
JWT audience for the API→executor internal auth token. Must equal that exact string. |
M365_GRAPH_READ_EXECUTOR_SIGNING_KID |
Yes | Non-empty string | Key ID of the API’s signing JWK. If the JWK file carries a kid, it must match this value. |
M365_GRAPH_READ_EXECUTOR_SIGNING_PRIVATE_JWK_FILE |
Yes | Absolute path to a file, e.g. /run/secrets/m365_graph_read_executor_signing_private_jwk |
The API’s Ed25519 (kty: OKP, crv: Ed25519) private signing JWK. The file must be a regular file whose mode denies all group/other access (0600 or stricter) and should be mounted read-only. Boot fails on a symlink, a relative path, over-broad permissions, or a JWK that isn’t a valid 32-byte Ed25519 key. Do not put the private JWK inline in an env var. |
Customer Microsoft 365 Graph-actions (write) executor (optional)
Section titled “Customer Microsoft 365 Graph-actions (write) executor (optional)”Like Graph-read above, this feature is off by default and safe to leave unset — every variable below stays empty in a normal deployment. The API only reads and validates them when M365_GRAPH_ACTIONS_TOOLS_ENABLED=true; while the flag is off (or absent) these variables are never parsed and any values you set are ignored.
Where Graph-read is read-only inventory, Graph-actions authorises write operations against a customer’s Microsoft 365 tenant — the identity actions the AI can propose, such as resetting a user’s password, disabling a user, or changing mailbox rules. Those tools are Tier 3, so they still require human approval before anything executes (see AI Risk Engine → Approval workflow).
It uses the same isolation model as Graph-read: the Entra certificate and Key Vault access live entirely inside a separately deployed executor sidecar, and the API holds only an opaque version-pinned reference plus a private key it uses to sign short-lived internal requests.
| Variable | Required when enabled | Format / example | Meaning |
|---|---|---|---|
M365_GRAPH_ACTIONS_TOOLS_ENABLED |
— (the switch) | true / false, 1 / 0, yes / no, on / off (default false) |
Master switch for the M365 write-action tools. When false, none of the variables below are validated and the write tools are unavailable. From v0.106.0 a value outside the list above stops the API from starting (it was previously read as “off”, silently leaving the write tools dark), and turning it on additionally requires APP_ENCRYPTION_KEY_ID. |
M365_GRAPH_ACTIONS_TOOLS_ORG_IDS |
Yes | Literal * or comma-separated canonical org UUIDs |
Which Breeze organizations may use M365 write actions. * = all organizations; otherwise an allowlist. Leave empty while the feature is off. |
M365_CUSTOMER_GRAPH_ACTIONS_CLIENT_ID |
Yes | Canonical UUID | Entra application (client) ID of the multi-tenant Graph-actions app. |
M365_CUSTOMER_GRAPH_ACTIONS_CREDENTIAL_VERSION |
Yes | Exactly 32 lowercase hex characters | Pins which stored credential version the executor must use. Must equal the version segment of M365_CUSTOMER_GRAPH_ACTIONS_VAULT_REF. |
M365_CUSTOMER_GRAPH_ACTIONS_VAULT_REF |
Yes | akv://<vault-host>/m365-customer-graph-actions/<32-lowercase-hex-version> |
Opaque Key Vault reference to the app’s certificate material. The trailing version segment must match M365_CUSTOMER_GRAPH_ACTIONS_CREDENTIAL_VERSION. |
M365_GRAPH_ACTIONS_EXECUTOR_URL |
Yes | Origin-only HTTPS, e.g. https://m365-actions.internal (no path, query, fragment, or userinfo) |
Private base URL of the Graph-actions executor sidecar. Must be HTTPS; anything other than a bare origin is rejected at boot. |
M365_GRAPH_ACTIONS_EXECUTOR_AUDIENCE |
Yes | Literal m365-graph-actions-executor |
JWT audience for the API→executor internal auth token. Must equal that exact string. |
M365_GRAPH_ACTIONS_EXECUTOR_SIGNING_KID |
Yes | Non-empty string | Key ID of the API’s signing JWK. If the JWK file carries a kid, it must match this value. |
M365_GRAPH_ACTIONS_EXECUTOR_SIGNING_PRIVATE_JWK_FILE |
Yes | Absolute path, e.g. /run/secrets/m365_graph_actions_executor_signing_private_jwk |
The API’s Ed25519 private signing JWK. Same rules as the Graph-read key: a regular file, mode 0600 or stricter, mounted read-only. Never inline the private JWK in an env var. |
Docker Deployment
Section titled “Docker Deployment”| Variable | Default | Description |
|---|---|---|
BREEZE_VERSION |
latest |
Breeze release tag for Docker images (e.g. 0.50.0). Also used by GitHub redirect mode for agent binary downloads. |
DOCKER_PLATFORM |
linux/amd64 |
Container platform. GHCR images are amd64 only. On Apple Silicon, use docker-compose.override.yml.local-build to build native arm64 images instead. |
REDIS_MAXMEMORY |
256mb |
Maximum memory Redis is allowed to use. Redis runs with noeviction policy so BullMQ jobs are never silently dropped. |
Billing
Section titled “Billing”| Variable | Default | Description |
|---|---|---|
BILLING_SERVICE_URL |
— | URL of the billing service for AI credit checks and deductions. When unset, AI usage is unlimited. |
BILLING_SERVICE_API_KEY |
— | API key for authenticating to the billing service |
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Anthropic API key for AI assistant (BYOK) |
ANTHROPIC_BASE_URL |
— | Self-hosted only. Point the AI Agent at an Anthropic-compatible /v1/messages backend (vLLM 0.23+, LiteLLM). Must be an http(s) URL. Permitted only when IS_HOSTED is explicitly false (or 0/no/off); refused at boot if IS_HOSTED is true, unset, or invalid. |
ANTHROPIC_AUTH_TOKEN |
— | Bearer token for the custom backend (use instead of ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is set). |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Override the default model id for a raw vLLM backend. Leave unset with a LiteLLM gateway that aliases claude-sonnet-4-6 → your model. |
Self-hosted: alternative Anthropic-compatible backends
Section titled “Self-hosted: alternative Anthropic-compatible backends”A self-hosted operator can run the AI Agent against any backend that speaks the
Anthropic /v1/messages dialect — keeping full tool-use and the aiGuardrails
stack — by setting:
IS_HOSTED=false # required — ANTHROPIC_BASE_URL is refused otherwiseANTHROPIC_BASE_URL=http://localhost:8000 # your vLLM / LiteLLM endpointANTHROPIC_AUTH_TOKEN=<backend bearer token># Raw vLLM only — match the served model id (skip with a LiteLLM alias):ANTHROPIC_MODEL=<served-model-id>The recommended route is a LiteLLM gateway that aliases claude-sonnet-4-6
to your backend model, so no ANTHROPIC_MODEL override is needed. For an
OpenAI-dialect-only endpoint, use the separate MCP_LLM_* openai-compatible
path instead (chat-only, no tool-use).
ANTHROPIC_BASE_URL is fail-closed: it is honored only when self-host is
affirmatively declared (IS_HOSTED explicitly false/0/no/off). If
IS_HOSTED is true, unset, or set to an unrecognized value, the API refuses
to boot when ANTHROPIC_BASE_URL is present, and the value is also stripped
from the SDK subprocess environment — so a stray or unmapped IS_HOSTED can
never redirect platform AI traffic to a third-party endpoint.
Cost tracking is best-effort. The SDK can’t price a non-Anthropic model id, so
it reports total_cost_usd: 0; Breeze then falls back to token-based pricing,
and an unrecognized model id is priced at conservative Opus-tier
DEFAULT_PRICING ($5/$25 per MTok) — i.e. likely an over-estimate for a
cheap local model, not $0. For accurate accounting, add the served model id to
MODEL_PRICING in aiCostTracker.ts, or use the openai-compatible path’s
MCP_LLM_PRICE_* overrides.
Accounting (QuickBooks Online)
Section titled “Accounting (QuickBooks Online)”| Variable | Default | Description |
|---|---|---|
QBO_CLIENT_ID |
— | Intuit OAuth client ID for the QuickBooks Online integration |
QBO_CLIENT_SECRET |
— | Intuit OAuth client secret |
QBO_REDIRECT_URI |
— | OAuth callback URL registered with Intuit (e.g. https://your-domain/accounting/quickbooks/callback) |
QBO_ENVIRONMENT |
— | sandbox or production |
Vulnerability Management
Section titled “Vulnerability Management”| Variable | Default | Description |
|---|---|---|
NVD_API_KEY |
— | Optional NIST NVD API key. Raises NVD request rate limits so vulnerability feed syncs run faster. Without it, syncs use the slower public rate; the feature works either way. |