Skip to content

Configuration

Every setting is an environment variable. .env.example is a working local configuration, so copy it and change what you need:

bash
cp .env.example .env

Self-hosting status: Preview. The production values on this page are a configuration reference, not a supported production release or provider compatibility guarantee. See Open-source readiness.

Orbit parses its own environment with Zod at startup, so a missing or malformed required variable fails immediately with a message that names it, rather than failing later somewhere confusing.

Bun does not implement process.loadEnvFile, so scripts load the repository .env with bun --env-file=../../.env in the script itself. A script whose working directory is inside a workspace package will not see the repository .env without that flag.

Required

VariableExampleNotes
DATABASE_URLpostgres://orbit:orbit@localhost:5434/orbitPostgres 16 or newer. In production use the runtime connection string recommended by your provider
REDIS_URLredis://localhost:6380Redis 7 or newer. Carries realtime fan-out. Use rediss:// for TLS
BETTER_AUTH_SECRET32+ random charactersSigns sessions. openssl rand -base64 32. Never reuse the example value
BETTER_AUTH_URLhttps://orbit.example.comMust match the origin exactly, or sign-in loops
NEXT_PUBLIC_APP_URLhttps://orbit.example.comPublic origin. Used for absolute links in email and OAuth metadata

Database

VariableDefaultNotes
DATABASE_PREPARED_STATEMENTSfalseSet to true only when the database endpoint supports protocol-level named prepared statements

Orbit disables automatic named prepared statements by default because transaction poolers do not expose that capability consistently. Direct connections and session poolers support them. Some transaction poolers support them when configured to track named statements, while others require them to stay off.

Do not add a prepare query option to DATABASE_URL. Orbit refuses that ambiguous configuration and uses DATABASE_PREPARED_STATEMENTS as the single source of truth. Connection options such as sslmode=require remain in DATABASE_URL.

Scheduled maintenance

VariableNotes
CRON_SECRETProtects the scheduled sprint snapshot, operational pruning, and Slack notification worker routes. Use a long random value in every deployed environment

Vercel presents CRON_SECRET as a bearer token when it invokes the scheduled routes. Without the secret, all three routes refuse to run. The Slack notification worker processes eligible deliveries across every organization only when SLACK_ENABLED=true; with the flag false or unset, it has no eligible work. The analytics route runs every six hours so every sprint-local calendar day is observed across timezone and daylight-saving changes. It records one row per active sprint and local day, then publishes the returned realtime actions. Sprint completion also records a final snapshot in the same transaction before unfinished work rolls into the next sprint. New and updated sprint timezones must be valid IANA names. A legacy sprint with an invalid stored timezone uses UTC explicitly so it cannot block other snapshots or prevent the sprint from closing.

Realtime

VariableDefaultNotes
NEXT_PUBLIC_REALTIME_URLunsetLocal development only. Set it to ws://localhost:3100 locally
REALTIME_PORT3100Port for apps/realtime, which is never deployed

NEXT_PUBLIC_REALTIME_URL is ignored whenever NODE_ENV is production, where the socket is always served from the page's own origin at /api/ws. Setting it on a deployed environment does nothing useful and risks confusing whoever reads the config next, so leave it unset there.

Authentication

Orbit uses better-auth. A production build and server start require at least one usable first-login method: password authentication, a complete Google or GitHub credential pair, or email OTP delivery through Resend with an explicit non-local sender. Passkeys work after a user registers one, but cannot bootstrap a new installation. Local development is unaffected.

VariableNotes
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGoogle sign-in. Redirect URI is <app>/api/auth/callback/google
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETGitHub sign-in. Callback is <app>/api/auth/callback/github
ORBIT_PASSWORD_AUTHfalse by default. true enables email and password
ALLOWED_EMAIL_DOMAINSUnset by default, which means anyone can sign up. Comma separated to restrict
ORBIT_DEV_LOGINLocal only. One-click sign-in as any seeded user

Email and password is off by default and hashed with @node-rs/argon2 (argon2id) when on. It is rate limited, and it is never a replacement for the passwordless methods. Leave it off unless you have a reason.

Signing up is open unless you close it. A new account creates its own workspace through the onboarding flow, and a workspace admits nobody else until it invites them, so an open instance is still one tenant per workspace.

ALLOWED_EMAIL_DOMAINS closes that door. It is enforced on invite creation, on user creation and when a sign-in code is requested, so it covers every provider rather than just invites, and it is checked again on every session so an address that stops qualifying loses access. A refused address is told so, rather than being left waiting for a code that will never arrive.

Set it when an instance should only admit one organisation. A value that is set but names no domain, such as a bare @, is refused rather than read as no restriction, so a typo cannot quietly open an instance you meant to close.

A workspace can narrow it further with its own allowedEmailDomains setting. The hosted instance at https://orbit.noveum.ai leaves it unset.

Authentication is rate limited per IP whatever the method: 10 sign-in code requests each ten minutes, 5 password sign-ins a minute, and 5 password sign-ups an hour, on top of better-auth's own defaults for the paths without a rule of their own. Better-auth applies them in production only, and a sign-in code additionally dies after three wrong guesses.

A custom rule replaces better-auth's matching default rather than stacking with it, which is why the sign-in code window is ten minutes and not an hour. An hourly rule would have to allow a whole hour of sends in a single burst, and a per-IP hourly cap tight enough to be worth having would lock out an office that shares one address.

In production those counters live in Redis, on the REDIS_URL the app already needs, so the caps hold across every serverless instance rather than resetting with each one. If Redis cannot be reached the check lets the request through rather than locking everybody out, which means an outage costs you the ceiling and not sign-in. Outside production better-auth keeps its own in-process store, which is all a single development server needs.

