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 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 | About 20 minutes | Almost everyone. This is what we run |
| Standalone Node (Preview) | About 30 minutes | Evaluation inside your own network, without realtime |
| Docker Compose (Preview) | 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 | Neon, Railway, RDS, your own |
| Redis | Upstash | 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 | 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, 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, 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://<account-id>.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:
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.jsonSkip 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 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:
DIRECT_URL="postgres://...direct connection on 5432..." bun run db:releaseUse 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:
DATABASE_URL=postgres://...pooler on 6543...
DATABASE_PREPARED_STATEMENTS=false
REDIS_URL=rediss://...
BETTER_AUTH_SECRET=<a fresh 32+ character random string>
BETTER_AUTH_URL=https://orbit.example.com
NEXT_PUBLIC_APP_URL=https://orbit.example.com
S3_ENDPOINT=https://<account-id>.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 <orbit@example.com>"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 whenNODE_ENVisproduction, 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:
curl https://orbit.example.com/api/healthYou 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 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 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 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.
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 buildThe 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:
cd apps/web
bun run startYour reverse proxy only needs to forward ordinary HTTP requests to the standalone server. In 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. 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
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 buildAlways 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 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.
# 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/orbitCLI 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
pg_dumpinstalled locally: The backup runner invokespg_dumpdirectly. Its version must match or exceed the version of the PostgreSQL server being backed up. ConfigurePG_DUMP_PATHor--pg-dump-pathifpg_dumpis not inPATH.- Direct database connection:
DIRECT_URLmust point directly to PostgreSQL, not through a transaction-mode connection pooler such as PgBouncer or Supabase's transaction pooler (port 6543). The coordinated snapshot requirespg_export_snapshot(), which requires an open transaction session. - 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-<timestamp>-<hash>/:
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.pngBackups 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:
- Postgres connections. Use the pooler.
- Redis latency, if it is in another region from the functions.
- Function concurrency, which Vercel handles on its own.
Security before you go public
Read SECURITY.md, which has the full checklist. The short version:
- Fresh
BETTER_AUTH_SECRET. ORBIT_DEV_LOGINunset.NEXT_PUBLIC_REALTIME_URLunset.- A real production sign-in completed successfully.
- Postgres, Redis and storage not reachable from the internet.
- Every default credential from
docker-compose.ymlchanged. ALLOWED_EMAIL_DOMAINSset 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.