owlette docs
self-hosting

web deployment

The owlette dashboard is a Next.js application in web/. This guide covers Railway, Vercel, and generic Node.js hosting.


runtime baseline

Use Node.js 22.x and npm 10 for the web app build. The web/package.json engines pin is the binding constraint for npm run build.

sourcebaseline
web/package.json enginesnode 22.x
Repository root package.jsonnode >=20.9.0, npm >=10.0.0
Repository .nvmrc20.18.0
web/nixpacks.tomlnodejs_20, npm-10_x
Firebase FunctionsNode 22

For a fresh host, select Node 22.x. The root package.json, .nvmrc, web/nixpacks.toml, and Firebase Functions all pin Node 22 — Node 20 reached end-of-security-support on 2026-04-30, and firebase-admin 14 declares engines: node >=22, so it is a hard floor rather than a preference. Node 20 also reproduces a next build failure (MODULE_NOT_FOUND from fumadocs-mdx).


step 1: create railway project

  1. Go to railway.app and sign up with GitHub.
  2. Click New Project -> Deploy from GitHub repo.
  3. Select the owlette repository.

step 2: configure service

  1. Click your service -> Settings.
  2. Set Root Directory to web.
  3. Set Branch to main for production or dev for development.
  4. Enable Auto-deploy on push.

The repository includes the deployment configuration Railway should use:

# web/railway.toml
[build]
builder = "NIXPACKS"
buildCommand = "npm run build"

[deploy]
startCommand = "npm start"
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

[env]
NODE_ENV = "production"

Install is handled by Nixpacks, not by railway.toml:

# web/nixpacks.toml
[phases.setup]
nixPkgs = ["nodejs_20", "npm-10_x"]

[phases.install]
cmds = ["npm ci --legacy-peer-deps"]

step 3: add environment variables

In Railway -> your service -> Variables, add the production values from Environment Variables. A working web deployment needs these runtime groups:

  • Firebase client: NEXT_PUBLIC_FIREBASE_API_KEY, NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, NEXT_PUBLIC_FIREBASE_PROJECT_ID, NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, NEXT_PUBLIC_FIREBASE_APP_ID
  • Firebase Admin: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY
  • Session and encryption: SESSION_SECRET, MFA_ENCRYPTION_KEY, LLM_ENCRYPTION_KEY
  • R2 for roost uploads and version bodies: R2_S3_ENDPOINT, R2_S3_ACCESS_KEY_ID, R2_S3_SECRET_ACCESS_KEY
  • Rate limiting: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
  • Public URL: NEXT_PUBLIC_BASE_URL
  • Cron: CRON_SECRET
  • Email alerts: RESEND_API_KEY, RESEND_FROM_EMAIL, ADMIN_EMAIL_PROD, ADMIN_EMAIL_DEV
  • Public status page: INSTATUS_API_KEY, INSTATUS_PAGE_ID, and the INSTATUS_COMPONENT_*_ID values when /api/cron/status-ping should publish to Instatus
  • Autonomous hoot: CORTEX_INTERNAL_SECRET when autonomous alert investigation is enabled

RAILWAY_PUBLIC_DOMAIN, RAILWAY_ENVIRONMENT, PORT, and NODE_ENV are injected by Railway or by web/railway.toml. Set ROOST_ENV=prod or ROOST_ENV=dev only when the automatic roost bucket selection does not match the service.

NEXT_PUBLIC_* variables are read during the Next.js build and by the browser. After changing them, redeploy so the built client bundle receives the new values.

step 4: deploy

Railway deploys automatically after variables are configured. Monitor the build in the Deployments tab.

Expected build time is 2-5 minutes.

step 5: configure firebase auth domain

  1. Copy your Railway deployment URL.
  2. Go to Firebase Console -> Authentication -> Settings -> Authorized Domains.
  3. Add your Railway URL, for example owlette-web.up.railway.app.

Required

Without this step, users cannot log in because Firebase rejects auth requests from unauthorized domains.

step 6: custom domain (optional)

  1. Railway -> Settings -> Networking -> Add Custom Domain.
  2. Add the CNAME or A record as instructed.
  3. Railway provisions SSL automatically.
  4. Add the custom domain to Firebase Authorized Domains.
  5. Update NEXT_PUBLIC_BASE_URL to the custom HTTPS origin and redeploy.

vercel deployment

Vercel can run the web app as a standard Next.js project.

step 1: import project

  1. In Vercel, click Add New -> Project.
  2. Import the owlette repository.
  3. Set Root Directory to web.
  4. Set the framework preset to Next.js.

step 2: configure build settings

Use the same commands as the checked-in Railway/Nixpacks deployment:

settingvalue
Install Commandnpm ci --legacy-peer-deps
Build Commandnpm run build
Output DirectoryNext.js default; leave unset unless Vercel asks for an explicit value

Set the Node.js version to 22.x in the Vercel project settings.

step 3: add environment variables

Add the same variables listed in the Railway section under Settings -> Environment Variables. Apply them to Production and Preview as needed.

