ECS Production Deployment Spec
Status: Draft Β· Owner: Platform Β· Date: 2026-05-29
Scope: Production only (dev + staging remain on Railway/Vercel). IaC: Terraform. Launch type: Fargate.
Supersedes/extends: ADR-003 (Railway deployment) for the prod environment; reuses the deferred assets in agent/deploy/ecs.deferred/ (tracking issue humanity-org/humanwork#238).
Implemented. The Terraform for this spec lives in
infra/terraform/: reusablemodules/composed bymodules/stack, instantiated by two thin env roots βenvs/staging(h852.work) andenvs/prod(h.work) β in one AWS account, S3-native state locking, shared GitHub OIDC provider inbootstrap. DNS stays in Cloudflare (Terraform creates no DNS records; you CNAME to the ALB output), and the ACM cert is operator-provided. Deploy automation:.github/workflows/{terraform-plan,terraform-apply,api-deploy,agent-deploy,reusable-ecs-deploy}.yml(plan both envs on PR; apply staging fromstaging/ prod frommain; per-env app deploys). Setup + cutover runbook:infra/terraform/README.md. Validated withterraform validate,tflint(AWS ruleset), andactionlint.
What prod actually runs today (verified against the repo, not assumed): ADR-003 β one Railway project
humanworkwith managed Postgres 16+pgvector and Redis 7, plushumanwork-apiandhumanwork-agentservices; frontend on Vercel, not Railway. The agent runs as one shared service (AGENT_RUNTIME_PROVISIONER=shared_railway) β there are no per-org agent runners in production. Region: Railway US-West. Deploys are manual (railway up --service <name>); gitflow promotion is--ff-onlydevβstagingβmain. This re-scopes the migration below.
1. Goal & non-goalsβ
Goal. Stand up the h.work production environment on AWS ECS Fargate: the long-running services (api, shared agent; frontend β see note), the managed data stores they depend on, and a CI/CD path that builds, pushes, and deploys them.
Re-scope based on current prod: the dynamic per-org agent runners are NOT part of the cutover β they don't run in prod today (
shared_railway) and theecs-per-org.provisioner.tsis an unimplemented stub (throwsNotImplementedError, "ECS per-org provisioner is M2 work; see #362c"). They are M2/future (Β§4 kept as forward-looking design). A like-for-like cutover needs only:api+ sharedagent+ data stores + CI/CD.Frontend is on Vercel today, not Railway. Moving it to ECS is optional and independent of the API cutover. Decide explicitly (Β§15 Q7): keep it on Vercel (zero work, recommended for v1) or containerize it onto ECS. The frontend service spec below applies only if you choose to move it.
Non-goals.
- Migrating dev/staging off Railway/Vercel (explicitly out of scope β those stay).
- Re-architecting the app. This is a hosting move; the app already obeys the active-active constraints in
CLAUDE.md(no in-process state, BullMQ for cron/background, Redis/PG for shared state). - A blue/green or canary rollout system on day one (ECS rolling deploy with circuit breaker is enough to start).
Hard constraints carried from CLAUDE.md:
- Multi-instance active-active β every service runs β₯2 tasks; no sticky sessions; ALB stickiness off.
stopTimeout β₯ 120son the API task (2Γ the 60s max LLM call) so BullMQ workers drain.- Production startup invariants in
api/src/main.tswillprocess.exit(1)unless:DEMO_MODE!='true',JWT_SECRETβ₯ 64 chars,SENTRY_DSNset,MASTER_ENCRYPTION_KEYset,DATABASE_URLpoints at RDS Proxy. Provision accordingly.
2. Target topologyβ
Cloudflare DNS (CNAME api.h.work β ALB; external)
β
βββββββββ΄βββββββββ
β ACM cert (provided) β
βββββββββ¬βββββββββ
β
ββββββββββββΌββββββββββββ ββββββββββββββββββββββββ
internet ββββΊ β ALB (public) β β ALB (internal) β
β :443 β β :443 agent only β
βββββ¬ββββββββββββ¬βββββββ ββββββββββββ¬ββββββββββββ
β /api,/ws β / (default) β
ββββββββββΌββββ ββββββΌββββββββββ βββββββββββΌβββββββββββ
β api serviceβ βfrontend svc β β agent service β
β Fargate β₯2 β β Fargate β₯2 β β Fargate β₯2 β
β :3000 β β :3001 β β :8000 β
βββ¬ββββ¬ββββ¬βββ ββββββββββββββββ ββββββββββββββββββββββ
β β ββββββββββββββββΊ (calls agent service via internal ALB / Cloud Map)
β β
β ββββΊ ElastiCache Redis (BullMQ, Socket.io adapter, rate limit, CB)
β
ββββΊ RDS Proxy βββΊ RDS Postgres 16 + pgvector (Multi-AZ)
Per-org agent runners (on demand, NOT behind ALB):
ββββββββββββββββββββββββββββββββββββββββββββββ
β ECS RunTask: humanwork-agent-<org>-<role> β
β Fargate, EFS workspace mount /app/workspace β
β provisioned by api/src/agent-runtime/ β
β provisioners/ecs-per-org.provisioner.ts β
ββββββββββββββββββββββββββββββββββββββββββββββ
Shared external (not in VPC): Cloudflare R2, OpenAI/OpenRouter, Resend,
Twilio, Slack, Stripe/Lago. KB/RAG is **in-stack**: Haystack 2.x + Hayhooks
on the `humanwork-rag` ECS service (Cloud Map `humanwork-rag.humanwork.internal:1416`),
per ADR-024 + #1527.
Why an internal ALB (or Cloud Map) for the agent: the shared agent service must never be internet-reachable β only the api service calls it (AGENT_SERVICE_URL). Put it behind an internal ALB or register it in Cloud Map (agent.humanwork.internal) and let the API resolve it. Cloud Map is cheaper and sufficient since it's purely service-to-service; ALB is only needed if you want per-target health-based routing. Recommendation: Cloud Map for the shared agent, ALB only for api + frontend.
3. Services (long-running) β task sizingβ
All Fargate, awsvpc, X86_64, in private subnets, egress via NAT.
| Service | Port | vCPU / Mem | Desired / Min / Max | Health (ALB / container) | stopTimeout | Notes |
|---|---|---|---|---|---|---|
api | 3000 | 1 vCPU / 2 GB | 2 / 2 / 6 | GET /ready (ALB), GET /health (container) | 120s | Runs migrations on boot; hosts Socket.io on same port. Scale on CPU + ALB req count. |
agent (shared) | 8000 | 1 vCPU / 2 GB | 2 / 2 / 4 | GET /health | 120s | Hermes shells out per request; β₯ HERMES_TIMEOUT_SEC so in-flight model calls finish before SIGKILL. Cloud Map, no public ALB. |
frontend | 3001 | 0.5 vCPU / 1 GB | 2 / 2 / 4 | GET / (200) | 30s | Next.js standalone (node server.js). Mostly static + SSR. |
ALB routing (public ALB):
- Listener :443 (ACM cert for
h.work,*.h.work). - Rule: host
api.h.workor path/api/*,/socket.io/*,/ws/*βapitarget group (sticky off, but Socket.io needs WebSocket upgrade β ALB supports it natively; ensure idle timeout β₯ 60s, set to 120s). - Default rule β
frontendtarget group. - Target group health checks:
apiβ/ready(matcher 200),frontendβ/(matcher 200,304).
WebSocket note: Socket.io over ALB works without stickiness because the app already uses the Redis Socket.io adapter (per CLAUDE.md). Confirm @socket.io/redis-adapter is wired to ElastiCache before cutover β otherwise cross-pod emits drop and the 8s polling fallback masks it.
Autoscaling: target-tracking on ECSServiceAverageCPUUtilization (60%) + ALBRequestCountPerTarget for api/frontend. Per-org runners are not autoscaled β they're RunTask-on-demand.
4. Per-org agent runners (dynamic Fargate) β M2 / NOT in cutoverβ
Status: deferred. Prod runs the shared agent (
shared_railway), so this whole section is forward-looking.api/src/agent-runtime/provisioners/ecs-per-org.provisioner.tsexists but every method throwsNotImplementedError("M2 work; see #362c"). The reference implementationrailway-per-org.provisioner.tswas decommissioned per ADR-028 / #1584 (PR merged) β when picking up the M2 work, consult the pre-#1584 git history for its config surface and lifecycle shape rather than expecting the file in-tree. Do not block the cutover on this.
When M2 starts, the assets are already templated in agent/deploy/ecs.deferred/ β promote to agent/deploy/ecs/ and wire it.
- Launch:
ECS RunTask(not a Service), one task per(org_id, agent_role), familyhumanwork-agent-${ORG_ID}-${ROLE}(template exists:task-definition.json). - Provisioner:
api/src/agent-runtime/provisioners/ecs-per-org.provisioner.ts(already in repo). SetAGENT_RUNTIME_PROVISIONER=ecs_per_orgin prod and supply the cluster/subnets/security-group/role ARNs it needs (see Β§7, Β§8). - Workspace: EFS, transit-encrypted, IAM-authorized, mounted at
/app/workspace. Access point per${OrgId}/${AgentRole}perefs-access-point.json(CloudFormation snippet β port to Terraformaws_efs_access_point, or keep creating access points dynamically at provision time). - Runner token: injected as a Secrets Manager
secrets[]entry (HUMANWORK_RUNNER_TOKENβ${HUMANWORK_RUNNER_TOKEN_SECRET_ARN}). One secret per runner registration. - Lifecycle: runner self-terminates on idle (
TERMINAL_LIFETIME_SECONDS, currently 300s) β confirm the provisioner also has a reaper/StopTaskpath + a BullMQ sweep for orphaned tasks (don't rely on a per-podsetInterval). - Sizing per the template: 1 vCPU / 2 GB. Revisit once real Hermes workloads are measured; this is the most likely cost driver.
Config surface to build (mirror the pre-#1584 Railway provisioner shape). The ECS provisioner currently reads nothing (stub). The Railway one (railway-per-org.provisioner.ts) was removed in #1584 / ADR-028, but historically it read, via the factory selected by AGENT_RUNTIME_PROVISIONER:
- Required:
AGENT_RUNTIME_RAILWAY_PROJECT_ID(fallbackRAILWAY_PROJECT_ID),AGENT_RUNTIME_RAILWAY_ENVIRONMENT_ID(fallbackRAILWAY_ENVIRONMENT_ID),RAILWAY_API_TOKEN,AGENT_SERVICE_SECRET. - Optional:
RAILWAY_AGENT_IMAGE(defaultghcr.io/humanity-org/humanwork-agent:dev),GHCR_PULL_USERNAME/GHCR_PULL_TOKEN,RAILWAY_DEPLOY_TIMEOUT_MS,RAILWAY_DEPLOY_POLL_MS. - Always injects into each runner:
HUMANWORK_ORG_ID/_SLUG,HUMANWORK_AGENT_NAME/_ROLE,PLATFORM_API_URL,PLATFORM_API_TOKEN,AGENT_SERVICE_SECRET,PORT=8000, theHERMES_*set, workspace paths,R2_KEY_PREFIX=humanwork/${orgId}. It explicitly deletesDATABASE_URLβ runners must never touch PG directly.
The ECS equivalent needs instead: cluster ARN, private subnets, runner SG, exec/task role ARNs, EFS filesystem id, AWS region, task-def family. Add those as AGENT_RUNTIME_ECS_* env on the api task def + .env.example when M2 lands.
Reaper: there is no idle reaper today on any provisioner β the pre-#1584 Railway teardownForOrg only fired on explicit org deactivation (serviceDelete). For ECS, runners self-expire via TERMINAL_LIFETIME_SECONDS=300, but a BullMQ sweep (StopTask on orphans + EFS access-point GC) must be built β do not add a per-pod setInterval (CLAUDE.md active-active rule).
5. Managed data storesβ
| Store | AWS resource | Sizing (start) | Notes |
|---|---|---|---|
| Postgres 16 + pgvector | RDS PostgreSQL 16, Multi-AZ | db.r6g.large, 100 GB gp3, autoscale to 500 GB | Enable vector extension. Required: front with RDS Proxy (prod invariant checks DATABASE_URL targets the proxy). |
| Redis | ElastiCache Redis 7, cluster-mode disabled, 1 primary + 1 replica, Multi-AZ + automatic failover | cache.r6g.large | Used for BullMQ, Socket.io adapter, rate limiter, circuit breaker, leader election. AUTH token + in-transit TLS β REDIS_URL=rediss://.... |
| EFS | EFS (per-org agent workspaces) | Elastic throughput, lifecycle to IA at 30d | Mount targets in each AZ; SG allows 2049 from agent-runner SG only. |
| Object storage | Cloudflare R2 (existing, external) | n/a | Keep R2 β no S3 migration. Buckets: R2_BUCKET_NAME, R2_CLIENT_BUCKET, KB_R2_BUCKET, RUNTIME_ARTIFACTS_R2_BUCKET. |
| KB / RAG | Haystack 2.x + Hayhooks (humanwork-rag ECS service, per #1527 / ADR-024) | small Fargate task; pgvector on the existing humanwork RDS (ADR-025); embed/chat/rerank via OpenRouter (ADR-026) | Replaces RAGflow per ADR-024/025/026. Cloud Map at humanwork-rag.humanwork.internal:1416. Budget target β $440/env-month. Deploy via .github/workflows/rag-deploy.yml (#1541). See docs/runbooks/haystack-deploy.md. |
RDS Proxy is load-bearing, not optional: the prod startup invariant in main.ts rejects a non-proxy DATABASE_URL. It also matters because Fargate tasks scale and each opens a PG pool (DATABASE_POOL_MAX, default 20 since #2540) β the proxy multiplexes so you don't exhaust max_connections.
6. Secrets & configβ
Pattern (matches the existing task-def secrets[] block): sensitive values come from AWS Secrets Manager via task-definition secrets[] (injected as env at start, never baked into the image); non-sensitive config via environment[].
Secrets Manager entries (one secret, JSON keys, referenced by valueFrom per key):
JWT_SECRET, RUNNER_JWT_SECRET, RUN_CONTRACT_SIGNING_SECRET, MASTER_ENCRYPTION_KEY, DATABASE_URL (RDS Proxy + creds β or compose from a separate RDS secret), REDIS_URL/REDIS_PASSWORD, OPENAI_API_KEY, OPENROUTER_API_KEY, RESEND_API_KEY, TWILIO_AUTH_TOKEN, TWILIO_SUBACCOUNT_TOKEN, SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN, TELEGRAM_BOT_TOKEN, CLOUDFLARE_EMAIL_WEBHOOK_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, LAGO_API_KEY/LAGO_WEBHOOK_SECRET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, HAYSTACK_API_KEY (per #1588 β sent as X-API-Key to humanwork-rag), PLATFORM_API_TOKEN, SANDBOX_WORKER_TOKEN, SENTRY_DSN*, plus per-runner HUMANWORK_RUNNER_TOKEN.
Plain environment[] (config): APP_ENV=production, HWORK_DOMAIN=h.work, NEXT_PUBLIC_HWORK_DOMAIN=h.work, AGENT_SERVICE_URL=http://agent.humanwork.internal:8000, HAYSTACK_BASE_URL=http://humanwork-rag.humanwork.internal:1416 (per ADR-024 + #1527; downstream sends X-API-Key per #1588), HAYSTACK_TIMEOUT_MS, HAYSTACK_CIRCUIT_*, HAYSTACK_TOP_K, HAYSTACK_MAX_UPLOAD_MB, PLATFORM_API_URL, DATABASE_SSL=true, DATABASE_POOL_MAX, AGENT_CHAT_TIMEOUT_MS=60000, AUTORESPOND threshold defaults, the frontend NEXT_PUBLIC_* (baked at build time for the frontend β see Β§9), and AGENT_RUNTIME_PROVISIONER=shared_railway (prod runs the shared agent; ecs_per_org + its ECS runner config β cluster/subnets/SG/role ARNs/EFS id β is M2, not the cutover; see Β§4, Β§15 Q3). RAGflow envs (RAGFLOW_*) are removed post-ADR-024 β Hayhooks is the replacement.
On the agent task: HW_IMAGE_GEN_ENABLED / HW_WEB_RESEARCH_ENABLED (#4022/#4028) β kill switches, fail-safe OFF default (the agent image bakes false), set per env via agent_environment in infra/terraform/modules/stack (prod false until sign-off; staging true). Plus their model/base-url config: HW_IMAGE_GEN_MODEL, HW_IMAGE_GEN_BASE_URL, HW_WEB_RESEARCH_MODEL, HW_WEB_RESEARCH_ROUTER_MODEL. These are plain config, not secrets. See railway-env-vars.md Β§Agent.
Frontend caveat:
NEXT_PUBLIC_*vars are inlined atnext build, not at runtime. The frontend image must be built per environment with prod values (NEXT_PUBLIC_API_URL=https://api.h.work,NEXT_PUBLIC_WS_URL=wss://api.h.work,NEXT_PUBLIC_APP_ENV=production,NEXT_PUBLIC_HWORK_DOMAIN=h.work, Sentry DSN). Don't expect to set these in the task definition.
Secret rotation: Secrets Manager rotation for DB creds (works with RDS Proxy). Rotating MASTER_ENCRYPTION_KEY requires a re-encrypt migration of integration_credentials β document, don't automate.
7. IAM rolesβ
Three role types (least privilege):
-
Task execution role (shared by all task defs):
AmazonECSTaskExecutionRolePolicy+secretsmanager:GetSecretValueon the specific secret ARNs + ECR pull + CloudWatch LogsCreateLogStream/PutLogEvents. (The template's${ECS_EXECUTION_ROLE_ARN}.) -
Task roles (per service, app-runtime perms β the template's
${ECS_TASK_ROLE_ARN}):apitask role:ecs:RunTask/ecs:StopTask/ecs:DescribeTasks+iam:PassRole(for the runner exec/task roles) scoped to the agent-runner task-def family β this is whatecs-per-org.provisioner.tsuses to spawn runners. Pluselasticfilesystem:*AccessPointif it creates EFS access points dynamically. No R2 (R2 is keyed creds, not IAM).agenttask role + per-org runner task role:elasticfilesystem:ClientMount/ClientWriteon the EFS fs (IAM auth isENABLEDin the template). R2 via env keys.frontendtask role: minimal (logs only).
-
CI/CD deploy role (GitHub OIDC, no long-lived keys):
ecr:*(push),ecs:RegisterTaskDefinition/UpdateService/DescribeServices,iam:PassRolefor the exec/task roles. Mirror the existingagent-ecs-deploy.ymlOIDC pattern.
8. Networkingβ
- VPC: 2β3 AZs. Public subnets (ALB, NAT GW), private subnets (all Fargate tasks, RDS, ElastiCache, EFS mount targets).
- Security groups:
alb-public-sg: 443 from 0.0.0.0/0.api-sg: 3000 fromalb-public-sg; egress toredis-sg:6379,rds-proxy-sg:5432,agent-sg:8000, NAT (R2/OpenAI/etc.),ecs:RunTask(AWS API via NAT/VPC endpoint).agent-sg: 8000 fromapi-sgonly.agent-runner-sg: egress toefs-sg:2049, NAT (R2/model gateway/PLATFORM_API_URLβapi). Inbound: none needed (RunTask, not behind LB) β but allow 8000 fromapi-sgif the API health-checks runners directly.frontend-sg: 3001 fromalb-public-sg.rds-proxy-sg: 5432 fromapi-sg.redis-sg: 6379 fromapi-sg.efs-sg: 2049 fromagent-runner-sg+agent-sg.
- VPC endpoints (cut NAT cost + keep AWS traffic private): ECR (api + dkr), S3 gateway (ECR layers), Secrets Manager, CloudWatch Logs, ECS.
- DNS/TLS: DNS stays in Cloudflare (Terraform creates no DNS records) β after apply, CNAME
api.h.workβ the ALB DNS name. The ACM cert is operator-provided (you DNS-validate it via a Cloudflare CNAME and pass the ARN). Cloud Map private namespacehumanwork.internalfor service-to-service.
9. CI/CD pipelineβ
Build β ECR β deploy, prod-only, triggered on push to main (current branch strategy: dev β staging β main). Reuse the OIDC + render-task-def + aws ecs update-service shape already in .github/workflows/agent-ecs-deploy.yml.
Per service:
- Build & push (
*-ecr-push.yml):docker buildeach ofapi/,agent/,frontend/, tag with the git SHA +latest-prod, push to ECR. Frontend build passes prodNEXT_PUBLIC_*as--build-arg(see Β§6 caveat). Three ECR repos:humanwork-api,humanwork-agent,humanwork-frontend. - Deploy (
*-ecs-deploy.yml):envsubstthe task-def template with the new image URI,RegisterTaskDefinition,UpdateService, wait for stability (the agent workflow uses a 15-min stability +/healthsmoke loop β copy it). - Migrations: the
apicontainer runsrun-migrations.shon boot (idempotent, TypeORM-tracked). Under expand-contract (CLAUDE.md) this is safe during rolling deploys. Enable ECS deployment circuit breaker with rollback so a migration/boot failure auto-reverts.
Promotion: dev/staging keep deploying to Railway as today; only main fans out to ECS. No auto-promotion (matches gitflow-deploy.yml).
Smoke after deploy: hit https://api.h.work/ready (checks PG, agent, Redis, throttler) and https://h.work/ through the ALB; fail the job on non-200.
10. Observabilityβ
- Logs:
awslogsdriver β CloudWatch. Theecs-servicemodule names each group/ecs/${name_prefix}-${service_name}, so withname_prefix = humanwork-<env>(humanwork-prod/humanwork-staging) that is/ecs/humanwork-<env>-api,/ecs/humanwork-<env>-agent,/ecs/humanwork-<env>-frontend. The M2 per-org runner fleet logs to its own group (/ecs/humanwork-agentinagent/deploy/ecs.deferred/task-definition.json) with a per-org/role stream prefix. Consider a FirelensβOTel/Datadog sink later. - Metrics: Container Insights on the cluster. Alarms: ALB 5xx rate, target unhealthy count,
api/readyfailures, RDS CPU/connections/free-storage, ElastiCache evictions/CPU, BullMQ depth via the existingGET /health/queues(scrape it into a CloudWatch custom metric or alert from a synthetic). - Errors: Sentry already wired (
SENTRY_DSN,SENTRY_DSN_AGENT,SENTRY_DSN_FRONTEND) β prod invariant requiresSENTRY_DSN. SetSENTRY_RELEASEto the git SHA in CI. - Tracing: optional ADOT sidecar later; not day-one.
11. Terraform module layoutβ
infra/terraform/
βββ bootstrap/ # run-once per account: S3 state bucket + GitHub OIDC provider
βββ envs/
β βββ staging/ # h852.work, smaller sizing, 2 AZs, deploys from `staging`
β βββ prod/ # h.work, prod sizing, 3 AZs, deploys from `main`
β βββ main.tf # module "stack" with prod literals
β βββ variables.tf # operator pass-through (acm cert arn, oidc arn, state bucket arn)
β βββ outputs.tf
β βββ backend.tf # S3 backend (S3-native lockfile; no DynamoDB)
β βββ *.example # backend.hcl.example, terraform.tfvars.example
βββ modules/
βββ network/ # VPC, subnets, NAT, SGs, VPC endpoints
βββ data/ # RDS + RDS Proxy, ElastiCache + DATABASE_URL/REDIS_URL secrets
βββ secrets/ # Secrets Manager containers (generated + external placeholders)
βββ iam/ # exec/task roles + GitHub OIDC deploy/terraform roles
βββ ecs-cluster/ # cluster, Cloud Map namespace, Container Insights, ECR repos
βββ alb/ # public ALB + api target group (operator-provided ACM cert)
βββ ecs-service/ # reusable task-def + service + autoscaling (instantiated for api, agent)
βββ stack/ # composition: wires all of the above for one environment
- State: S3 backend with S3-native locking (
use_lockfile, no DynamoDB); one bucket, per-env keys (staging/,prod/). The shared GitHub OIDC provider lives inbootstrap/. - Secrets: TF creates the Secrets Manager containers β generated internal values are stored, external ones hold
REPLACE_MEwithignore_changesso the real values are injected out-of-band and never enter TF-managed drift.DATABASE_URL/REDIS_URLare owned by thedatamodule. Reference by ARN in task defs. - Task defs: the
ecs-servicemodule authors the api + agent task defs from one shape. Per-org runner assets (the deferredagent/deploy/ecs.deferred/task-def + EFS access point) are M2 β noagent-runnermodule exists yet (see Β§4).
12. Sizing & rough monthly cost (us-west-2, order-of-magnitude)β
| Item | Config | ~$/mo |
|---|---|---|
| api Fargate | 2Γ (1 vCPU/2 GB) avg | ~$70 |
| agent Fargate (shared) | 2Γ (1 vCPU/2 GB) avg | ~$70 |
| frontend | stays on Vercel (separate bill); ~$35 only if moved to ECS | $0 here |
| per-org runners | M2 β not in cutover. bursty, org count Γ session minutes | n/a until M2 |
| RDS r6g.large Multi-AZ + Proxy | ~$450 | |
| ElastiCache r6g.large Γ2 | ~$300 | |
| ALB Γ1 (+ internal if used) | ~$25β50 | |
| NAT GW + data | 2 AZ | ~$70+ |
| EFS | elastic, low | ~$10β50 |
| Baseline (excl. runners + R2/LLM) | ~$1.1kβ1.3k/mo |
R2, OpenAI/OpenRouter, Twilio, Resend billed separately/externally. Haystack/Hayhooks is in-stack as the humanwork-rag ECS Fargate service (β $440/env-month target per ADR-024). The per-org runner fleet is the cost variable to watch β instrument session-minutes early.
13. Gotchas / prod invariants checklist (don't skip)β
-
DATABASE_URLβ RDS Proxy endpoint (elsemain.tsexits 1). -
JWT_SECRETβ₯ 64 chars,MASTER_ENCRYPTION_KEYset (generate 32 bytes / 64 hex; the startup invariant only checks presence, not length),SENTRY_DSNset,DEMO_MODEunset/false (CI already greps.env.production*forDEMO_MODE=true). -
apitaskstopTimeout = 120; ECS service deregistration delay β₯ 120s so BullMQ drains. - ALB stickiness off; ALB idle timeout β₯ 120s for WebSocket; WebSocket upgrade allowed.
- Socket.io Redis adapter pointed at ElastiCache before cutover (else cross-pod emits silently drop).
- No
setInterval/setImmediatebusiness work β verified, but re-check any runner-reaper code uses BullMQ. -
rediss://(TLS) + AUTH token for ElastiCache in transit. - Webhook signature verification still works behind ALB β confirm
rawBody:truesurvives (ALB doesn't mangle the body; just verify the inbound URLs are routed toapi). - Channel webhook public URLs updated to
api.h.workin Twilio/Slack/Cloudflare/Tavus configs at cutover. - ECS deployment circuit breaker + rollback enabled (catches bad migrations/boots).
- EFS access point cleanup for deprovisioned orgs (avoid unbounded growth).
14. Phased rolloutβ
Cutover path (like-for-like with current prod): api + shared agent + data stores. Frontend stays on Vercel; per-org runners are M2.
- Foundation β Terraform
network+data(RDS + RDS Proxy β no pooler exists today, this is new β ElastiCache, no EFS needed until M2) +iam+ ECR repos (humanwork-api,humanwork-agent) + Secrets Manager containers. Populate secrets.CREATE EXTENSION vectoron RDS. - DB migration dry-run β
pg_dumpfrom Railway PG βpg_restoreinto RDS; verify row counts + pgvector indexes. Confirms the cutover window size. - Services β
ecs-cluster+ecs-serviceΓ2 (api, sharedagent), Cloud Map for agent, public ALB with the operator-provided ACM cert forapi; CNAME a staging hostname in Cloudflare to the ALB, run/ready+ smoke while Railway still serves prod. Confirm Socket.io Redis adapter on ElastiCache. - Cutover β final
pg_dump/restore in a short read-only window, flip theapi.h.workCNAME in Cloudflare to the ECS ALB, repoint VercelNEXT_PUBLIC_API_URL/WS_URLβapi.h.work, update channel webhook URLs (Twilio/Slack/Cloudflare/Tavus), pointAGENT_SERVICE_URLat Cloud Map. Watch dashboards; keep Railway warm for DNS-flip rollback. - Decommission β after a soak window, retire the Railway prod project (dev/staging stay on Railway).
- (M2, later) β build the ECS per-org provisioner + EFS + runner reaper; promote
ecs.deferredβecs/; setAGENT_RUNTIME_PROVISIONER=ecs_per_org; validate one org end-to-end (spawn β workspace β R2 round-trip β reap).
15. Questions β answered from the current Railway deploymentβ
-
Internal agent transport. Resolved β Cloud Map. Today the shared agent is reached purely service-to-service via
AGENT_SERVICE_URL(a Railway internal/public URL); nothing internet-facing hits it directly. No need for an internal ALB β register the ECS agent service in Cloud Map (agent.humanwork.internal:8000) and setAGENT_SERVICE_URLto it. Internal ALB only if you later want health-based weighted routing. -
KB/RAG hosting. Resolved β in-stack Haystack/Hayhooks, per ADR-024 (Accepted 2026-06-02). ADR-024 supersedes ADR-004 + ADR-009; RAGflow is being replaced by self-hosted Haystack 2.x + Hayhooks. The new RAG service is
humanwork-rag(Hayhooks) on its own ECS Fargate task in the same cluster, registered in Cloud Map athumanwork-rag.humanwork.internal:1416, using pgvector on the existing humanwork Postgres (ADR-025) and OpenRouter for embed/chat/rerank (ADR-026). Deploy via the manualDeploy Ragworkflow (.github/workflows/rag-deploy.yml, #1541). Downstream callers sendX-API-Key(#1588). Seedocs/runbooks/haystack-deploy.mdand PRs #1527 (RAG ECS service + RAGFlow cleanup ininfra/terraform/), #1541, #1586 (curl-based container health check), #1588. -
ecs-per-org.provisioner.tsconfig surface. Resolved β it's a stub, deferred to M2. It reads no config and throwsNotImplementedError. Prod isshared_railway. β Drop per-org runners from the cutover Terraform. Build the ECS provisioner + itsAGENT_RUNTIME_ECS_*config (cluster/subnets/SG/roles/EFS β see Β§4) as M2. The Railway reference (railway-per-org.provisioner.ts) was removed in #1584 / ADR-028 β consult the pre-#1584 git history (e.g. the commit immediately before #1584 merged) for its config surface and lifecycle shape rather than expecting the file in-tree. -
DB migration method. Resolved β logical
pg_dump/pg_restore. Today PG is Railway-managed (PG16 + pgvector); there are no existing dump/restore scripts and no pooler/pgbouncer in front of it. Migration:pg_dumpRailway βpg_restoreinto RDS (ensureCREATE EXTENSION vectorfirst). App migrations are idempotent on boot (api/scripts/run-migrations.sh, waits β€60s for DB). Cutover downtime = a short read-only window during the final dump/restore; size it once you know the DB volume (likely minutes at MVP scale). DMS only if the DB is too large for a stop-the-world dump. -
Per-org runner cost ceiling. N/A for cutover (no runners in prod). When M2 lands: a concurrency cap + budget alarm is required before opening it up;
MAX_RUNTIME_HEALTH_PER_TICK=10already bounds health-check fan-out, but nothing caps concurrent runner count yet β add one. -
Region + DR. Resolved β single region, Multi-AZ only, no cross-region DR. Railway prod is US-West today; to minimize latency change pick
us-west-2for ECS (theecs.deferredexamples usedus-east-1/staging.humanwork.aiplaceholders, not a committed region). Multi-region (APAC) is tracked as BLK-037 and explicitly deferred beyond MVP β so Multi-AZ within one region is the target; no active DR. -
Frontend: Vercel vs ECS. Decision needed. Frontend is on Vercel today, not Railway. Recommendation: keep it on Vercel for v1 β it's working, decoupled from the API cutover, and avoids the
NEXT_PUBLIC_*build-per-env packaging (Β§6). Only containerize onto ECS if you want a single platform/VPC-internal frontend. If you keep Vercel, just repoint itsNEXT_PUBLIC_API_URL/NEXT_PUBLIC_WS_URLatapi.h.workat cutover and skip the frontend service + its ALB default rule. -
Agent cold starts. Note from ADR-003: the Python agent has ~2 min cold starts (mitigated by always-on). On ECS keep
agentmin tasks β₯2 and disable scale-to-zero so a cold Fargate task never gates a live chat turn.