Status: planning only. Nothing in this document is provisioned. It describes how to move the local Docker Compose stack
(infra/README.md) to AWS, written against the current architecture (architecture.md): a stateless
api tier, a separate worker tier, Postgres as database and job queue, optional Redis, a migrate release job, and the existing serverless
evaluation stack in aws/. All variables referenced here are documented in configuration.md.
| Component | Local today | Gap for the cloud |
|---|---|---|
| Database | pgvector/pgvector:pg17 container, volume qs_pg_data, no managed backups |
Managed Postgres with PITR and Multi-AZ failover |
| API | ROLE=api, gunicorn + uvicorn, bind-mounted source |
Immutable image, health-checked, horizontally scaled behind a load balancer |
| Workers | ROLE=worker, one replica, bind-mounted source |
Long-running tasks (evaluations up to 4 h), SIGTERM drain, autoscaling on backlog |
| Migrations | migrate compose service (python -m app.scripts.migrate) |
A release step that runs once before the new api / workers start |
| Redis | redis:7-alpine, no persistence, no host port |
Managed Redis, private subnet (or none: limits fail open) |
| Frontend | next dev with bind mounts |
A real next build image |
| Secrets | plaintext infra/.env |
Secrets Manager / SSM, no keys in images or the repo |
| Network | loopback host ports, no TLS | TLS, private data tier, trusted-proxy settings |
| CI/CD | CI exists (.github/workflows/backend-ci.yml, frontend-ci.yml: tests, lint, build) |
No image build, no deploy, no migration step |
flowchart LR
U["Users"] --> CF["ALB or CloudFront<br/>TLS (ACM)"]
CF --> FE["frontend service<br/>Next.js standalone"]
FE -->|"/api/v1/* rewrite"| INT["internal ALB"]
INT --> API["api service<br/>ROLE=api, N tasks"]
WK["worker service<br/>ROLE=worker, M tasks<br/>scale on BacklogPerWorker"]
API --> DB[("Aurora PostgreSQL<br/>pgvector + ltree")]
WK --> DB
API -.-> RD[("ElastiCache Redis")]
WK -.-> RD
WK --> SFN["Step Functions + Lambdas + S3"]
WK --> BR["Bedrock"]
WK -->|"BacklogPerWorker"| CW["CloudWatch"]
CW -->|"target tracking"| WK
REL["migrate task<br/>one-off, before each rollout"] --> DB
Why the split matters for hosting: a worker has no inbound HTTP and runs jobs for minutes to hours. Platforms that scale on requests (App Runner) are a poor fit
for it. Recommendation: ECS on Fargate for api, worker and frontend (three services, one cluster), ALB in front, ECR for images.
EKS is not justified for three services. App Runner remains acceptable for the frontend only if you prefer it, but keeping one platform is simpler.
Target: Aurora PostgreSQL 17.x (Provisioned or Serverless v2), one writer and at least one reader/standby.
- Extension parity first. The schema needs
vector(pgvector, 1024-dim Titan embeddings) andltree(kpi_nodes.path), created by migration0001. In a sandbox cluster runSELECT name, default_version FROM pg_available_extensions WHERE name IN ('vector','ltree');and thenpython -m app.scripts.migrateend to end. - Run the test suite against it by pointing
TEST_DATABASE_URLat a throwaway database on the cluster (the suite truncates tables: never the real database). - Connection budget. Each api/worker process opens up to
DB_POOL_SIZE + DB_MAX_OVERFLOW(default 5 + 10) connections. Sizemax_connectionsforprocesses x 15(gunicornWEB_CONCURRENCYworkers per api task, plus workers), or put RDS Proxy in front. Workers hold advisory locks and long-lived transactions only briefly. - Migration of existing data:
pg_dump -Fc/pg_restore(see infra/README.md) for small volumes; AWS DMS only if downtime must be minimal. LangGraph checkpoint tables are in the same database and travel with the dump. - Backups: automated backups with PITR, 14-35 day retention. The trash purge and the lease protocol are idempotent, so restoring a snapshot is safe: workers simply adopt or re-run active rows.
Redis only holds disposable state: the cluster-wide Bedrock / Jev / web-search slot semaphores, the shared AIMD limiter and rate-limit counters. Set REDIS_URL to the cluster endpoint
(rediss:// for in-transit encryption). If Redis is unreachable the code fails open to per-process limits (rate limits to in-memory), and /ready reports redis: down without failing, so a Redis outage
degrades fairness, not availability. No persistence or backups are needed. Set BEDROCK_GLOBAL_CONCURRENCY to your account's Bedrock concurrency budget so the sum over all workers stays inside it.
- Backend image: the existing
backend/Dockerfileis already production-shaped (no--reload, gunicorn with--graceful-timeout 30). One image serves three roles via command andROLE:api(default CMD),worker(python -m app.worker),migrate(python -m app.scripts.migrate). Do not bind-mount source in the cloud. - Frontend image: needs a multi-stage production build (
next build,output: "standalone",next start); today's Dockerfile is dev-mode.INTERNAL_API_BASE_URLis read at runtime by the server (rewrites, middleware, Server Components); point it at the internal API load balancer. The browser never sees it. - Release order: build and push images (tag = git SHA) -> run the
migratetask to completion (non-zero exit blocks the rollout) -> updateapi, thenworker, thenfrontendservices. Migrations are additive-only so the previous image keeps working while a rollout is in flight. - Worker shutdown: ECS
stopTimeoutmust exceedWORKER_DRAIN_GRACE_SECONDS(20 s) plus lease release; set it to 60-120 s (compose uses 60 s). On SIGTERM a worker stops claiming, lets chat turns finish, cancels the rest (recordedinterrupted) and releases evaluation leases so a peer resumes them at once. Evaluations that were mid-flight are resumed by Step Functions execution ARN, not restarted. - Health checks: api target group on
GET /ready; worker container health check onGET :8001/health;/readyof the worker is for readiness-style probes. - Trusted proxy: set
FORWARDED_ALLOW_IPSto the load balancer's subnets so per-IP rate limits and the audit log see real client addresses.
Signal: backlog_per_worker = (queued + active evaluations + waiting chat turns) / live workers (app/metrics.py).
- Set
AUTOSCALE_METRIC_NAMESPACE(for exampleScoreSmith) andAUTOSCALE_METRIC_INTERVAL_SECONDS; workers publishBacklogPerWorkerto CloudWatch (one publisher per interval when Redis is available) and log it as JSON. The task role needscloudwatch:PutMetricData. - Application Auto Scaling target tracking on that metric for the worker service (for example target 2 backlog per worker, min 1, max bounded by the Bedrock quota and
AI_EVAL_MAX_CONCURRENT). Scale in slowly (long cooldown): leases make it safe, but a drained task wastes in-flight work. - Workers turn on ECS task scale-in protection while they hold evaluations or chat turns, so scale-in never picks a busy task. The task role needs
ecs:UpdateTaskProtection. - Remember the ceilings:
AI_EVAL_MAX_CONCURRENT(1-5) is cluster-wide, each user has one running evaluation job and one chat turn, and Lambda/Bedrock quotas apply. More workers raise chat-turn capacity (CHAT_TURN_MAX_CONCURRENTeach) and recovery speed, not evaluation throughput beyond the cap. - The api tier scales on CPU / request count as usual; it is stateless.
| Secret | Where | Notes |
|---|---|---|
JWT_SECRET, BOOTSTRAP_TOKEN (first run only) |
Secrets Manager, injected as ECS secrets: |
Never in images or task-definition plaintext |
| Database URL / password | Secrets Manager (Aurora-managed rotation) | DATABASE_URL assembled at deploy time; password 12+ random characters (production validation) |
| Bedrock credential | Prefer the task IAM role (bedrock:InvokeModel / Converse on the named model ARNs) |
Removes the long-term key and the AWS_BEARER_TOKEN_BEDROCK workaround; verify boto3 picks up the role (drop the compose-only bearer line) |
OPENROUTER_JEV_API |
Secrets Manager | Jev is an OpenRouter alpha API |
AGENTCORE_GATEWAY_AWS_* |
Secrets Manager | Separate principal, hand-signed SigV4 |
AWS_APP_ACCESS_KEY_ID / _SECRET |
Prefer the task role with the policy from aws/policies/backend-user-policy.json; otherwise Secrets Manager |
S3 and Step Functions only |
| OAuth client secrets | Secrets Manager | Optional |
Non-secret settings (ENV=production, ROLE, CORS_ORIGINS, FRONTEND_URL, model ids, limits) go in task-definition environment variables. Settings needs no code change: it reads the environment.
ENV=production makes api and worker refuse to boot with weak secrets (list in configuration.md), and requires EMAIL_BACKEND=ses (verify the sender domain in SES, EMAIL_FROM).
Route 53 + ACM certificates on the ALB (or CloudFront). Set FRONTEND_URL and CORS_ORIGINS to the exact public https origin (production validation rejects localhost and *), OAUTH_REDIRECT_BASE if OAuth is used,
and the S3 bucket CORS origin (aws/bootstrap --cors-origin). Cookies become Secure automatically; the frontend sends HSTS in production builds. Because the browser calls the frontend origin and Next proxies /api/v1/*, there is
no cross-site cookie problem and COOKIE_DOMAIN can stay empty. The API itself never needs to be public: keep it on an internal load balancer.
Existing: backend CI (Postgres + Redis services, migrations, pytest, ruff) and frontend CI (tsc, lint, unit tests, build). To add:
- On merge to
main: build both images, tag with the SHA, push to ECR, scan. - Run the
migratetask (aws ecs run-task) and wait for exit code 0. - Deploy
api,worker,frontendtask definitions; rely on ECS deployment circuit breaker for rollback. - First production deploy only: set
BOOTSTRAP_TOKEN, create the admin withPOST /api/v1/auth/register-user, then remove the token.
Terraform (or CDK) for: Aurora, ElastiCache, ECR, ECS cluster + three services, ALBs, Secrets Manager, task roles, Route 53/ACM, CloudWatch alarms (5xx, BacklogPerWorker, worker unhealthy), AWS Budgets.
The serverless evaluation stack is already scripted in aws/ (idempotent bootstrap); keep it as is or port it to the same IaC later.
Suggested order: (1) Aurora extension/version check; (2) IaC skeleton in a sandbox account; (3) production frontend Dockerfile; (4) image build/push in CI; (5) sandbox deploy of api + worker + migrate, smoke test (sign in,
create a chart via chat, run an evaluation, kill a worker mid-run and watch another adopt it); (6) TLS/domain, autoscaling on BacklogPerWorker; (7) cut over.