Skip to content

Production deployment

Carabase runs on your always-on Mac, reachable only over Tailscale — no public ports, no cloud dependency, no Docker-in-a-VM on the Mac itself. Deploys are a local source build restarted via launchd.

ScriptRuns whereWhat it does
scripts/install-prod-service.shprod MacOne-time: installs the launchd LaunchAgent
scripts/deploy-prod.shprod MacEvery deploy: backup → pull → build → restart → smoke → auto-rollback
scripts/rollback-prod.shprod MacManual rollback to a specific sha/tag
scripts/smoke-prod.tsanywhereRead-only HTTP smoke against a running host URL

All four scripts load .env.production via scripts/load-env.sh so they share a single, tested env-resolution path.

pnpm install-prod-service writes ~/Library/LaunchAgents/dev.carabase.host.prod.plist with:

  • RunAtLoad=true — start on login
  • KeepAlive.SuccessfulExit=false — auto-restart on crash
  • ThrottleInterval=10 — don’t flap if something is badly broken
  • StandardOutPath=~/.carabase/logs/prod.log
  • StandardErrorPath=~/.carabase/logs/prod.err
  • ProgramArguments = ./scripts/with-env.sh prod -- node dist/server.js

The installer refuses if .env.production or dist/server.js don’t exist — a flapping service that can’t start up is worse than a missing service.

Every run:

  1. Loads .env.production, classifies DATABASE_URL, refuses if the db name isn’t *_prod / *_production
  2. Acquires a lock file at ~/.carabase/deploys/.lock so two deploys can’t race
  3. Records the current git sha (PRE_SHA) and branch, writes audit metadata to ~/.carabase/deploys/<ts>.json
  4. Runs pnpm backup:prod (every change gets a backup first). --skip-backup exists but prints a loud warning and should only be used in emergencies
  5. git fetch origin --tags + git checkout --detach <target> (default origin/main, override with --tag v0.2.0 or --ref 1a2b3c)
  6. pnpm install --frozen-lockfile
  7. pnpm db:migrate:prod (Drizzle handles forward migrations only)
  8. pnpm build
  9. launchctl kickstart -k gui/<uid>/dev.carabase.host.prod (atomic restart)
  10. Poll http://127.0.0.1:$PORT/api/v1/health until 200 or 30s
  11. Run pnpm smoke:prod --url <live-url> — the read-only prod smoke

On any failure after step 9: auto-rollback. git checkout $PRE_SHApnpm installpnpm buildlaunchctl kickstart -k → best-effort health check → exit 1. The audit metadata is updated with status: rolled_back + failed_at_sha + rolled_back_to.

--dry-run prints the plan without executing. Every run tees into ~/.carabase/deploys/<ts>.log.

Terminal window
# On your dev machine — make changes, open PR, merge to main
git push
# → CI runs lint + test + smoke:e2e
# → PR merged to main
# On the prod Mac (or ssh in via Tailscale)
cd ~/Code/carabase-host
pnpm deploy:prod
# → runs backup → pulls main → migrate → build → restart → smoke
# → auto-rollback on any failure
# → audit log at ~/.carabase/deploys/<ts>.log

To pin prod to a specific tagged version:

Terminal window
pnpm deploy:prod --tag v0.2.0

To emergency-roll back to the previous tag (doesn’t run migrations — restore from backup if the schema needs to move):

Terminal window
pnpm rollback:prod --to v0.1.0

scripts/smoke-prod.ts is the post-deploy gate. Unlike the E2E smoke (which creates a scratch DB + seeds + spawns a subprocess), this one:

  • Takes --url <host-url> and hits it with real fetch() calls
  • Does NOT create, seed, or mutate any DB state
  • Does NOT require a known workspace id to run basic liveness
  • With --workspace-id <uuid>, additionally runs read-only scoped checks: model-routing GET, folios list, canonical entities list

Unauthenticated checks always run:

  • GET /api/v1/health → 200
  • GET /health → 200
  • GET /api/v1/folios without header → 400 (confirms the auth middleware is wired — a critical security regression otherwise)

See docs/FIRST_DEPLOY.md in the repo for the complete one-time setup checklist: prereqs, env files, setup:envs, first build, backup cron install, service install, first deploy, first tag, desktop reconnection, backup/restore stress test.