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.
| source | baseline |
|---|---|
web/package.json engines | node 22.x |
Repository root package.json | node >=20.9.0, npm >=10.0.0 |
Repository .nvmrc | 20.18.0 |
web/nixpacks.toml | nodejs_20, npm-10_x |
| Firebase Functions | Node 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).
railway deployment (recommended)
step 1: create railway project
- Go to railway.app and sign up with GitHub.
- Click New Project -> Deploy from GitHub repo.
- Select the owlette repository.
step 2: configure service
- Click your service -> Settings.
- Set Root Directory to
web. - Set Branch to
mainfor production ordevfor development. - 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 theINSTATUS_COMPONENT_*_IDvalues when/api/cron/status-pingshould publish to Instatus - Autonomous hoot:
CORTEX_INTERNAL_SECRETwhen 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
- Copy your Railway deployment URL.
- Go to Firebase Console -> Authentication -> Settings -> Authorized Domains.
- 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)
- Railway -> Settings -> Networking -> Add Custom Domain.
- Add the CNAME or A record as instructed.
- Railway provisions SSL automatically.
- Add the custom domain to Firebase Authorized Domains.
- Update
NEXT_PUBLIC_BASE_URLto the custom HTTPS origin and redeploy.
vercel deployment
Vercel can run the web app as a standard Next.js project.
step 1: import project
- In Vercel, click Add New -> Project.
- Import the owlette repository.
- Set Root Directory to
web. - Set the framework preset to Next.js.
step 2: configure build settings
Use the same commands as the checked-in Railway/Nixpacks deployment:
| setting | value |
|---|---|
| Install Command | npm ci --legacy-peer-deps |
| Build Command | npm run build |
| Output Directory | Next.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.
| endpoint | schedule | auth header | purpose |
|---|---|---|---|
/api/cron/health-check | */5 * * * * | X-Cron-Secret | flags machines with stale heartbeats |
/api/cron/process-alerts | */3 * * * * | X-Cron-Secret | drains pending_process_alerts into email |
/api/cron/display-alerts | */3 * * * * | X-Cron-Secret | drains pending_display_alerts into email |
/api/cron/status-ping | every 60s | X-Cron-Secret | writes status pings, publishes to Instatus |
/api/cron/retention | daily | X-Cron-Secret | deletes metrics + logs past 400 days |
/api/cron/talons | every 60s | X-Cron-Secret | claims due talons (schedule triggers), missed-fire grace, stale-run recovery |
/api/hoot/escalation | every 5 min | Authorization: Bearer | hoot 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:
| branch | deployment | url |
|---|---|---|
dev | Development | dev.owlette.app |
main | Production | owlette.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 startRequirements:
- Node.js 22.x, npm 10.x
- All required environment variables from Environment Variables
- A writable port supplied as
PORTor 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-depssucceeds -
npm run buildsucceeds - Required environment variables are set for the target environment
-
NEXT_PUBLIC_BASE_URLmatches 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 startwhere the host requires one -
CRON_SECRETconfigured - 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/escalationjob sendsAuthorization: Bearer <CRON_SECRET>— the other six routes takeX-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
enginespin (web/package.json) andfirebase-admin14 both require Node 22; Node 20 reproduces anext buildfailure. - Install fails: Use
npm ci --legacy-peer-deps, matchingweb/nixpacks.toml. - Missing client env var: Verify every required
NEXT_PUBLIC_FIREBASE_*variable is set for the build environment. - TypeScript errors: Run
npm run buildlocally with the same Node and environment values. - Dependency issues: Ensure
web/package-lock.jsonis 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_DOMAINandNEXT_PUBLIC_BASE_URLmatch 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_ENDPOINTuseshttps://<account-id>.r2.cloudflarestorage.com. - Check
ROOST_ENV,RAILWAY_ENVIRONMENT, andRAILWAY_PUBLIC_DOMAINif 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 buildand check.next/staticoutput. - 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.