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.SPECIALIST_EMAIL_DOMAINandNEXT_PUBLIC_APP_DOMAINare deprecated.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
Expert Computer / remote desktopβ
Expert Computer is a multi-transport production feature, not the retired VNC-only PoC. Machine provisioning, WebRTC/TURN, connector enrollment, TCC, Tailscale relay egress, direct WSS and recovery are documented in the canonical deployment and operations guide.
The API relay dials a raw VNC port; do not point org_machines.vnc_port at
websockify. Tailscale remains an operator-managed network dependency and must
not be started from NestJS. Local-only relay tests may use
REMOTE_DESKTOP_TARGET_HOST and REMOTE_DESKTOP_TARGET_PORT.
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
Dataset/schema changes spanning api + rag β deploy rag FIRSTβ
The rag service validates the dataset field of ingest/retrieval requests
against pydantic Literals, so an api that starts sending a new dataset name
(e.g. slack-archive) to an older rag gets hard 422s until rag catches up.
For any dataset addition, deploy rag BEFORE api.
On Railway dev, rag has no auto-deploy β it ships only via:
cd rag && railway up --service humanwork-rag
This exact gap caused the first live Slack-archive sync tick to fail on
2026-07-28: api was deployed with the new slack-archive dataset while dev
rag was still on the previous build.
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
# Add the local model credential consumed by dev-up.
./dev-up.sh
# Verify
curl http://localhost:3000/health
The root docker-compose.yml is the consolidated local browser-development stack. dev-up.sh also builds and exports the immutable managed-Hermes runner image before starting it.
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 debug-role JWTs without authentication when DEMO_MODE=true; api/src/auth/auth.controller.ts and deployment env guards are authoritative. |
| 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.