Environment variables
The full list of environment variables for running a self-hosted Multica server.
A self-hosted Multica server reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out what happens if you leave it unset and which ones you must set in production. For how to actually configure the auth-related ones, see Sign-in and signup configuration.
Core server variables
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly.
| Variable | Default | Required in production? |
|---|---|---|
DATABASE_URL | postgres://multica:multica@localhost:5432/multica?sslmode=disable | Yes |
PORT | 8080 | No (unless you change the port) |
JWT_SECRET | multica-dev-secret-change-in-production | Yes (the default is unsafe) |
APP_ENV | empty | Yes (must be production) |
FRONTEND_ORIGIN | empty | Yes (self-host must set its own domain) |
MULTICA_DEV_VERIFICATION_CODE | empty | No (must stay empty in production) |
Keep MULTICA_DEV_VERIFICATION_CODE empty in production. A fixed local test code is disabled by default, but if you opt in with MULTICA_DEV_VERIFICATION_CODE=888888, anyone who can request a code can sign in with that fixed value while APP_ENV is non-production. The shortcut is ignored when APP_ENV=production.
Database connection pool
| Variable | Default | Description |
|---|---|---|
DATABASE_MAX_CONNS | 25 | pgxpool max connections. The daemon polls frequently (every 3s) and uses connections; larger deployments may need a higher value |
DATABASE_MIN_CONNS | 5 | Minimum idle connections |
When unset, the values above are used — not pgx's built-in 4/NumCPU defaults, which previously caused pool exhaustion in production.
Email configuration
Multica supports two delivery backends — Resend for cloud deployments, or an SMTP relay for internal / on-premise networks. SMTP_HOST takes priority over RESEND_API_KEY when both are set.
Resend
| Variable | Default | Description |
|---|---|---|
RESEND_API_KEY | empty | Resend API key |
RESEND_FROM_EMAIL | [email protected] | Sender address (must be a domain verified in your Resend account; also reused as the From: header when SMTP is in use) |
SMTP relay
| Variable | Default | Description |
|---|---|---|
SMTP_HOST | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
SMTP_PORT | 25 | SMTP port. Use 587 for STARTTLS submission, or 465 for SMTPS (implicit TLS, auto-enabled) |
SMTP_USERNAME | empty | SMTP username. Leave empty for unauthenticated relay |
SMTP_PASSWORD | empty | SMTP password |
SMTP_TLS | starttls | TLS mode. implicit (aliases smtps, ssl) forces an immediate TLS handshake on connect (SMTPS); port 465 auto-enables it. Unset / starttls upgrades via STARTTLS after connect |
SMTP_TLS_INSECURE | false | Set true to skip TLS certificate verification (private CA / self-signed only) |
SMTP_EHLO_NAME | machine hostname | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace smtp-relay.gmail.com) rejects the default greeting from a public IP — otherwise the relay drops the connection and it surfaces as an opaque EOF on a later command |
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
Behavior when neither is set: the server does not error, but every email that should have been sent (verification codes, invite links) is written to the server's stdout only. Convenient for local development — copy the code out of the server logs; in production, forgetting to set this creates a silent black hole, with users never receiving email and no error surfaced.
Google OAuth configuration
Optional. Leave unset for email + verification code only; configure it to add "Sign in with Google" on the sign-in page.
| Variable | Default | Description |
|---|---|---|
GOOGLE_CLIENT_ID | empty | Google Cloud OAuth client ID |
GOOGLE_CLIENT_SECRET | empty | Google Cloud OAuth secret |
GOOGLE_REDIRECT_URI | http://localhost:3000/auth/callback | OAuth callback URL (self-host: replace with your frontend domain) |
Takes effect at runtime: the frontend reads these settings via /api/config at runtime, so changing them requires no frontend rebuild or redeploy — restart the server and they apply.
Full setup (including Google Cloud Console steps) is in Sign-in and signup configuration.
File storage configuration
Multica stores user-uploaded attachments (images and files in comments). S3 is preferred; if S3 is not configured, it falls back to local disk.
S3 / S3-compatible storage
| Variable | Default | Description |
|---|---|---|
S3_BUCKET | empty | Bucket name only (for example my-bucket). Do not include the .s3.<region>.amazonaws.com suffix — the server constructs the public host from S3_BUCKET + S3_REGION. Setting this enables S3 storage |
S3_REGION | us-west-2 | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
AWS_ENDPOINT_URL | empty | Custom S3-compatible endpoint (for example MinIO). Setting this switches to path-style URLs |
ATTACHMENT_DOWNLOAD_MODE | auto | Attachment download path: auto, cloudfront, presign, or proxy. In auto, CloudFront is preferred when fully configured; internal/private endpoint hosts use the server proxy; public S3-compatible endpoints use presigned GET URLs when supported |
ATTACHMENT_DOWNLOAD_URL_TTL | 30m | TTL for CloudFront signed URLs and S3 presigned download URLs. Accepts Go duration strings |
When S3_BUCKET is unset: the server logs "S3_BUCKET not set, cloud upload disabled" at startup, and all uploads fall back to local disk.
Stored object URLs are constructed in this order of priority:
https://<CLOUDFRONT_DOMAIN>/<key>ifCLOUDFRONT_DOMAINis set.<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>(path-style) ifAWS_ENDPOINT_URLis set.https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>(virtual-hosted-style). WhenS3_BUCKETcontains dots, the server falls back tohttps://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>(path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
API download_url values use GET /api/attachments/{id}/download unless CloudFront signing is configured. The endpoint redirects to CloudFront/S3 presigned URLs when safe, or streams through the server for private/internal endpoints such as http://rustfs:9000. For Docker/VPC-only object stores, set ATTACHMENT_DOWNLOAD_MODE=proxy if auto detection is not conservative enough for your network.
Local disk (when S3 is not configured)
| Variable | Default | Description |
|---|---|---|
LOCAL_UPLOAD_DIR | ./data/uploads | Local storage directory |
LOCAL_UPLOAD_BASE_URL | empty (returns relative paths) | Public base URL — leave unset and the frontend can't resolve a full URL for attachments |
CloudFront (optional)
If you front S3 with CloudFront, three variables apply: CLOUDFRONT_DOMAIN, CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY (or CLOUDFRONT_PRIVATE_KEY_SECRET to read from Secrets Manager). Skip them if you don't use CloudFront — they don't conflict with S3 configuration.
Cookie domain
| Variable | Default | Description |
|---|---|---|
COOKIE_DOMAIN | empty | Scope of the session cookie |
- Empty: the cookie is valid only on the exact host visited (correct for single-host deployments)
- Set to
.example.com: the cookie is shared across subdomains (soapp.example.comandapi.example.comshare a sign-in session) - Warning: it cannot be an IP address (browsers ignore it)
Restricting who can sign up
Three allowlist layers combine by priority. If any layer is set to a non-empty value, emails that don't match are rejected — even ALLOW_SIGNUP=true won't override that.
| Variable | Default | Description |
|---|---|---|
ALLOWED_EMAILS | empty | Explicit email allowlist (comma-separated). When non-empty, only listed emails can sign up |
ALLOWED_EMAIL_DOMAINS | empty | Domain allowlist (comma-separated). When non-empty, only listed domains can sign up |
ALLOW_SIGNUP | true | Signup master switch. Set false to disable signup entirely |
The counterintuitive part: ALLOWED_EMAIL_DOMAINS=company.io + ALLOW_SIGNUP=true does not mean "allow company.io or everyone" — it means only allow company.io. The allowlist layers are AND semantics — the full decision tree is in Sign-in and signup configuration → Signup allowlists.
Invite flows themselves do not check the signup allowlist — but the invitee must still be able to sign in before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; if they have never signed up, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by ALLOW_SIGNUP=false or by ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS cannot finish signup, and therefore cannot accept the invite.
Locking down workspace creation
ALLOW_SIGNUP=false blocks new accounts, but it does not block an already-signed-in user from creating another workspace via POST /api/workspaces. On a self-hosted instance where every issue, repo, and agent must be visible to the platform admin, set DISABLE_WORKSPACE_CREATION=true to close that gap.
| Variable | Default | Description |
|---|---|---|
DISABLE_WORKSPACE_CREATION | false | When true, every call to POST /api/workspaces returns 403 workspace creation is disabled for this instance. The web UI hides every "Create workspace" affordance via /api/config. There is no role/owner exception — the gate is global per instance |
Recommended bootstrap sequence:
- Start the instance with
DISABLE_WORKSPACE_CREATIONunset (the default). - Sign in as the admin and create the shared workspace.
- Set
DISABLE_WORKSPACE_CREATION=trueand restart the backend. From this point on, users join via invitation only.
If you also want to keep ALLOW_SIGNUP=true so invited users can finish signup with their first verification code, combine DISABLE_WORKSPACE_CREATION=true with ALLOWED_EMAIL_DOMAINS / ALLOWED_EMAILS to scope which addresses can sign up. Setting ALLOW_SIGNUP=false will additionally block pending invitees from creating their account at all — useful only on instances where every member already has a Multica account.
Rate limiting (optional Redis)
Public auth endpoints — /auth/send-code, /auth/verify-code, /auth/google — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When REDIS_URL is unset the middleware is a no-op (fail-open) and the backend logs rate limiting disabled: REDIS_URL not configured at startup.
| Variable | Default | Description |
|---|---|---|
REDIS_URL | empty | Redis connection URL (for example redis://localhost:6379/0). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
REDIS_DISABLE_CLIENT_NAME | false | Set to true to skip the CLIENT SETNAME handshake on every Redis connection. Required for managed Redis providers that block the CLIENT command, such as GCP Memorystore or AWS ElastiCache with restricted ACLs. When enabled, connections lose their descriptive name in CLIENT LIST output but gain compatibility with restricted providers |
RATE_LIMIT_AUTH | 5 | Max requests per IP per minute against /auth/send-code and /auth/google |
RATE_LIMIT_AUTH_VERIFY | 20 | Max requests per IP per minute against /auth/verify-code |
RATE_LIMIT_TRUSTED_PROXIES | empty | Comma-separated CIDRs whose X-Forwarded-For header the limiter is allowed to trust. Empty (the default) means never trust XFF — the limiter only uses the direct connection's RemoteAddr |
When a request is over the limit, the server replies with 429 Too Many Requests, Retry-After: 60, and body {"error":"too many requests"}.
Behind a reverse proxy you must set RATE_LIMIT_TRUSTED_PROXIES. Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and /auth/send-code becomes 5 req/min for the entire site. Typical values: 127.0.0.1/32,::1/128 for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose RemoteAddr falls inside one of these CIDRs may use X-Forwarded-For to identify the client.
This separate RATE_LIMIT_TRUSTED_PROXIES is not the same as MULTICA_TRUSTED_PROXIES, which controls the autopilot-webhook limiter (/api/webhooks/autopilots/{token}). Each limiter parses its own list, so a deployment behind a proxy should set both.
Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
| Variable | Default | Description |
|---|---|---|
MULTICA_SERVER_URL | ws://localhost:8080/ws | Server address (self-host: replace with your domain) |
MULTICA_DAEMON_HEARTBEAT_INTERVAL | 15s | Heartbeat interval |
MULTICA_DAEMON_POLL_INTERVAL | 3s | Task polling interval |
MULTICA_DAEMON_MAX_CONCURRENT_TASKS | 20 | Max concurrent tasks |
MULTICA_<PROVIDER>_PATH | matches the CLI name | Path to each AI coding tool's executable (for example MULTICA_CLAUDE_PATH) |
MULTICA_<PROVIDER>_MODEL | empty | Default model for each AI coding tool |
MULTICA_<PROVIDER>_ARGS | empty | Daemon-wide default CLI arguments for a backend, applied to every task before each agent's own custom_args. Supported for MULTICA_CLAUDE_ARGS, MULTICA_CODEX_ARGS, and MULTICA_CODEBUDDY_ARGS |
For a full explanation of how each parameter affects daemon behavior, see Daemon and runtimes.
Default agent arguments (MULTICA_<PROVIDER>_ARGS)
These set a fleet-wide default layer of CLI flags for a backend — a convenient way to apply a default cost or resource baseline (for example --max-turns) across every agent on a daemon without editing each agent's custom_args individually. This is a default layer, not a hard ceiling: per-agent custom_args are appended afterward and can override it (see Precedence below).
- Precedence: the default args are applied first, then each agent's own
custom_argsare appended after. For flags that take a value, the downstream CLI's own argument parser decides the winner (last occurrence wins for most tools), so an individual agent can raise a daemon default but the default still applies wherever the agent doesn't override it. - Parsing: the value is split with POSIX shell-word rules, so quoting works —
MULTICA_CLAUDE_ARGS='--append-system-prompt "multi word"'parses into two tokens. - Safety: both the default-args and per-agent
custom_argslayers pass through the same blocked-flags filter, so protocol-critical flags (such as-p,--output-format,--input-format,--permission-mode,--mcp-configfor Claude, and--listenfor Codex) cannot be injected through either layer. - Unset/empty means no change to behavior.
Frontend access control
| Variable | Default | Description |
|---|---|---|
FRONTEND_ORIGIN | empty | Frontend address. Invite email links, the CORS allowlist, and the cookie domain are all derived from this. When unset, invite email links fall back to the hosted domain https://app.multica.ai — self-host must set this explicitly |
MULTICA_APP_URL | empty | Frontend URL for CLI login flow. Also used by the web UI to show self-host daemon setup commands with your app domain; for same-origin deployments this is also used as daemon server_url when MULTICA_PUBLIC_URL is unset |
MULTICA_PUBLIC_URL | empty | Public API URL, without trailing slash. Used for autopilot webhook URLs and by the web UI as the daemon server_url |
CORS_ALLOWED_ORIGINS | empty | Additional allowed CORS origins (comma-separated) |
ALLOWED_ORIGINS | empty | WebSocket-specific origin allowlist (comma-separated); when unset, fallback order is CORS_ALLOWED_ORIGINS → FRONTEND_ORIGIN → localhost:3000/5173/5174 |
Leaving FRONTEND_ORIGIN unset creates two silent failures: (1) invite email links point at https://app.multica.ai (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to localhost:3000 / 5173 / 5174, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
GitHub integration
The GitHub PR ↔ issue integration needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks. Two additional variables are optional but populate the connected account name on install.
| Variable | Default | Description |
|---|---|---|
GITHUB_APP_SLUG | empty | The slug of your GitHub App (the tail of https://github.com/apps/<slug>). Drives the Settings → GitHub install button URL |
GITHUB_WEBHOOK_SECRET | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every pull_request / installation delivery, and as the HMAC key for the setup-callback state token |
GITHUB_APP_ID | empty | Optional. Numeric App ID from the App's settings page. Combined with GITHUB_APP_PRIVATE_KEY, lets the setup callback fetch the connected account name from GitHub immediately on install |
GITHUB_APP_PRIVATE_KEY | empty | Optional. Full PEM block of the App's RSA private key (including -----BEGIN/END----- lines, newlines preserved). Used to mint the short-lived JWT GitHub requires for App-authenticated REST calls |
Behavior when either of the required variables is unset:
Connect GitHubin Settings → GitHub is disabled and shows a "not configured" hint to admins.- The
/api/webhooks/githubendpoint returns503 github webhooks not configured— Multica refuses to process events with no secret rather than treating every signature as valid.
Behavior when the optional GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY are unset:
- The connection card briefly shows
Connected to unknownafter install. Multica refreshes the row to the real org/user name as soon as GitHub delivers theinstallation.createdwebhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
Note: GITHUB_WEBHOOK_SECRET is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is not the GitHub App's Client secret — Client secrets are OAuth-related and not used by this integration. See GitHub integration → Self-host setup for the full walkthrough.
Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set ANALYTICS_DISABLED=true.
| Variable | Default | Description |
|---|---|---|
ANALYTICS_DISABLED | false | Set true to disable backend analytics entirely |
POSTHOG_API_KEY | built-in default key | Set when pointing at your own PostHog instance |
POSTHOG_HOST | https://us.i.posthog.com | Change to your own host if you self-host PostHog |
Next
- Sign-in and signup configuration — how to actually configure the auth-related variables above and where the traps are
- GitHub integration — how to set up the GitHub App that backs
GITHUB_APP_SLUG/GITHUB_WEBHOOK_SECRET - Troubleshooting — symptoms and fixes for common misconfigurations
- Daemon and runtimes — what the
MULTICA_DAEMON_*parameters actually do