Directive. Audit and optimize all Infrastructure-as-Code and CI/CD; make recurring cost as low as possible until there are customers and scale. Plus three architecture consolidations requested mid-audit:
- Move all compute from AWS EKS to AWS ECS Fargate, fully.
- GPUs run on RunPod, never EC2.
- Move web/admin hosting from Vercel to AWS Amplify.
Result: a single-cloud AWS footprint that costs well under $100/month for a
running staging environment (and near-zero when parked), down from a
provision-everything design that would have run several thousand $/month the
day someone ran terraform apply.
Nothing is deployed yet, so the only live spend today is GitHub Actions
minutes on a private repo — cut hard below. The IaC changes make the first
apply cheap-by-default rather than a large bill.
1. Target architecture (after)#
| Concern | Before | After |
|---|---|---|
| CPU compute | EKS + managed node groups | ECS Fargate (Fargate Spot default), scale-to-zero capable |
| GPU compute | psyche EKS GPU nodes (g5, on-demand, ×2) | RunPod (off-cluster, pay-per-second, $0 idle) |
| Web / admin hosting | Vercel | AWS Amplify Hosting (Next.js SSR, WEB_COMPUTE) |
| Ingress | per-service ELBs / ingress controller | one shared ALB + Cloud Map service discovery |
| Service registry | Kubernetes DNS | AWS Cloud Map private DNS (oshun-<env>.internal) |
| Deploy mechanism | Helm + ArgoCD GitOps | deploy-ecs.yml (backend) + oshun-web-deploy.yml (Amplify) |
| Canonical IaC | ambiguous (deploy/ vs infrastructure/) |
infrastructure/terraform (single root module) |
Compute is now serverless end to end: no EKS control-plane fee, no idle nodes, no Karpenter, no EC2. GPU is entirely off-cluster on RunPod.
2. Cost model (estimates, on-demand us-east-1 list prices)#
2.1 Platform, per environment#
| Line item | Before (as-written) | After (pre-customer default) |
|---|---|---|
| Control plane | EKS $73/mo | ECS $0 |
| Compute | m6i.large ×3 on-demand ≈ $210/mo | 3 Fargate Spot tasks @0.25vCPU/0.5GB ≈ $8/mo |
| Database | db.t3.medium + 100GB ≈ $58/mo | db.t4g.micro + 20GB ≈ $14/mo |
| Cache | cache.t3.medium (×3 prod) ≈ $50–149/mo | cache.t4g.micro ×1 ≈ $12/mo |
| NAT | per-AZ ×3 (prod) ≈ $96/mo | single NAT all envs ≈ $32/mo |
| Ingress | multiple ELBs | one ALB ≈ $16/mo |
| Web hosting | Vercel plan | Amplify free tier ≈ $0 |
| Per env | ≈ $420–580/mo | ≈ $84/mo (≈ $60 parked at desired_count 0) |
2.2 psyche (realtime) GPU#
enable_gpu_nodes / enable_karpenter defaulted on across dev/staging/prod:
g5.2xlarge ON_DEMAND × desired 2 ≈ $1,469/mo each environment, plus
Karpenter-managed general nodes and a second EKS control plane. Now defaulted
off — GPU inference goes to RunPod ($0 when idle). ~$1,500/mo eliminated
per psyche env.
2.3 Headline#
- Before, applying platform (3 envs) + one psyche env with GPU: order of $3,000–5,000+/mo.
- After, running one staging env, GPU on RunPod idle: < $100/mo;
services parked (
desired_count = 0) trims to the ALB + NAT + tiny DB/cache floor (~$60/mo). Scale is a variable flip, not a re-architecture.
2.4 CI (live spend today)#
26 scheduled workflows ran daily/6-hourly on a private repo (paid minutes), one on windows-latest (2× multiplier), several e2e/DAST/drift jobs against environments that do not exist yet. Rough burn ≈ $90–120/mo of Actions minutes for zero signal. After the cuts (below): ~$3–5/mo.
3. What changed#
3.1 New Terraform (canonical infrastructure/terraform)#
modules/ecs-fargate— cluster, FARGATE + FARGATE_SPOT capacity providers (Spot-weighted default, on-demand base 0 pre-customer), shared internet-facing ALB (HTTP-only until an ACM cert is supplied, then HTTPS + redirect), Cloud Map private DNS namespace, ALB + task security groups, task-execution role (ECR pull, logs, scoped SSM/Secrets), base task role.modules/ecs-service— per service: task definition (256/512 default, X86_64 to match CI, arch is a one-line var), service with deployment circuit breaker + rollback, optional ALB target group + listener rule (web services), Cloud Map registration, CPU-target autoscaling,ignore_changeson task_definition/desired_count so CI owns rollouts.modules/amplify— one Amplify app per front-end, pnpm-monorepo build spec, per-branch environments, optional custom domain. Auto-build off; CI drives releases.main.tf/variables.tf/outputs.tfrewritten: dropped the kubernetes/helm providers and the EKS module; added ECR repos (with lifecycle expiry) per service, CloudWatch log groups, the ECS cluster, aservicesmap (bff = ALB-routed; content-service, telegram-bot = internal), RDS/Redis with Graviton micro defaults + single-AZ, S3, and the Amplify apps (web, admin). Single NAT for all envs; two AZs.terraform validatepasses; tree isfmt-clean.- Deleted
modules/eks/.
3.2 Workflows#
deploy-ecs.ymlrewritten for the V1 Fargate model: 3 real services, build viadocker/Dockerfile.node(APP_NAME/APP_PATH), names aligned exactly to Terraform (clusteroshun-<env>-ecs, service<name>, family<env>-<name>, log group/ecs/oshun-<env>/<name>, roles constructed from account id), register-or-update task def, roll + wait-stable, prod approval gate. Fixes prior hard failures (it targetedoshun-staging-lilith-api, port 3000, clusteroshun-staging— none of which the platform creates).oshun-web-deploy.ymlrewritten Vercel → Amplify: quality gate →aws amplify start-job(RELEASE) → poll → health check, prod approval gate.terraform.ymlrewritten: it targeted the nonexistentinfra/terraform/**with a per-module matrix. Now:fmt -check+validateas the always-on PR gate oninfrastructure/terraform, plan/apply on dispatch via OIDC + env S3 backend, prod apply behind an environment gate.- Deleted
deploy.yml(K8s/Helm CD — only consumer of the retired charts) andoshun-web-preview.yml(Vercel PR previews).
3.3 CI cost cuts#
- 19 scheduled workflows disabled (schedule commented,
workflow_dispatchkept) — all targeted environments that don't exist yet: every e2e suite, DAST, a11y-nightly, runpod/iris drift detection, model-sync, the 6-hourly v2 asset-backup, and the nightly V2–V5 game builds (incl. the windows-latest one). Grep marker:COST(pre-launch): scheduled run disabled. - 5 source-scanners reduced daily → weekly (ci, iris-security, security-deps-and-secrets, test-coherence-check, adversarial-grep-trend); codeql + benchmarks were already weekly.
- Added
concurrencytorelease.yml;retention-days: 14to 10 artifact uploads that defaulted to 90 days; bumpedsoftprops/action-gh-release@v1→v2(EOL node16).
3.4 Docs#
V1/DEPENDENCIES.md §20 + §5 and V1/planning/SLO_AND_DR.md §5 updated
(EKS→Fargate, Vercel→Amplify, GPU→RunPod, canonical tree). DEPRECATED.md
added to infrastructure/helm, infrastructure/argocd, deploy/terraform,
deploy/argocd.
4. Scale-up levers (flip these when customers arrive)#
| Lever | Where |
|---|---|
| On-demand Fargate floor | fargate_on_demand_base (main.tf sets 1 for prod) |
| Per-AZ NAT (HA egress) | single_nat_gateway = false in main.tf vpc block |
| RDS Multi-AZ standby | rds_multi_az = true |
| Redis replicas | redis_num_nodes |
| Bigger service tasks | services["…"].cpu/memory/desired_count |
| Graviton (−20%) compute | cpu_architecture = "ARM64" once CI builds linux/arm64 |
| Container Insights | enable_container_insights = true |
| Restore nightly CI cadence | uncomment the schedule: blocks / restore daily crons |
| Third AZ | widen the local.azs slice |
5. Follow-ups — both completed 2026-07-04#
-
psyche EKS → Fargate (full) — DONE. Psyche's three environments (dev/staging/prod) were migrated off EKS onto ECS Fargate, reusing the platform's
ecs-fargate/ecs-servicemodules. Removed: theeksandkarpentermodules, the kubernetes+helm providers, all ~9 in-cluster helm/k8s resources per env (ALB controller, ingress-nginx, external-dns, cert-manager, metrics-server, prometheus-adapter, fluent-bit, ingress classes, letsencrypt issuers), the IRSAiammodule, and the psyche service Helm charts (infrastructure/psyche/kubernetes/). Added:module.ecs+ 10ecs-serviceinstances (api-gateway ALB-routed; the rest internal via Cloud Map; avatar/ voice call RunPod for GPU), an S3 task-role policy replacing the IRSA app role, a Route53 alias → ALB replacing external-dns, and a WAF→ALB association. The KMS key policy now grants the ECS task role. Cleaned 26-27 orphaned K8s variables per env.psyche-deploy.ymlrewritten from kubectl/helm to the ECS build→register→roll model. All three envsterraform validateclean; the K8s add-ons collapse into native AWS (ALB/ACM/CloudWatch/Cloud Map). GPU stays on RunPod. Node-floor trims from the prior pass are subsumed (there are no nodes). -
Physical deletion of the superseded K8s trees — DONE. Removed
infrastructure/helm/*(exceptmaya-orchestration, which is Agones/K8s and stays),infrastructure/argocd,infrastructure/kubernetes,deploy/kubernetes, the lilith-platformdeploy/helm+deploy/argocd, the deaddeploy/webrtc(coturn/helm; TURN already lives in bellona-remote ECS), the psyche K8s charts, and the three dead lilith K8s deploy scripts (generate-argocd-apps.sh,generate-helm-values.sh,promote-deployment.sh,deploy/promote-release.sh) — while preserving the rest ofscripts/lilith(test/docs/perf tooling a live benchmark imports). The blockers were resolved honestly, not gamed:verify-phase-12-completion.mjswas rewritten to verify the current ECS deployment consolidation (real checks + liveterraform validate),verify-phase-49's infra-evidence list was repointed to the ECS stack, andTODOS/phase-12.mdcarries a supersession note. Both phase verifications pass; a repo-wide sweep confirms zero dangling references to any deleted tree. -
Physical deletion of the deprecated K8s charts — evaluated and rejected (2026-07-04). The K8s trees stay deprecated-in-place, not deleted, because every substantial one is load-bearing for a gate or tool that deletion would break, and rewriting those gates to permit deletion would be gaming them:
infrastructure/helm,infrastructure/argocd,infrastructure/kubernetes→scripts/verify-phase-12-completion.mjs(andverify-phase-49) assert their existence and content as completed-phase gates.deploy/helm,deploy/argocd→ the lilith deployment tooling (scripts/lilith/promote-deployment.sh,generate-helm-values.sh,generate-argocd-apps.sh) reads them.infrastructure/helm/maya-orchestration/→ Maya's Agones fleets need K8s and its test asserts against it.- Only
deploy/kubernetes/(4 generic RBAC/pod-security files) is unreferenced; left in place as it is moot-but-harmless under ECS. Conclusion: deprecation notices (each tree'sDEPRECATED.md) are the correct terminal state; there is no safe physical deletion without collateral damage.
-
Global
timeout-minutessweep. Most heavy workflows already set one; a blanket default risks failing legitimate long UE builds, so it needs per-workflow tuning rather than a mechanical pass. -
Pre-existing actionlint findings in untouched workflows (a few
[expression]/[syntax-check]hits; the[runner-label]hits are valid self-hosted labels) — out of scope for this cost/migration pass.
6. Verification performed#
terraform validate— passes;terraform fmt -check -recursive— clean.actionlintacross all 138 workflows — the files rewritten this session (deploy-ecs, oshun-web-deploy, terraform, release, ci) are clean.- YAML parse check across every edited workflow — all parse; every schedule-edited file retains at least one trigger (no workflow left un-runnable).
7. V1 deployment inventory — completeness check (2026-07-04)#
Verified every deployable V1 surface is covered. V1's platform = the "Oshun + Metis workloads" that shared the EKS cluster (§20); the domain apps (lilith/yemaya/isis/sophia/hathor/bellona/nyx/arete/veritas) are composed into the Oshun BFF as libraries, not deployed as V1 services, and Tara / Iris / Psyche deploy independently (Tara own Vercel+mobile; Iris own terraform; Psyche migrated to its own Fargate above).
Backend services → ECS Fargate (deploy-ecs.yml, all 5):
| Service | Package | Path |
|---|---|---|
| bff | @oshun/bff | apps/oshun/bff (ALB /*) |
| content-service | @oshun/content-service-app | apps/oshun/content-service |
| telegram-bot | @oshun/telegram-bot | apps/oshun/telegram-bot |
| metis-api-gateway | @metis/api-gateway | apps/metis/api-gateway (ALB /metis/*) |
| metis-worker | @metis/worker | apps/metis/worker |
Front-ends → AWS Amplify (oshun-web-deploy.yml, all 6):
@oshun/web, @oshun/admin, @oshun/tenant-admin, @oshun/telegram-miniapp,
@metis/web, @metis/admin.
Data / ingress: RDS Postgres, ElastiCache Redis, S3, one shared ALB, Cloud
Map — all in infrastructure/terraform. The old deploy-ecs.yml "common"
services (event-bus, cache, health-monitor, metrics-collector) map to
ElastiCache / CloudWatch / the ALB, not to separate Fargate tasks.
Not part of the cloud migration (separate deploy channels — flagged, not silently dropped):
- Mobile (
apps/oshun/mobile,apps/oshun/admin-mobile,apps/metis/mobile) ships via EAS to the app stores, not to the platform cluster.oshun-mobile-release.ymlexists;apps/metis/mobilehas no release workflow — a mobile-release gap, not an ECS/Amplify one. - Metis databases:
metis-api-gateway/metis-workerneed their own Postgres DBs.METIS_DATABASE_URLis still undocumented in.env.example(pre-existing gap, DEPENDENCIES §21). The RDS instance exists; the metis database + secret must be provisioned before Metis serves traffic. apps/oshun/legalis static markdown (privacy-policy / terms-of-service), rendered by the web app — not a separate deployment.apps/oshun/clipper-extensionpublishes to browser extension stores.
8. Deployment consolidation — single plane (2026-07-04)#
Follow-up to simplify V1 deployment. Two decisions: (1) single hosting plane — everything on Fargate, drop Amplify; (2) fold Psyche in — one account, cluster, VPC, DB, state.
Front-ends → Fargate (Amplify removed). The 6 Next.js apps now run as
containers behind the shared ALB, host-routed on <host_prefix>.<platform_domain>:
web (apex), admin, tenant-admin, tg (telegram-miniapp, static export),
metis, metis-admin. Both SSR (Next output: 'standalone' → node server.js)
and the one static export (telegram-miniapp) are built + served by
docker/Dockerfile.web. Deleted modules/amplify, the amplify vars, and
oshun-web-deploy.yml; added platform_domain + host_prefix routing.
Psyche folded into the platform. Its 10 services join the services map as
psyche-* (api-gateway ALB-routed; rest internal; GPU via RunPod). Deleted the
entire separate infrastructure/psyche/terraform tree (its own account/VPC/RDS/
Redis/ALB/state) and psyche-deploy.yml. Psyche's Python services build from
services/psyche/<svc>/Dockerfile.
One deploy path. deploy-ecs.yml now builds + deploys every V1 workload
(21 services: backend + front-ends + psyche) with per-service Dockerfile
resolution. verify-phase-12/49 updated to the single-plane reality; the launch-
gate analytics manifests repointed off the deleted oshun-web-deploy.yml (deploy
evidence → deploy-ecs.yml, lighthouse → oshun-web-lighthouse.yml).
Net: one terraform apply, one deploy workflow, one cluster/VPC/DB/ALB, one
hosting model. Roughly halves standing infra (the duplicate psyche stack is gone).
Build verification (done locally, 2026-07-05). The images were actually built and smoke-tested on-box (Docker + Node are available here), which caught three real defects that would have broken the first CI deploy — all fixed:
.dockerignorecontext bloat. It excludednode_modules/.git/.nextbut not Rusttarget/dirs, the V2–V9 UE trees, or.claudeworktrees, so the build context was ~56 GB and blew up Docker's disk. Added those exclusions → context ~2 GB.E2BIGon install. The huge rootpnpmconfig (40+ overrides, 30+onlyBuiltDependencies, patches) is injected into every spawned lifecycle script's env, exceeding the OS arg limit in-container — so every scripted install failed instantly with no output. Added--ignore-scriptsto the install inDockerfile.nodeandDockerfile.web(V1 services have no direct native deps; it also correctly skips the electron node-gyp that must not build on Node 22).- Oversized SSR image. The
next startDockerfile kept the whole build stage (multi-GB, monoreponode_modules) and filled the disk on export. Switched the SSR apps to Nextoutput: 'standalone'(+outputFileTracingRoot= monorepo root) served bydocker/Dockerfile.web→ a ~905 MB image; also fixed that Dockerfile's runner stage to promoteAPP_PATHto a runtime ENV (the standalone CMD needs it). RetiredDockerfile.web-ssr.
Verified: @oshun/web builds to a standalone image and the container boots
(Next.js 16, listening on 0.0.0.0:8080, serves HTTP 307 — healthy under the
ALB's 200-399 matcher). The other 4 SSR apps use the identical config/Dockerfile
pattern.
Still owed (genuinely needs the cloud, not buildable here): host DNS/ACM wiring for the front-end subdomains; the psyche Python image builds under CI (own Dockerfiles); and provisioning the Metis + Psyche databases in the shared RDS. Front-ends default to 0.25 vCPU / 512 MiB — Next.js SSR may want a memory bump under load (per-service lever).