Configuration¶
Every setting is read from an environment variable; the .env file is gitignored and never
committed. In production the backend refuses to boot with placeholder or weak secrets, with a
datastore URL still carrying an example or default password, with rate limiting switched off, or
with TRUST_PROXY_HEADERS left unset. Every problem is reported in one message, naming the
variables and never their values.
Generate the two required secrets before the first run:
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 32 # API_KEY_MASTER_KEY (32-byte AES-256 master key)
.env.example carries the full annotated list; .env.prod.example is the production
variant. The tables below group the settings by concern.
Databases¶
| Variable | Description | Default |
|---|---|---|
POSTGRES_URL |
Async PostgreSQL connection string. Production refuses to boot on this default, on a leftover CHANGE_ME, or on a guessable password (one equal to the username, or a compose default like password) |
postgresql+asyncpg://maestro:maestro@localhost:5433/maestro |
MONGODB_URL |
MongoDB connection string. Port 27018, not the stock 27017 — a natively-installed MongoDB binds loopback and beats Docker's wildcard bind, so 27017 can silently reach the wrong server. Production refuses to boot on this default or on a leftover CHANGE_ME |
mongodb://localhost:27018 |
MONGODB_DB_NAME |
MongoDB database name | maestro |
QDRANT_URL |
Qdrant vector DB address | http://localhost:6333 |
QDRANT_API_KEY |
Qdrant API key (optional for local, and optional in production too — but a leftover CHANGE_ME is rejected at boot) |
— |
Security & auth¶
| Variable | Description | Default |
|---|---|---|
JWT_SECRET |
JWT signing secret — random and confidential, min 32 chars in production | — |
JWT_ALGORITHM |
JWT signing algorithm | HS256 |
ACCESS_TOKEN_EXPIRE_MINUTES |
Access token lifetime | 30 |
REFRESH_TOKEN_EXPIRE_DAYS |
Refresh token lifetime | 7 |
REFRESH_COOKIE_SECURE |
Secure on the refresh cookie. Unset means "on outside development" — Safari refuses a Secure cookie over http://localhost, so a hard true would break npm run dev. Production refuses to boot with it off. |
unset |
REFRESH_COOKIE_SAMESITE |
strict or lax. The only CSRF control on /auth/refresh and /auth/logout; none is rejected at boot. |
strict |
REFRESH_COOKIE_DOMAIN |
Empty = host-only. Naming a domain shares the session cookie with every subdomain; only for an app. / api. split under one registrable domain. |
— |
API_KEY_MASTER_KEY |
AES-256-GCM master key for encrypting BYOK keys (32 bytes, base64 or hex) | — |
CORS_ORIGINS |
Allowed frontend origins (comma-separated) | http://localhost:3000 |
LLM_SSRF_GUARD_ENABLED |
Validate custom provider endpoints (http(s), credential-free, public addresses only); disable only on a fully self-hosted stack | true |
Rate limiting¶
| Variable | Description | Default |
|---|---|---|
REDIS_URL |
Redis for shared sliding-window buckets; empty falls back to in-process memory (single dev worker). A leftover CHANGE_ME or a guessable password is rejected in production — it would otherwise boot and silently fall back to per-process buckets |
— |
RATE_LIMIT_ENABLED |
Master throttle switch. false is rejected at boot in production |
true |
TRUST_PROXY_HEADERS |
true only behind a proxy that appends X-Forwarded-For (e.g. Caddy). Unset is rejected in production: neither value is safe to guess |
unset (means false) |
TRUST_PROXY_HEADERS cuts both ways: exposed directly to the internet, a client forges the
header and opens a fresh bucket per request; left false behind a proxy, every user shares
the proxy's single bucket. That is why there is no default worth shipping: production refuses to
boot until the variable is set explicitly, while development and the test suite treat unset as
false. Code reads the resolved settings.proxy_headers_are_trusted, never the tri-state field.
Models & embeddings¶
| Variable | Description | Default |
|---|---|---|
FREE_MODEL_ENDPOINT |
Ollama OpenAI-compatible endpoint | http://localhost:11434/v1 |
FREE_MODEL_NAME |
Free-tier / local model | qwen3.5:9b |
OLLAMA_CHAT_ENABLED |
Serve the local Ollama chat model; false on hosted deployments where the Ollama service only runs embeddings |
true |
OLLAMA_NATIVE_TOOLS |
Use Ollama's native function calling instead of the directive fallback | false |
EMBEDDING_ENDPOINT |
Embedding endpoint; reuses FREE_MODEL_ENDPOINT when blank |
— |
EMBEDDING_MODEL_NAME |
RAG embedding model | nomic-embed-text |
EMBEDDING_DIM |
Embedding vector dimension | 768 |
GEMINI_MODEL_NAME |
Gemini model id; the -latest alias survives model retirements — pin a stable id for deterministic behavior |
gemini-flash-latest |
LLM_REQUEST_TIMEOUT_SECONDS |
Per-LLM-call read timeout | 180 |
LLM_CONNECT_TIMEOUT_SECONDS |
Per-LLM-call connect timeout | 10 |
Agent tools¶
| Variable | Description | Default |
|---|---|---|
WEB_SEARCH_ENABLED |
DuckDuckGo web-search tool | true |
WEB_SEARCH_MAX_RESULTS |
Results per query | 5 |
WEB_SEARCH_TIMEOUT_SECONDS |
Per-query timeout | 10 |
WEB_SEARCH_MAX_USES_PER_SUBTASK |
Searches per subtask | 3 |
DATA_FETCH_ENABLED |
Data-fetch tool (Scrapling: TLS-impersonating GET → page text, or CSS-selected JSON) | true |
DATA_FETCH_TIMEOUT_SECONDS |
Per-fetch timeout | 15 |
DATA_FETCH_MAX_USES_PER_SUBTASK |
Fetches per subtask | 3 |
DATA_FETCH_ENGINE |
scrapling or httpx (the pre-Scrapling path: no selectors, no impersonation, but a streaming size cap) |
scrapling |
DATA_FETCH_RENDER_ENABLED |
Browser tier for JS-rendered pages; needs scrapling install in the image plus ~1GB RAM. Self-host only — the tool works fully without it |
false |
DATA_FETCH_RENDER_TIMEOUT_SECONDS |
Per-render timeout | 45 |
DATA_FETCH_RENDER_MAX_CONCURRENCY |
Concurrent browser renders | 1 |
REPO_INTEL_ENABLED |
GitHub repository intelligence for the Open Source squad. Works with no key at all (anonymous reads, 60/hour); a user's stored token raises it to 5000 | true |
SOCIAL_SEARCH_ENABLED |
X post search for the Social Listening squad. Needs the user's X key; withheld without one | true |
COMMUNITY_READ_ENABLED |
Discord / Slack / Telegram channel reading for the Community squad. Needs the user's key for that platform | true |
PLACES_INTEL_ENABLED |
Google Places lookup for the Local Market squad. Needs the user's Maps key | true |
CODE_EXECUTION_ENABLED |
Docker code sandbox. Off by default and self-host only: it needs access to the Docker daemon, so enabling it on a hosted deployment puts the host in the tool's blast radius | false |
CODE_EXECUTION_IMAGE |
Sandbox container image | python:3.12-slim |
CODE_EXECUTION_TIMEOUT_SECONDS |
Per-run timeout | 30 |
CODE_EXECUTION_MEMORY_LIMIT / CODE_EXECUTION_CPUS |
Sandbox resource limits | 512m / 1 |
CODE_EXECUTION_MAX_USES_PER_SUBTASK |
Runs per subtask | 3 |
A missing service key is deliberately not fatal: the tool is withheld from the squad, the
members fall back to web_search, and the answer's mandatory data-coverage section states
what could not be reached. A missing brain key stops the task and tells the user.
Agent limits & execution¶
| Variable | Description | Default |
|---|---|---|
MAX_ITERATIONS |
Max steps per Subagent | 10 |
MAX_REVIEW_ITERATIONS |
Reviewer ↔ Subagent loop limit | 3 |
TASK_TIMEOUT_SECONDS |
Total timeout per task (whole pipeline) | 1800 |
SUBAGENT_MAX_PARALLEL |
Concurrent Subagents per task | 3 |
SUBAGENT_MAX_TOOL_CALLS |
Total tool calls (all kinds) per subtask | 6 |
REVIEWER_FAIL_MODE |
What a reviewer crash means for the subtask: warn, approve, or reject |
warn |
TASK_RETENTION_DAYS |
Mongo TTL on task sessions + agent logs; dashboard metrics cover this window | 30 |
Payments¶
| Variable | Description | Default |
|---|---|---|
PAYMENT_PROVIDER |
Payment gateway; only mock is implemented (Luhn/BIN validation, moves no real money) |
mock |
BILLING_ENABLED |
Whether paid plans are reachable. false parks them: subscribe/cancel answer 403 for everyone but admins, and every account runs on the unlimited free plan. Flip together with BILLING_LIVE in frontend/src/lib/legal/config.ts |
false |
GRANT_ADMIN_EMAILS |
Comma-separated accounts python -m app.scripts.grant_admin promotes to admin |
(empty) |
Plan prices and quotas are product constants in backend/app/core/constants.py, not
environment variables.
Transactional email¶
| Variable | Description | Default |
|---|---|---|
EMAIL_PROVIDER |
console logs messages (dev / self-host — verification links are read from the log); resend sends via the Resend API |
console |
RESEND_API_KEY |
Required when EMAIL_PROVIDER=resend; checked at boot in production |
— |
EMAIL_FROM |
From header for outgoing mail | Maestro <noreply@maestro.example.com> |
SITE_URL |
Base URL the backend uses to build verification / reset links | http://localhost:3000 |
EMAIL_VERIFICATION_REQUIRED |
Soft-gates task start, API-key creation and custom-API-tool writes until the email is verified. Ships off: the default console sender delivers nothing to an inbox, so an enforced gate would lock a fresh install rather than protect it. Enable it only alongside a real sender, and flip EMAIL_VERIFICATION_LIVE in frontend/src/lib/legal/config.ts with it |
false |
Signup abuse protection¶
Two protections guard the endpoints that send mail. Neither needs configuring.
The per-recipient mail budget counts sends per recipient address and is
always on — rate_limit keys by caller, and every one of these endpoints lets
the caller choose who receives the mail, so a caller-keyed limit measures the
wrong thing. The anti-automation check (honeypot plus a server-signed form
challenge) guards the two unauthenticated endpoints, /auth/register and
/auth/forgot-password; a CAPTCHA provider is an optional third layer on top.
Rejections are silent by design: the endpoint answers with its normal success
body, because those endpoints already guarantee a response that reveals nothing
about whether an address exists. Watch maestro_abuse_rejected_total{reason} on
/metrics — a silent rejection is invisible to the user, so that counter is the
only place a false positive shows up.
| Variable | Description | Default |
|---|---|---|
CAPTCHA_PROVIDER |
none or turnstile. none is not "unprotected": the honeypot and form-challenge layers still run and no third party is contacted. There is deliberately no production guard forcing a provider — requiring one would refuse to boot every self-hosted deployment, the same trap EMAIL_VERIFICATION_REQUIRED avoids by shipping off |
none |
CAPTCHA_SITE_KEY |
Cloudflare Turnstile site key. Served to the browser at runtime by GET /api/v1/auth/challenge, never baked into the frontend bundle, so turning a provider on needs no rebuild |
— |
CAPTCHA_SECRET_KEY |
Turnstile secret, used server-side only. Production refuses to boot if either key is empty while CAPTCHA_PROVIDER=turnstile: a provider that cannot verify fails closed on every call and stops registration silently |
— |
Turnstile verification fails closed — if Cloudflare is unreachable, the submission is refused. That costs nothing in practice: when siteverify is down the widget did not load for real users either, so they hold no token regardless, and failing open would relax the gate only for clients that never needed it.
App & observability¶
| Variable | Description | Default |
|---|---|---|
ENVIRONMENT |
production enforces strong secrets and closes Swagger |
development |
LOG_LEVEL |
Application log level | INFO |
LOG_FORMAT |
text for local dev, json for structured logs in production |
text |
SENTRY_DSN |
Sentry error tracking; empty disables Sentry entirely | — |
SENTRY_TRACES_SAMPLE_RATE |
Tracing/APM sample rate (0.0 = off) |
0.0 |
SENTRY_ENVIRONMENT |
Sentry environment tag; falls back to ENVIRONMENT |
— |
TRACING_ENABLED |
Per-task span tracing (Mongo trace_spans); disabled = zero overhead |
false |
TRACE_RETENTION_DAYS |
TTL on stored trace spans | 30 |
HEALTH_DETAIL_TOKEN |
Unlocks the per-dependency checks map on /health/ready for callers sending it as X-Health-Token; empty withholds it from everyone. Grants nothing else |
— |
Operator alerting & metrics¶
Self-contained: no monitoring service to run. Both alert channels empty means
alerting is a silent no-op with zero egress — configuring a channel is the
enable. See DEPLOYMENT.md "Monitoring" for what fires and why.
| Variable | Description | Default |
|---|---|---|
ALERT_WEBHOOK_URL |
Slack/Discord-compatible incoming webhook (one payload serves both). This URL is a credential — never logged or echoed in an error | — |
ALERT_EMAIL_TO |
Operator address for the same alerts, delivered via EMAIL_PROVIDER |
— |
ALERT_WATCHDOG_INTERVAL_SECONDS |
Seconds between readiness/error-rate evaluations; 0 stops the loop, which also freezes the /metrics dependency gauges |
60 |
ALERT_READINESS_FAILURES |
Consecutive failing ticks before "degraded" is declared. Recovery takes one good tick | 2 |
ALERT_COOLDOWN_SECONDS |
Minimum gap between two alerts sharing a dedupe key; claimed in Redis so N workers page once | 900 |
ALERT_ERROR_RATE_THRESHOLD |
5xx share that triggers an alert. A ratio, so it means the same at any WEB_CONCURRENCY |
0.05 |
ALERT_ERROR_RATE_WINDOW_SECONDS |
Rolling window the ratio is measured over | 300 |
ALERT_ERROR_RATE_MIN_REQUESTS |
Volume floor below which the ratio is ignored | 20 |
METRICS_TOKEN |
Unlocks GET /metrics (Prometheus text format) for callers sending it as X-Metrics-Token; empty makes the route answer 404. Deliberately separate from HEALTH_DETAIL_TOKEN |
— |
UPTIME_INTERVAL_SECONDS |
Poll interval for the optional uptime compose profile |
60 |
UPTIME_FAILURES_BEFORE_ALERT |
Consecutive failures before the sidecar alerts | 2 |
Deployment-specific settings — domain, TLS, backups, the purge cron — live in
DEPLOYMENT.md. Architectural rationale for the non-obvious settings is
in CLAUDE.md §11.