h.work β Deployment Guide
Today (2026-06-04): Dev runs on Railway; staging + prod run on AWS ECS Fargate. Terraform + GitHub Actions landed in #1101; staging boot validated #1303; prod bootstrap #1529; CI promotion enforcement #1559; manual
terraform-apply#1577; composite deploy actions #1314/#1327. The Railway-hosted setup documented below remains relevant for dev only. For the prod ECS target architecture seedocs/ops/ecs-prod-deployment-spec.md. For runbook material when running on ECS seedocs/runbooks/incident-response.md.
Domains by environmentβ
The webapp is served via app.{HWORK_DOMAIN} in every env (so production is app.h.work, staging is app.h853.work, dev is app.h852.work). Dev historically ran at the bare dev.h852.work subdomain β no app. prefix β and may still need to be in the CORS allowlist for the dev API explicitly until that legacy host is fully retired.
| Environment | Frontend | API | HWORK_DOMAIN env var | Specialist email pattern | CORS allowlist (ALLOWED_ORIGINS) |
|---|---|---|---|---|---|
| dev | https://app.h852.work (legacy: https://dev.h852.work) | https://api.h852.work (or Railway URL) | h852.work | {name}@h852.work | https://app.h852.work,https://*.h852.work,https://dev.h852.work |
| staging | https://app.h853.work | https://api.h853.work | h853.work | {name}@h853.work | https://app.h853.work,https://*.h853.work |
| production | https://app.h.work + per-org {slug}.h.work | https://api.h.work | h.work | {name}@h.work | https://app.h.work,https://*.h.work |
β οΈ The domain
humanwork.aiis NOT used. Older docs may reference it from a pre-pivot snapshot β replace withh.work(prod) when found.Source of truth:
api/src/common/env.ts+.env.exampleβHWORK_DOMAINandNEXT_PUBLIC_HWORK_DOMAINcontrol all domain-derived behavior (CORS, Specialist email, client portal subdomain). If you seeSPECIALIST_EMAIL_DOMAINorNEXT_PUBLIC_APP_DOMAINanywhere, those are deprecated βHWORK_DOMAINreplaced them per CLAUDE.md.Canonical zone-to-env mapping: see hp-knowledge-base β Domains & Environments.
Hosting + database pooler per environmentβ
| Environment | Runtime | Database | Connection pooler |
|---|---|---|---|
dev (api.h852.work) | Railway | Railway Postgres (postgres.railway.internal:5432) | None β direct connection. Pool size defaults to DATABASE_POOL_MAX=20 (raised from 5 in #2540: the Ops setup wizard step-3 fans out ~8 parallel API calls and a pool of 5 exhausted under burst). |
staging (api.h853.work) | AWS ECS Fargate | RDS PostgreSQL | AWS RDS Proxy (Terraform: infra/terraform/modules/data/main.tf, resource aws_db_proxy.this) |
production (api.h.work) | AWS ECS Fargate | RDS PostgreSQL | AWS RDS Proxy (Terraform: infra/terraform/modules/data/main.tf, resource aws_db_proxy.this) |
The dev environment is intentionally left without a pooler (decision 2026-06-05, #1233). RDS Proxy handles all production pooling responsibilities on AWS; bringing up a separate PgBouncer on Railway dev added operational surface area for very little benefit at dev's load profile. If multi-pod connection saturation ever becomes a real issue on Railway dev, the path is either (a) put PgBouncer back in β Railway plugin still exists, just unwired β or (b) accelerate the dev-to-AWS migration. Don't reintroduce PgBouncer reflexively; check whether you're actually saturating first.
The startup invariant in api/src/common/startup-invariants.ts enforces in production builds (APP_ENV=production) that DATABASE_URL resolves to a known pooler hostname pattern (*.proxy-*.<region>.rds.amazonaws.com for RDS Proxy, pgbouncer substring for the Railway path). The invariant is dormant on dev because dev runs APP_ENV=development (note: Railway sets NODE_ENV=production on all Railway services β including dev β so NODE_ENV is not the distinguishing variable; APP_ENV is).
Architectureβ
Scope below: dev / Railway only. Staging + prod run on AWS ECS Fargate per ADR-028; for those envs use
docs/ops/ecs-prod-deployment-spec.mdand the Terraform/GitHub Actions wiring in #1101 / #1303 / #1559. The Railway diagram and steps in this section apply to the dev environment only.
Internet
β
βββ Vercel (CDN) frontend (Next.js) β staging + prod redirected to AWS ALB after #1720
β
βββ AWS ALB (staging, prod)
β ββ ECS Fargate: humanwork-api, humanwork-agent, humanwork-rag
β β RDS PostgreSQL via RDS Proxy
β β ElastiCache Redis
β
βββ Railway (dev only)
ββ humanwork-api NestJS :3000 /health
ββ humanwork-agent FastAPI :8000 /health
ββ Postgres PostgreSQL 16 + pgvector (direct, no pooler)
ββ redis-dev Redis 7
Prerequisitesβ
- Railway CLI:
npm install -g @railway/cli - Vercel CLI:
npm install -g vercel - Railway account with a project created
- Vercel account with a project created
1. Railway β First-time Setupβ
# Authenticate
railway login
# Link to existing project (or create new)
railway link
Create services in Railway dashboardβ
Create 4 services manually in the Railway UI:
humanwork-postgresβ add PostgreSQL pluginhumanwork-redisβ add Redis pluginhumanwork-apiβ point toapi/directoryhumanwork-agentβ point toagent/directory
Set environment variablesβ
For each service, configure via Railway dashboard β Variables, or use the CLI:
# API service
railway variables set \
DATABASE_URL="$DATABASE_URL" \
REDIS_URL="$REDIS_URL" \
NODE_ENV=production \
JWT_SECRET="$(openssl rand -hex 32)" \
OPENAI_API_KEY="$OPENAI_API_KEY" \
AGENT_SERVICE_URL="https://humanwork-agent.up.railway.app" \
ALLOWED_ORIGINS="https://app.h.work,https://*.h.work" \
HWORK_DOMAIN=h.work \
PLATFORM_API_TOKEN="$(openssl rand -hex 32)" \
DATABASE_SSL=true \
--service humanwork-api
# Agent service
railway variables set \
OPENAI_API_KEY="$OPENAI_API_KEY" \
PLATFORM_API_URL="https://humanwork-api.up.railway.app" \
PLATFORM_API_TOKEN="$PLATFORM_API_TOKEN" \
--service humanwork-agent
Dev API Tailscale userspace mode for remote desktop (#3769)β
The remote-desktop PoC is dev-only. The Railway dev API must be joined to
the Tailscale tailnet so /v1/remote-desktop can open a TCP connection to the
Mac Mini running websockify. This is an operator step, not application code:
do not start tailscaled from NestJS and do not commit the auth key.
Required dev API variables:
railway variables set \
TS_AUTHKEY="$TS_AUTHKEY" \
--service humanwork-api
The API container/runtime must run userspace tailscaled with that auth key
before starting node /app/dist/src/main. Keep the key reusable only if the
Tailscale admin policy requires it, tag it for the dev API service, and rotate it
after the PoC if it is no longer needed.
The single dev machine registry row is data, not application logic. After the
org_machines migration has run on dev, seed the Humanity dev org machine row
from api/scripts/dev-org-machine.sql:
cd api
railway run --service humanwork-api psql "$DATABASE_URL" -f scripts/dev-org-machine.sql
For local verification without Tailscale, point the proxy at a local websockify or fake TCP server instead of the registry target:
REMOTE_DESKTOP_TARGET_HOST=127.0.0.1
REMOTE_DESKTOP_TARGET_PORT=8900
2. Railway β Deploy Servicesβ
# Deploy API
cd api && railway up --service humanwork-api
# Deploy Agent
cd ../agent && railway up --service humanwork-agent
Logs:
railway logs --service humanwork-api
railway logs --service humanwork-agent
3. First-time Setup (Migrations + Seed)β
After the API service is running for the first time, initialize the database:
# 1. Run migrations
cd api && npm run typeorm migration:run -- -d dist/data-source.js
# 2. Seed demo users (dev / staging only β do NOT run on production)
npm run seed
The seed script is idempotent β safe to run multiple times.
Demo credentials (dev/staging only)β
See README.md for the canonical seed-account list. Run bash scripts/seed-dev.sh [dev|staging] to seed. Highlights:
| Password | Role | |
|---|---|---|
e@humanity.org | 2a7e7f02 | superadmin |
sarah.chen@h852.work | 881b1d6b | account_manager |
david.kim@h852.work | 881b1d6b | expert |
amy@acmefinancial.com | 881b1d6b | org_admin (Acme Financial) |
3a. Database Extensionsβ
pgvector and pg_trgm extensions are installed automatically on first startup via scripts/setup-db.sh (mounted as a postgres init script in Docker).
For Railway's managed postgres, run once after first deploy:
railway run --service humanwork-postgres \
psql "$DATABASE_URL" -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;"
4. Vercel β Deploy Frontendβ
cd frontend
# First time: link project
vercel link
# Set env vars
vercel env add NEXT_PUBLIC_API_URL production # https://api.h.work
vercel env add NEXT_PUBLIC_WS_URL production # wss://api.h.work
# Deploy
vercel --prod
5. GitHub Actions β CI/CD Secretsβ
Add these secrets to the repo (Settings β Secrets β Actions):
| Secret | Value |
|---|---|
RAILWAY_TOKEN | railway whoami --token output |
VERCEL_TOKEN | Vercel account token |
VERCEL_ORG_ID | From .vercel/project.json |
VERCEL_PROJECT_ID | From .vercel/project.json |
API_URL | https://api.h.work |
Once set, every push to main triggers auto-deploy. Branch strategy: dev β staging β main (production). Promotion is CI-gated per PR #1559 / docs/promotion-flow.md. Terraform apply is manual-only (workflow_dispatch) per #1577 β infra changes never auto-apply on push.
6. Health Checksβ
API_URL=https://api.h.work # prod; staging: https://api.h853.work, dev: https://api.h852.work
# Liveness
curl "$API_URL/health"
# Readiness (DB + agent connectivity)
curl "$API_URL/ready"
# Metrics
curl "$API_URL/metrics"
7. Local Developmentβ
cp .env.example .env
# Set OPENAI_API_KEY in .env
docker compose up --build
# Verify
curl http://localhost:3000/health
The docker-compose.override.yml is auto-merged and enables hot reload for all services.
8. Rollbackβ
Railway keeps the last 5 deploys. To rollback:
# Via dashboard: Railway β Service β Deployments β select previous β Rollback
# Via CLI (get deploy ID from logs)
railway rollback --service humanwork-api --deployment <deploy-id>
Troubleshootingβ
| Symptom | Check |
|---|---|
API 503 on /health | railway logs --service humanwork-api β likely DB timeout |
| Migrations fail | Check DATABASE_URL and DATABASE_SSL=true on Railway |
| Agent unreachable | Verify AGENT_SERVICE_URL (today: Railway internal URL. Post-ECS cutover: Cloud Map agent.humanwork.internal:8000 β see ecs-prod-deployment-spec.md Β§15 Q1.) |
| WhatsApp webhook failing | Verify TWILIO_SUBACCOUNT_TOKEN in Railway env vars; check Twilio Console for errors |
| CORS errors | Add origin to ALLOWED_ORIGINS env var (comma-separated) |
Environment Variablesβ
APP_ENV β The Canonical Environment Flagβ
APP_ENV (backend) / NEXT_PUBLIC_APP_ENV (frontend) is the authoritative variable for distinguishing environments at the feature/logic level.
| Value | Where |
|---|---|
development | Local dev (default) |
staging | Railway staging env / Vercel preview |
production | Railway production env / Vercel production |
Why not NODE_ENV? Next.js sets NODE_ENV=production in any production build, including staging. APP_ENV is explicitly set per Railway/Vercel environment so you can tell staging from production.
Rule: Never set NODE_ENV manually. It is controlled by Node.js and Next.js automatically.
Setting it on Railwayβ
# Staging environment
railway variables set APP_ENV=staging --environment staging
railway variables set HWORK_DOMAIN=h853.work --environment staging
# Production environment
railway variables set APP_ENV=production --environment production
railway variables set HWORK_DOMAIN=h.work --environment production
Setting it on Vercelβ
In Vercel project settings β Environment Variables:
NEXT_PUBLIC_APP_ENV=stagingβ Preview environmentNEXT_PUBLIC_APP_ENV=productionβ Production environmentNEXT_PUBLIC_HWORK_DOMAIN=h852.workβ Staging/PreviewNEXT_PUBLIC_HWORK_DOMAIN=h.workβ Production
Note:
HWORK_DOMAIN(backend) andNEXT_PUBLIC_HWORK_DOMAIN(frontend) replace the oldSPECIALIST_EMAIL_DOMAINandNEXT_PUBLIC_APP_DOMAINvariables. Remove the old variables from any env where they appear.
Usage in codeβ
// Backend (api/src/common/env.ts)
import { IS_PRODUCTION, IS_STAGING, IS_DEPLOYED } from "./common/env";
// Frontend (frontend/src/lib/env.ts)
import { IS_PRODUCTION, IS_STAGING, IS_DEPLOYED } from "@/lib/env";
NODE_ENV is still appropriate for purely technical Node.js concerns: TypeORM SQL logging, DB SSL, and secure-cookie flags β these are correct as-is.
9. Multi-pod safety (#865, #1007)β
The API runs active-active across multiple pods (compose/Railway). Two contracts must hold:
- Idempotent webhook/inbound paths β channel webhooks (Slack/Email/Telegram/WhatsApp) use the dedup keys defined in ADR-013. Replays land on the same conversation.
- Leader-gated cron β periodic jobs (digest emails, queue-stale sweeps, reconciliation) are wrapped in a leader-election lock (#1007). Without this, every pod runs the cron and clients receive N duplicate digests. Cron handlers MUST call
leaderLock.acquire(jobName)before executing.
See ADR-018 (Multi-Pod Safety Contract) for the full invariant set. Adding a new cron without leader-gating is a code-review block.
10. Sentry (#845)β
Both agent (Python) and frontend (Next.js) emit to Sentry. The API has emitted since earlier rollouts.
Required env vars (all three services):
SENTRY_DSN=<service-specific DSN>
SENTRY_ENVIRONMENT=<dev|staging|production> # mirrors APP_ENV
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10% in prod; raise to 1.0 in staging
Source maps for the frontend are uploaded as part of the vercel --prod step (sentry-cli releases files <ver> upload-sourcemaps). If a release shows minified stack traces only, the upload step failed β check the Vercel build log for the Sentry CLI block.
11. Throttler β shadow mode (#812, #839)β
Per ADR-021 (renumbered 2026-06-01 via #1229), the global ThrottlerModule runs in shadow mode for the first soak window after deploy:
- #812 β global API throttler (Redis-backed storage, cross-pod shared counters).
- #839 β per-org channel-webhook throttle (Slack/Email/WhatsApp inbound).
Shadow-mode flag: THROTTLER_MODE=shadow β guards count + log would-have-blocked but do NOT 429. Promote to enforce by setting THROTTLER_MODE=enforce once the dashboard shows steady-state below limits for β₯1 week.
Required env (both modes):
REDIS_URL=<...> # MUST be reachable from every API pod
THROTTLER_MODE=shadow|enforce
THROTTLER_WEBHOOK_RPS=<per-org limit>
12. Lago billing (#1069, #1105, #1155)β
The platform bills via Lago β self-hosted; dev on Railway, staging + prod on AWS (consolidated single instance serving both via separate Lago orgs). AWS Lago bring-up tracked in #1287.
Workspace setup steps:
- Create the Lago org / workspace (one per HP environment β dev, staging, prod β under the Lago instance the env points at: dev β Railway Lago; staging + prod β AWS Lago).
- Issue an API key with scope
events:write,customers:read,subscriptions:read. - Wire env on the API service (canonical framing β see
docs/ops/railway-env-vars.mdΒ§Lago for the per-env URL):
LAGO_API_KEY=<scoped key>
LAGO_API_URL=<Lago instance URL β Railway for dev; AWS for staging + prod (see #1287)>
LAGO_WEBHOOK_SECRET=<hmac shared secret>
- Run the customer-backfill script (
scripts/backfill-lago-customers.ts) once per environment to sync existing orgs.
Cross-ref: docs/decisions/2026-05-29-lago-pricing-strategy.md defines the pricing model; #1069 wires the event emitters, #1105 the customer sync, #1155 the subscription/invoice surface in /ops/billing. The previously-evaluated Orb vendor is not in use β any Orb references in older docs are historical.
13. Security: Production startup invariants (P0-5)β
The API validates critical environment variables at startup via validateProductionInvariants() in api/src/common/startup-invariants.ts. The process exits with code 1 if any invariant fails, preventing unsafe deployments.
Enforced in production (APP_ENV=production):
| Invariant | What it checks | Why |
|---|---|---|
| DEMO_MODE β true | process.env.DEMO_MODE !== 'true' | /auth/demo-login hands out superadmin JWTs without authentication when DEMO_MODE=true. Single highest-blast-radius misconfiguration. See CLAUDE.md "Security Rules" #5. |
| JWT_SECRET length | JWT_SECRET.length >= 64 | Weak secrets enable JWT forgery |
| SENTRY_DSN present | !!SENTRY_DSN | Observability compliance β prod must report errors |
| MASTER_ENCRYPTION_KEY present | !!MASTER_ENCRYPTION_KEY | Data encryption compliance β encrypts sensitive DB fields |
| DATABASE_URL uses pooler | URL hostname matches *.proxy.rlwy.net, *pgbouncer*, etc. | Multi-pod safety β direct connections exhaust PG max_connections |
| REDIS_URL present | !!REDIS_URL | Cross-pod message passing β BullMQ, Socket.io, throttler, circuit breaker all degrade silently to in-memory mode without it (#1354) |
Defense in depth for DEMO_MODE:
- Startup guard (runtime):
validateProductionInvariants()exits before HTTP listener binds - CI grep guard (pre-deploy):
.github/workflows/ci.ymlfails if.env.production*containsDEMO_MODE=true - Request-time guard (code regression protection):
/auth/demo-loginendpoint throwsForbiddenExceptionwhenAPP_ENV === 'production'
All three layers are spec'd with Jest tests (see api/src/common/startup-invariants.spec.ts and api/src/auth/demo-login.spec.ts).
Scanned files (CI + startup):
.env.production*β any file matching this glob is checked by CI- Runtime environment variables read from Railway/ECS at process start
Behavior on violation:
- CI: Job
check-demo-modefails with exit code 1, blocking merge/deploy - Startup: Process logs error summary and exits with code 1 before binding port
- Request-time: Endpoint returns HTTP 403 Forbidden
To add a new invariant:
- Add check to
validateProductionInvariants()andcheckProductionInvariants()arrays - Add test cases to
startup-invariants.spec.ts - Update this runbook table
14. Migration runner β transaction-each mode (#953)β
The TypeORM migration runner is configured transaction: 'each' (per-migration transactions) rather than 'all' (one big transaction). This is what enables CREATE INDEX CONCURRENTLY migrations β Postgres rejects concurrent index builds inside an outer transaction.
Practical rule:
- OK in same migration: schema-only DDL, transactional DDL,
CREATE INDEX(non-concurrent). - Must be its own migration:
CREATE INDEX CONCURRENTLY,REINDEX CONCURRENTLY,VACUUM, anything documented as "cannot run inside a transaction block".
A migration that mixes concurrent and non-concurrent statements will fail at runtime even though TypeScript compiles. See #953 for the migration linter that catches this in CI.