NEXT_PUBLIC_* values are bundled at build time, so redeploy after changing Firebase client settings or NEXT_PUBLIC_BASE_URL.

step 4: configure firebase auth domain

Add the Vercel deployment domain and any custom domain to Firebase Authentication -> Settings -> Authorized Domains.

step 5: cron

Most cron routes require X-Cron-Secret: <CRON_SECRET>. /api/hoot/escalation is the exception: it reads Authorization: Bearer <CRON_SECRET>. See scheduled endpoints below for the full list and the header each route expects. owlette's own schedules run on cron-job.org rather than on the web host, registered once per environment. Use a scheduler that can send those headers; if the hosting plan cannot send custom headers or cannot call all required schedules, use an external scheduler.


scheduled endpoints

Every scheduled route in the app, in one place. All are GET, all authenticate with a header, and all must be registered per environment — dev and prod use different CRON_SECRET values, so a prod secret against a dev host correctly returns 401.

endpointscheduleauth headerpurpose
/api/cron/health-check*/5 * * * *X-Cron-Secretflags machines with stale heartbeats
/api/cron/process-alerts*/3 * * * *X-Cron-Secretdrains pending_process_alerts into email
/api/cron/display-alerts*/3 * * * *X-Cron-Secretdrains pending_display_alerts into email
/api/cron/status-pingevery 60sX-Cron-Secretwrites status pings, publishes to Instatus
/api/cron/retentiondailyX-Cron-Secretdeletes metrics + logs past 400 days
/api/cron/talonsevery 60sX-Cron-Secretclaims due talons (schedule triggers), missed-fire grace, stale-run recovery
/api/hoot/escalationevery 5 minAuthorization: Bearerhoot autonomous-mode backstop poller

Two failure modes that are silent

Most of these fail quietly if unscheduled — nothing errors, the work simply never happens. The alert digests just let the queue grow. /api/cron/retention leaves data past its stated retention window, which contradicts the privacy policy rather than merely degrading a feature.

/api/hoot/escalation is the odd one out: it takes Authorization: Bearer <CRON_SECRET>, not X-Cron-Secret.

Existing schedules pointing at /api/cortex/escalation keep working — it is a thin alias onto the same handler — so there is nothing to migrate urgently. Use the /api/hoot/ path for anything you set up from now on.

A healthy response is 200. 401 means the header or its value is wrong for that environment. 500 means the handler threw — check the host logs for the route's own prefix, e.g. [retention] failed:.


cron health checks

Set up the machine offline detection cron:

step 1: add cron_secret

python -c "import secrets; print(secrets.token_hex(32))"

Add the generated value as CRON_SECRET in the host environment variables.

step 2: configure cron schedule

Call the health-check route every 5 minutes:

GET https://<your-app>/api/cron/health-check
X-Cron-Secret: <CRON_SECRET value>

Use cron expression */5 * * * * when the host accepts cron syntax.

Format

Use spaces between fields: */5 * * * *. No spaces (*/5****) will fail.

alert digest crons

Two routes batch-and-send the agent's queued alerts. If they are not scheduled, those emails never go out — the agent only queues alerts (into pending_process_alerts / pending_display_alerts); these crons are what actually email them. Schedule both, in every environment, every 3 minutes:

GET https://<your-app>/api/cron/process-alerts
GET https://<your-app>/api/cron/display-alerts
X-Cron-Secret: <CRON_SECRET value>

Use cron expression */3 * * * *. A missing schedule here fails silently — the queue just grows and nobody is notified — so confirm it per environment (dev and prod).

hoot escalation backstop

Only needed when autonomous hoot is enabled for a site. It is a backstop poller, so a missed run delays escalations rather than losing them. Every 5 minutes:

GET https://<your-app>/api/hoot/escalation
Authorization: Bearer <CRON_SECRET value>

Note the header: this route reads Authorization: Bearer, unlike the six X-Cron-Secret routes. Sending the wrong header shape returns 401 with no other symptom, so verify a run returns 200 after scheduling it.

data retention sweep

Deletes machine metrics and event logs older than 400 days. This is what backs the retention commitment in the privacy policy — without it, that statement is not true. Schedule daily, in every environment:

GET https://<your-app>/api/cron/retention
X-Cron-Secret: <CRON_SECRET value>

Use cron expression 0 4 * * * (or any quiet hour). A successful run returns:

{"ok":true,"deleted":{"metrics":107,"logs":496},
 "retentionDays":{"metrics":400,"logs":400},"truncated":false}

truncated: true means the run hit its 2000-document ceiling and older data still remains — the next run continues from the same oldest-first position. Against a large first-time backlog, schedule it hourly until a run reports truncated: false, then drop back to daily.

Changing the window means changing both METRICS_RETENTION_DAYS / LOGS_RETENTION_DAYS in the route and the stated periods in the privacy policy. They are a pair; changing one alone makes the policy inaccurate.

public status-page ping

