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@shayanlatif.com | 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; port 465 (SMTPS / implicit TLS) is not supported |
SMTP_USERNAME | empty | SMTP username. Leave empty for unauthenticated relay |
SMTP_PASSWORD | empty | SMTP password |
SMTP_TLS_INSECURE | false | Set true to skip TLS certificate verification (private CA / self-signed only) |
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 |
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.
Public 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.
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.
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.shayanlatif.com — self-host must set this explicitly |
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.shayanlatif.com (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.
| 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 |
Behavior when either 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.
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