bash
ALLOWED_EMAIL_DOMAINS=example.com,example.org

ORBIT_DEV_LOGIN must never be set on a deployed environment. It lists the seeded users on the login screen and signs anyone in as any of them.

ORBIT_DEV_LOGIN and passkeys do not satisfy the production first-login check. Half-configured OAuth providers, blank values and the local Orbit <auth@orbit.local> sender are also rejected.

This check proves only that a complete method is present. It cannot contact an OAuth provider or confirm that Resend has verified the sender domain, so test a real production sign-in after deployment.

Email

Orbit sends through Resend only, for sign-in codes and invites. Event notification email and digests are not currently dispatched.

VariableNotes
RESEND_API_KEYFrom the Resend dashboard
EMAIL_FROMMust be on a domain verified in Resend
bash
RESEND_API_KEY=re_xxxxxxxxx
EMAIL_FROM="Orbit <orbit@example.com>"

If EMAIL_FROM is not on a verified domain every send fails, and users see missing sign-in codes and invitations.

The sender in .env.example is local-only. Replace it before relying on Resend for production authentication.

Object storage

Any S3-compatible bucket. Uploads go straight from the browser through a presigned PUT.

VariableLocal valueNotes
S3_ENDPOINThttp://localhost:9010MinIO locally, R2 or S3 in production
S3_REGIONus-east-1auto for Cloudflare R2
S3_BUCKETorbit-uploads
S3_ACCESS_KEY_IDorbitminio
S3_SECRET_ACCESS_KEYorbitminio

The bucket needs a CORS policy allowing your origin, otherwise uploads fail in the browser while the server logs look healthy. infra/s3-cors.json is the document, with the origin as a placeholder.

Workspace deletion needs permission to list and delete both current objects and object versions. On AWS S3, grant s3:ListBucket, s3:ListBucketVersions, s3:DeleteObject, and s3:DeleteObjectVersion for the upload bucket.

Integrations

VariableNotes
GITHUB_APP_IDGitHub App, for linking pull requests to issues
GITHUB_APP_PRIVATE_KEYThe PEM. Escaped newlines as \n are handled
GITHUB_APP_SLUGThe app's URL slug. Without it, the connect button hides
GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRETExchange the callback code to confirm the installation belongs to the person connecting. Without them the connect flow refuses rather than binding an installation it cannot attribute
GITHUB_WEBHOOK_SECRETVerifies inbound webhooks
SLACK_CLIENT_IDSlack OAuth client ID. It is not secret
SLACK_CLIENT_SECRETSlack OAuth client secret. Mark it Sensitive in Vercel
SLACK_SIGNING_SECRETVerifies Slack webhook signatures. Mark it Sensitive in Vercel
SLACK_ENABLEDGlobal server-side Slack gate. true enables Slack for every current and future Orbit organization. False or unset keeps Slack dark

All are optional. Orbit hides the GitHub affordance when it is not configured. Slack requires all three Slack OAuth and webhook variables. Keep SLACK_ENABLED=false or leave it unset while preparing a deployment. Setting it to true is a global release action: the Slack settings surface, routes, webhook processing, and notification worker become available to every current and future Orbit organization. It does not connect an organization automatically. An authorized manager must complete a separate OAuth connection for each organization.

Do not configure SLACK_APP_ID, SLACK_BOT_TOKEN, or SLACK_APP_TOKEN. Orbit does not use them. See Integrations for the safe launch sequence and Slack-side configuration.

MCP

VariableNotes
NEXT_PUBLIC_MCP_URLOverrides the advertised MCP URL. Defaults to <app>/mcp

You almost never need this. It exists for deployments that put the MCP endpoint behind a different hostname. See MCP server.

Testing

VariableNotes
ORBIT_TEST_LANEIsolates a test run into its own set of databases
ORBIT_E2E_BASE_URLWhere Playwright points. Defaults to NEXT_PUBLIC_APP_URL

ORBIT_TEST_LANE matters whenever two runs share a Postgres, which happens with two worktrees or two agents. Without it both runs truncate the same tables and you get deadlocks and foreign key violations that look like real failures.

bash
ORBIT_TEST_LANE=my-branch bun run test
ORBIT_TEST_LANE=my-branch bun run db:test-lanes-drop

The lane name becomes a readable stub plus a digest of the raw value, so two lanes that normalise alike stay apart.

The cleanup drops only the lane named by ORBIT_TEST_LANE, and refuses when that is unset. bun run db:test-lanes-drop --all drops every lane on the server, including lanes another worktree is using. Neither mode touches the six base databases.

Reference: a deployment environment

bash
DATABASE_URL=postgres://user:pass@runtime-db.example.com:5432/orbit?sslmode=require
DATABASE_PREPARED_STATEMENTS=false
REDIS_URL=rediss://default:pass@redis.example.com:6379
CRON_SECRET=<openssl rand -base64 32>

BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=https://orbit.example.com
NEXT_PUBLIC_APP_URL=https://orbit.example.com

GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

ALLOWED_EMAIL_DOMAINS=example.com

RESEND_API_KEY=re_...
EMAIL_FROM="Orbit <orbit@example.com>"

S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=orbit-uploads
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...

Note what is absent: no NEXT_PUBLIC_REALTIME_URL, and no ORBIT_DEV_LOGIN.

ALLOWED_EMAIL_DOMAINS appears here because this example is a single-company deployment. Drop the line to let anyone sign up, which is what the hosted instance does.

Released under the Apache 2.0 License.