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 | noreply@multica.ai | 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 |
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 |
For a full explanation of how each parameter affects daemon behavior, see Daemon and runtimes.
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