External public launch also needs a 60-second HTTP cron that calls:

GET https://<your-app>/api/cron/status-ping
X-Cron-Secret: <CRON_SECRET value>

The route writes an internal status-ping record and posts component status changes to Instatus when the INSTATUS_* variables are configured. It waits for two consecutive failures before degrading a component, then marks it operational on recovery. Publish failures are logged and summarized in the cron response without returning vendor error bodies or failing the local ping write.

Use a separate cron job or service if the hosting provider only supports one schedule per service. Keep /api/cron/status-ping internal; it is not part of the public API reference.

After adding the status-page variables, run the readiness check from an operator shell with the same env values loaded:

node scripts/check-status-page-ready.mjs --env-only
node scripts/check-status-page-ready.mjs --base-url https://<your-app>

The check validates required env names, component ids, and the live status-ping response without printing secrets.


two-branch deployment

owlette uses two branches with separate deployments:

branchdeploymenturl
devDevelopmentdev.owlette.app
mainProductionowlette.app

Each branch should have its own service or project, environment variables, and optionally its own Firebase project.


general node.js hosting

For non-Railway and non-Vercel deployments:

cd web
npm ci --legacy-peer-deps
npm run build
npm start

Requirements:

  • Node.js 22.x, npm 10.x
  • All required environment variables from Environment Variables
  • A writable port supplied as PORT or the default Next.js port 3000
  • HTTPS for Firebase Auth in production

deployment checklist

pre-deployment

  • Host is configured for Node.js 22.x and npm 10.x
  • npm ci --legacy-peer-deps succeeds
  • npm run build succeeds
  • Required environment variables are set for the target environment
  • NEXT_PUBLIC_BASE_URL matches the public HTTPS origin

host setup

  • Repository linked
  • Root directory set to web
  • Branch configured
  • Install command is npm ci --legacy-peer-deps
  • Build command is npm run build
  • Start command is npm start where the host requires one
  • CRON_SECRET configured
  • Cron calls configured for every route in the scheduled-endpoints table above, in each environment — health-check (every 5 min), process-alerts + display-alerts (every 3 min), status-ping (every 60 s), talons (every 60 s), retention (daily), and hoot/escalation (every 5 min)
  • The /api/hoot/escalation job sends Authorization: Bearer <CRON_SECRET> — the other six routes take X-Cron-Secret, and the wrong header shape returns 401 with no other symptom

post-deployment

  • Deployment domain added to Firebase Authorized Domains
  • Registration works
  • Login works
  • Dashboard loads and shows data
  • R2-backed roost upload/version routes work
  • Real-time updates work
  • Custom domain configured and added to Firebase Authorized Domains, if applicable

troubleshooting

build fails

  • Wrong Node version: Select Node.js 22.x. The web build's engines pin (web/package.json) and firebase-admin 14 both require Node 22; Node 20 reproduces a next build failure.
  • Install fails: Use npm ci --legacy-peer-deps, matching web/nixpacks.toml.
  • Missing client env var: Verify every required NEXT_PUBLIC_FIREBASE_* variable is set for the build environment.
  • TypeScript errors: Run npm run build locally with the same Node and environment values.
  • Dependency issues: Ensure web/package-lock.json is committed.

app crashes after deploy

  • Check runtime logs in the host deployment logs.
  • Look for env var validation errors such as Firebase Admin SDK: Missing required environment variables.
  • Verify Firebase Admin variables: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY.
  • Verify session and encryption variables: SESSION_SECRET, MFA_ENCRYPTION_KEY, LLM_ENCRYPTION_KEY.
  • Verify R2 variables: R2_S3_ENDPOINT, R2_S3_ACCESS_KEY_ID, R2_S3_SECRET_ACCESS_KEY.
  • Verify CRON_SECRET, email, rate-limit, and Instatus variables when those routes or integrations are enabled.

auth not working

  • Add the deployment domain to Firebase Authorized Domains.
  • Verify NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN and NEXT_PUBLIC_BASE_URL match the target environment.
  • Clear browser cache and cookies.
  • Check browser console for Firebase error details.

roost uploads or version fetches fail

  • Verify the R2 credentials have object read and write access to the owlette buckets.
  • Verify R2_S3_ENDPOINT uses https://<account-id>.r2.cloudflarestorage.com.
  • Check ROOST_ENV, RAILWAY_ENVIRONMENT, and RAILWAY_PUBLIC_DOMAIN if writes are landing in the wrong dev/prod bucket.

slow performance

  • Cold starts: Use a plan that keeps the app warm for production.
  • Bundle size: Run npm run build and check .next/static output.
  • Firestore queries: Add indexes for frequently queried fields.

cost

railway pricing

Check the provider pricing page before provisioning. Keep development and production in separate projects or services so their usage, secrets, and billing limits can be managed independently.

optimization

  • Use separate plans or projects for development and production.
  • Optimize bundle size for faster cold starts.
  • Add a CDN such as Cloudflare for static assets and custom-domain traffic.

on this page