# Self-hosting Orbit > **Status: Preview.** This page documents the current Noveum AI deployment and > evaluation paths. Orbit does not yet publish a provider-neutral production > support, migration, rollback, backup, or compatibility contract. Review the > [readiness tracker](open-source-readiness.md) before deploying important data. Orbit is one Next.js app. It needs Postgres, Redis and an S3-compatible bucket, plus a usable sign-in method. A Docker Compose preview packages the standalone application and those dependencies for local evaluation. Everything below has a free tier, so a small team can run Orbit for nothing. ## Pick your route | Route | Effort | Best for | | --- | --- | --- | | [Vercel](#deploy-on-vercel) | About 20 minutes | Almost everyone. This is what we run | | [Standalone Node (Preview)](#run-standalone-node-preview) | About 30 minutes | Evaluation inside your own network, without realtime | | [Docker Compose (Preview)](docker-preview.md) | Local image build and setup | Evaluation with bundled infrastructure, realtime and maintenance | All routes need the same infrastructure plus one complete first-login method. ## What Orbit needs | Piece | What we use | Alternatives | | --- | --- | --- | | Postgres 16 or newer | [Supabase](https://supabase.com) | Neon, Railway, RDS, your own | | Redis | [Upstash](https://upstash.com) | Any Redis 7 or newer, ElastiCache, your own | | S3-compatible storage | Cloudflare R2 | AWS S3, Backblaze B2, MinIO, Supabase Storage | | Transactional email (optional with password or OAuth) | [Resend](https://resend.com) | None. Orbit only supports Resend | Email is used for sign-in codes and invites. It is optional when password, Google or GitHub sign-in is configured. Production build and startup refuse to proceed when none of those methods can bootstrap the first user. ## Deploy on Vercel ### 1. Create the database On [Supabase](https://supabase.com), create a project, then take the connection string from **Project settings**, **Database**, **Connection string**, in URI form. Use the **connection pooler** string on port `6543` for `DATABASE_URL`, not the direct one on `5432`. Serverless functions open a lot of short lived connections, and the direct endpoint will run out of them under any real load. Keep the direct `5432` string somewhere too. You need it once, to apply the schema. Set `DATABASE_PREPARED_STATEMENTS=false` for this transaction pooler. Other providers use different ports, so use the runtime connection string they recommend and choose this setting from the endpoint's prepared-statement capability, not its port number. Any Postgres works. Neon and Railway are equally fine, and so is a Postgres you run yourself. Orbit uses `postgres.js` through Drizzle, and no provider-specific extensions beyond what `bun run db:push` installs itself. ### 2. Create Redis On [Upstash](https://upstash.com), create a Redis database in the same region as your Vercel functions, and copy the `rediss://` URL. Redis carries the realtime fan-out. Every mutation publishes there, and the socket layer subscribes. Region matters more than size: a Redis on another continent adds its round trip to every live update anyone sees. ### 3. Create the bucket Cloudflare R2 is the cheapest of these because it does not charge for egress. Create a bucket, then create an API token with object read, write, list, and delete permissions. AWS S3 deployments also need `s3:ListBucketVersions` and `s3:DeleteObjectVersion` so workspace deletion removes recoverable historical versions instead of leaving them behind. R2 gives you an endpoint like `https://.r2.cloudflarestorage.com`, and the region is `auto`. Uploads go straight from the browser to the bucket through a presigned PUT, so the bucket has to allow your origin. Apply the CORS policy: ```bash sed 's|__ORBIT_ORIGIN__|https://orbit.example.com|' infra/s3-cors.json > /tmp/cors.json aws s3api put-bucket-cors --bucket "$S3_BUCKET" --cors-configuration file:///tmp/cors.json ``` Skip this and uploads fail in the browser with a CORS error while the server logs look completely healthy. ### 4. Set up email or another sign-in method Create a [Resend](https://resend.com) account, verify a domain, and create an API key. `EMAIL_FROM` has to be on the domain you verified. If it is not, every send fails and the only symptom is that invites never arrive. You can omit Resend when password, Google or GitHub sign-in is configured, but invitations and email OTP will remain unavailable. ### 5. Apply the schema **Migrations are applied from your machine, never by the platform.** There is no migration job in the build. Schema changes are completed and verified before the new application is deployed. So push the schema before the code that needs it ships: ```bash DIRECT_URL="postgres://...direct connection on 5432..." bun run db:release ``` Use the **direct** connection string here, not the transaction pooler. The release command takes a database lock, verifies every recorded migration hash, applies the pending migrations and then verifies the complete required catalog. A compatible database created before Orbit had a migration ledger is baselined without changing application rows. A partial legacy schema is refused until its matching catchup scripts have been applied. ### 6. Import the project into Vercel Import the repository, then set: | Setting | Value | | --- | --- | | Framework preset | Next.js | | Root directory | `apps/web` | | Build command | `bun run build` | | Install command | `bun install` | | Node.js version | 22.x or newer | **Do not set `bunVersion` in `apps/web/vercel.json`.** It moves every function to the Bun runtime, where `experimental_upgradeWebSocket` silently never fires. The app looks fine and the browser retries forever against a socket that never opens. This is the single most expensive mistake you can make here, because nothing errors. ### 7. Set the environment variables In **Settings**, **Environment Variables**: ```bash DATABASE_URL=postgres://...pooler on 6543... DATABASE_PREPARED_STATEMENTS=false REDIS_URL=rediss://... BETTER_AUTH_SECRET= BETTER_AUTH_URL=https://orbit.example.com NEXT_PUBLIC_APP_URL=https://orbit.example.com S3_ENDPOINT=https://.r2.cloudflarestorage.com S3_REGION=auto S3_BUCKET=orbit-uploads S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... RESEND_API_KEY=re_... EMAIL_FROM="Orbit " ``` Generate the secret with `openssl rand -base64 32`. Never reuse the one from `.env.example`, which is public. The example above uses Resend as the required first-login path. You can instead set `ORBIT_PASSWORD_AUTH=true`, both Google OAuth variables, or both GitHub OAuth variables. Passkeys cannot bootstrap a new installation, and `ORBIT_DEV_LOGIN` is deliberately ignored in production. Two variables must **not** be set: - **`NEXT_PUBLIC_REALTIME_URL`.** In production the socket is always served from the page's own origin at `/api/ws`. `configuredRealtimeUrl()` ignores this variable when `NODE_ENV` is `production`, so it is a local development override and nothing else. Leave it unset so the deployment configuration reflects the production topology. - **`ORBIT_DEV_LOGIN`.** It signs anyone in as any user with one click. ### 8. Deploy and check Deploy, then: ```bash curl https://orbit.example.com/api/health ``` You want `{"status":"ok","service":"web"}`. Then open the app in two browser windows and change something in one. If the other updates without a refresh, the websocket, Redis and the database are all wired up correctly. That single test covers more than any health check. ### 9. Sign in for the first time Each person who creates a workspace becomes its admin, and onboarding walks through naming it and creating the first team. The first account has no special server-wide privileges. There is no default administrator account. See the [first-run setup guide](first-run.md) for registration, invitation verification, email setup and the deployment checks available to workspace admins. The production preflight has already confirmed that at least one first-login method is configured. See [Configuration](configuration.md#authentication) for Google, GitHub, password authentication, passkeys and email OTP. Complete one real sign-in before inviting anyone. The preflight cannot validate remote OAuth credentials or a Resend domain. Passkeys become available after an authenticated user registers one; they cannot create the first session. ## Run standalone Node (Preview) If you want to evaluate Orbit inside your own network, run the Next.js standalone build behind a reverse proxy. This standalone path is Preview only: Running only the Next HTTP server serves routes and assets. The complete [Docker Compose preview](docker-preview.md) also starts a Node WebSocket host, a same-origin gateway and a maintenance scheduler. Use that stack to evaluate live updates and background work on a VPS. The packaged start command requires Node.js 22 or newer. Bun remains required for installing dependencies, applying the schema, and building the app. ```bash git clone https://github.com/Noveum/orbit.git cd orbit bun install cp .env.example .env # then edit it for production bun run db:push bun run build ``` The build produces a portable standalone server in `apps/web/.next/standalone`, including the public and Next static assets. Run it with the package command, which loads the repository `.env`: ```bash cd apps/web bun run start ``` Your reverse proxy only needs to forward ordinary HTTP requests to the standalone server. In nginx: ```nginx location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } ``` For a complete evaluation stack, use the [Docker Compose preview](docker-preview.md). It supplies Postgres, Redis and MinIO with generated private credentials and persistent volumes. The root `docker-compose.yml` is for local development only; its published passwords must never be used for a deployed installation. ## Keeping it running ### Upgrading ```bash git pull bun install DIRECT_URL="postgres://...direct connection..." bun run db:release DATABASE_URL="postgres://...direct connection..." bun run db:check-drift bun run build ``` Always complete the database release before the code that depends on it goes live. The production Vercel build refuses to deploy when the configured database cannot be verified or is missing a required schema object. Additional legacy tables and indexes are reported and preserved. Orbit ships continuously from `main`. We also publish automated weekly dated tags and GitHub releases, with manual workflow dispatch available when needed, so you can track `main` or a recent dated tag for deployed versions. Watch the [releases](https://github.com/Noveum/orbit/releases) for anything labelled `breaking change` and follow the upgrade notes in the associated release. Upgrades across this release drop four tables the app never displayed: `module`, `module_member`, `module_issue` and `module_link`. A Plane import before #287 filled them and nothing has read them since, so the migration removes them. Databases that materialized their schema without a migration ledger can remove them with `packages/db/catchup/drop-module-tables-catchup.sql`. ### Backups Back up Postgres and object storage together. Orbit ships a coordinated backup CLI (`bun run backup:create`) that exports a single repeatable-read PostgreSQL snapshot, runs `pg_dump` against it, and downloads all referenced attachment objects into an atomic backup archive. ```bash # Capture a backup into ./backups bun run backup:create --destination ./backups # Pass a direct database connection explicitly DIRECT_URL="postgres://user:pass@host:5432/orbit" bun run backup:create -d ./backups # Machine-readable output for cron or orchestrators bun run backup:create --json --destination /var/backups/orbit ``` #### CLI flags and environment variables | Flag | Env variable | Default | Description | | --- | --- | --- | --- | | `--destination`, `-d` | `ORBIT_BACKUP_DESTINATION` | `./backups` | Target directory where the backup folder is published | | `--database-url` | `DIRECT_URL`, `DATABASE_URL` | none | Direct connection string to PostgreSQL | | `--pg-dump-path` | `PG_DUMP_PATH` | `pg_dump` | Path to the local `pg_dump` binary | | `--orbit-version` | `ORBIT_VERSION` | `0.1.0` | Orbit version string stamped into `manifest.json` | | `--source-revision` | `SOURCE_REVISION`, `VERCEL_GIT_COMMIT_SHA` | `unknown` | Git commit SHA stamped into `manifest.json` | | `--json` | none | `false` | Emit JSON status on stdout and stderr | #### Prerequisites 1. **`pg_dump` installed locally:** The backup runner invokes `pg_dump` directly. Its version must match or exceed the version of the PostgreSQL server being backed up. Configure `PG_DUMP_PATH` or `--pg-dump-path` if `pg_dump` is not in `PATH`. 2. **Direct database connection:** `DIRECT_URL` must point directly to PostgreSQL, not through a transaction-mode connection pooler such as PgBouncer or Supabase's transaction pooler (port 6543). The coordinated snapshot requires `pg_export_snapshot()`, which requires an open transaction session. 3. **Object storage credentials:** Storage environment variables (`S3_BUCKET`, `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, etc.) must be accessible to the command so it can download attachment files. #### Output structure and atomicity Each backup creates an isolated directory named `orbit-backup--/`: ``` orbit-backup-2026-09-10T19-36-31-839Z-68a1dddb/ ├── manifest.json # Schema ledger, checksums, counts, safe config allowlist ├── database.dump # pg_dump custom format (-Fc) archive └── objects/ # Captured attachments keyed by storage key └── org_xxx/issue/att_yyy/file.png ``` Backups write to a temporary `.tmp` directory first. If `pg_dump`, preflight validation, or object capture fails, the working directory is renamed to `.incomplete` and the command exits with code 1. Only a fully verified backup is published to its final path. #### Backup limitations - **Unencrypted at rest:** Archive files and dumps are written with restricted file modes (`0o600`), but payloads are unencrypted. Encrypt the backup directory at the filesystem or bucket level if storing backups in cloud cold storage. - **Online object capture:** The database snapshot guarantees consistent relational state, and object storage capture fetches all attachments present when the snapshot began. If external tooling deletes an object from storage while Orbit is running, the backup fails rather than publishing a partial archive. - **Local scratch disk space:** The destination directory must have enough disk capacity to hold the uncompressed PostgreSQL dump and all attachment objects. ### Scaling Orbit is fine on the smallest tier of everything for a team of twenty. The things that give out first, roughly in order: 1. **Postgres connections.** Use the pooler. 2. **Redis latency**, if it is in another region from the functions. 3. **Function concurrency**, which Vercel handles on its own. ## Security before you go public Read [SECURITY.md](https://github.com/Noveum/orbit/blob/main/SECURITY.md), which has the full checklist. The short version: - Fresh `BETTER_AUTH_SECRET`. - `ORBIT_DEV_LOGIN` unset. - `NEXT_PUBLIC_REALTIME_URL` unset. - A real production sign-in completed successfully. - Postgres, Redis and storage not reachable from the internet. - Every default credential from `docker-compose.yml` changed. - `ALLOWED_EMAIL_DOMAINS` set if only your organisation should get in. - Bucket CORS scoped to your origin. - HTTPS, because sessions and the socket ticket both depend on it. ## When it does not work | Symptom | Cause | | --- | --- | | Endless "Reconnecting to live updates" | Check the Docker realtime service and gateway, or the Vercel websocket route, and Redis configuration | | Live updates never arrive, no banner | `REDIS_URL` is wrong, or Redis is unreachable from the functions | | Uploads fail in the browser, server looks fine | Bucket CORS does not allow your origin | | Invites and sign-in codes never arrive | `EMAIL_FROM` is not on a domain verified in Resend | | Build or startup says a first-login method is required | Configure password auth, a complete Google or GitHub pair, or Resend with a non-local sender | | Connection pool exhausted | `DATABASE_URL` points at the direct endpoint instead of the pooler | | Sign-in loops back to the login screen | `BETTER_AUTH_URL` does not exactly match the origin you are visiting | | Websocket never reaches 101 | `bunVersion` is set in `apps/web/vercel.json`, so functions run on Bun | More in [Troubleshooting](troubleshooting.md).