AWS Deployment Guide¶
Last Updated: 2026-05-24 Status: Production-Ready
Table of Contents¶
- Pre-Deployment Checklist
- Deployment Steps
- Post-Deployment Validation
- Adding AWS CD to a Scaffolded Service
- Known Issues & Fixes
- Troubleshooting
- Cost Optimization
- Production Hardening
- Cleanup & Destroy
Pre-Deployment Checklist¶
Required Tools¶
- AWS CLI ≥ 2.15 (
aws sts get-caller-identityworks) - Terraform ≥ 1.5
- kubectl
- Helm ≥ 3.x
- Docker (running, with
docker buildxsupport) - GitHub CLI (
gh) authenticated -
jq,python3,opensslavailable
Required Credentials¶
| Credential | Where to get it | Where it goes |
|---|---|---|
GITHUB_TOKEN |
https://github.com/settings/tokens (scopes: repo, read:org, read:user) |
local/.env |
AUTH_GITHUB_CLIENT_ID |
https://github.com/settings/developers → New OAuth App | local/backstage/.env |
AUTH_GITHUB_CLIENT_SECRET |
Same OAuth App | local/backstage/.env |
ANTHROPIC_API_KEY |
https://console.anthropic.com/settings/keys | Created in Secrets Manager by setup.sh |
DD_API_KEY / DD_APP_KEY (optional) |
https://app.datadoghq.eu/organization-settings/api-keys | terraform/terraform.tfvars (datadog_api_key / datadog_app_key) |
Auto-generated by bootstrap (no manual action needed):
- AUTH_SESSION_SECRET — Terraform generates a 64-char random value
- BACKSTAGE_CATALOG_TOKEN — bootstrap.sh generates and injects into Secrets Manager
- K8S_SERVICE_ACCOUNT_TOKEN — bootstrap.sh reads from the cluster and injects into Secrets Manager
GitHub OAuth App Setup¶
Create the OAuth app before running bootstrap so you have the credentials ready:
- Go to https://github.com/settings/developers → New OAuth App
- Set callback URL to:
http://YOUR_BACKSTAGE_ALB_URL/api/auth/github/handler/frame
(Use a placeholder URL; update it after bootstrap outputs the real ALB hostname) - Copy
Client IDandClient Secretintolocal/backstage/.env
Verify Everything Before Deploying¶
Deployment Steps¶
Step 1: Personalize Configuration¶
Replaces moatazeldebsy and other placeholders across all template files, creates .env files, and generates terraform/terraform.tfvars.
Step 2: Bootstrap AWS Infrastructure¶
./scripts/bootstrap.sh # evaluation: terraform.tfvars only, one shared NAT gateway
./scripts/bootstrap.sh --profile medium # production: per-AZ NAT + RDS Multi-AZ, Karpenter
--profile small|medium|large applies terraform/profiles/<p>.tfvars on top of
terraform.tfvars and is remembered for later runs (--profile none clears it).
medium and large also install ArgoCD in HA mode (aws/argocd/argocd-ha-values.yaml:
Redis HA, two server/repo-server/applicationset replicas with PDBs) and run two
Backstage and two Grafana replicas spread across AZs; small and no-profile stay
single-replica. On every profile Grafana keeps its state (users, sessions,
service-account tokens such as Backstage's GRAFANA_TOKEN) in a dedicated Postgres
(terraform/grafana.tf, enable_grafana_db), so it survives pod restarts.
Without medium or large, losing the AZ that hosts the single NAT gateway cuts all
outbound traffic from the private subnets — see
ADR-0009.
What it deploys (in order):
Phase numbers below match the timer_start labels the script prints, so the
timing table at the end of a run lines up with this one.
| Phase | Duration | What |
|---|---|---|
| 1 — Terraform | 15–25 min | VPC, EKS (6× t3.medium by default; scales to 0 overnight when enable_cost_optimizer = true), RDS (PostgreSQL), ECR, IAM/OIDC, Secrets Manager. EKS and RDS build concurrently, so this is bounded by the slower one, not their sum. |
| 2 — kubectl config | <1 min | aws eks update-kubeconfig, connectivity check |
| 2.5 — Image builds | starts here | hello-service + Backstage images build in the background for the rest of the run. Skipped entirely when the source is unchanged and the tag is already in ECR. |
| 3 / 3.6 / 3.7 | 3–5 min | Namespaces, RBAC, External Secrets Operator, ClusterSecretStore, Secrets Manager population |
| 4 — Prometheus + Grafana | 4–8 min | kube-prometheus-stack, dashboards, PrometheusRules |
| 4a+4b — Pushgateway + OpenCost | 1–2 min | Installed concurrently |
| 3.8–3.9 / 4.4x / 4.4 | 3–8 min | Gatekeeper + Kyverno, then Rollouts/Loki/Tempo/Datadog, then the exporter group — each a parallel batch |
| 4.5–4.7 — GitOps | 3–5 min | ArgoCD (with one retry on a slow first ALB), the idp-services ApplicationSet, Crossplane, and the Backstage/ArgoCD tokens. The ApplicationSet auto-discovers services/* and deploys hello-service, idp-mcp-server and qa-mcp-server to services-dev; contract-mcp-server is excluded and deployed only by bootstrap-ai.sh --aws. |
| 5 — Image builds (join) | 0–20 min | Waits for phase 2.5. Usually already finished, since it has had the whole platform install to run. Then writes the seed image tag into helm-values-aws.yaml. |
| 5.6–5.8 — Backstage | 2–8 min | ExternalSecret, generated ConfigMaps, deployment, ALB hostname wait, catalog exporter, AlertManager routing |
| 6 — AI/ML platform | 8–20 min | bootstrap-ai.sh --aws, only with --with-ai/--adp. Argo Workflows and Velero install in parallel with it. |
Total: ~40–70 minutes cold. A repeat run against an existing cluster is far
shorter — the image builds, helm releases and Terraform providers all skip when
nothing has changed. Set IDP_FORCE=1 to override every skip check, and
HELM_WAIT_SHORT/HELM_WAIT_MED/HELM_WAIT_LONG/HELM_WAIT_XL to raise the
helm timeouts on a slow account — these four now cover every helm --wait in the
bootstrap. Defaults are 5m/10m/15m/25m; see
Scripts Reference.
On a slow link, measure throughput first, then raise them — the defaults turn a
slow network into what looks like a hard failure.
Step 3: Update GitHub OAuth Callback URL¶
After bootstrap completes, the Backstage ALB URL is printed. Update your GitHub OAuth app:
- Go to https://github.com/settings/developers → your OAuth app → Edit
- Update callback URL:
http://<BACKSTAGE_ALB_HOSTNAME>/api/auth/github/handler/frame
Step 4: AI/ML stack — opt-in¶
AI/ML is a layer, not part of the core, matching how local works. Add it with a flag on the same script:
./scripts/bootstrap.sh --with-ai # KAgent, MLflow, Langfuse, MCP servers
./scripts/bootstrap.sh --adp # implies --with-ai, adds the agentic platform
Requires ANTHROPIC_API_KEY in Secrets Manager.
Skipping it is a real cost saving, not just skipped workloads: enable_ai and
enable_langfuse gate the Langfuse RDS instance and the MLflow/Langfuse S3 buckets
and IRSA roles, so a core-only install never provisions them. The old --skip-ai
flag skipped the Helm installs but still built a second RDS instance.
Removing the infrastructure is deliberately explicit: omitting --with-ai on a
cluster that already has it keeps it, because a forgotten flag must not drop
the Langfuse database. Pass --remove-ai-infra to actually destroy it.
You can also call bootstrap-ai.sh directly — note the --aws flag, without which
it targets your local Kind context rather than EKS:
Step 5: Provision team namespaces¶
After the platform is up, onboard teams via the Backstage Provision Team Namespace template. Before running it, create the per-team ESO IAM role:
cd terraform
# Add each team (repeat for all teams)
terraform apply -var='team_eso_roles=[
{name="payments", cost_center="CC-1234"},
{name="platform", cost_center="CC-0001"}
]'
# Get role ARNs to paste into the scaffold form
terraform output team_eso_role_arns
Then in Backstage → Create → Provision Team Namespace → fill in team name, tier, and the
IAM role ARN. The scaffold PR creates:
- kubernetes/teams/<name>/ with namespace, quota, RBAC, AppProject, ApplicationSet, SecretStore, Grafana folder
- backstage/catalog/groups/<name>.yaml — team auto-registers in catalog
Merge the PR. ArgoCD's idp-teams ApplicationSet picks up kubernetes/teams/<name>/
and applies it as the team-<name> Application.
Service path convention: Team service values go under
teams/<teamName>/services/<serviceName>/, notservices/<teamName>/. See docs/team-management.md for details.
Step 6: GitHub Actions secrets¶
Service repos scaffolded with Deployment Target: AWS get their AWS_ROLE_ARN
secret automatically — a push-only role for that repository's own ECR repository
(see Adding AWS CD to a Scaffolded Service).
Do not give a service repository the platform's github_actions_role_arn: it
only trusts the platform repo, and carries near-admin permissions.
Add these to the platform repo to enable the auto-merge workflow (recommended over a PAT):
| Secret | Value |
|---|---|
APP_ID |
Numeric GitHub App ID (see docs/github-app-setup.md) |
APP_PRIVATE_KEY |
PEM contents of the App's private key |
Add this to the platform repo to enable Datadog deployment markers in build-and-deploy.yml
(optional — the pipeline runs fine without it, just skips the marker step):
| Secret | Value |
|---|---|
DD_API_KEY |
Datadog API key — https://app.datadoghq.eu/organization-settings/api-keys |
Rebuilding Backstage¶
There is no separate Backstage deploy step — bootstrap.sh already did it. Phase 5.6
builds the image, pushes it to ECR under a content-hash tag, applies
aws/backstage/deployment.yaml with that tag substituted, waits for the
ExternalSecret to sync and for the ALB hostname, then patches the config with the
real URLs.
To rebuild and roll out after changing Backstage, re-run the bootstrap — the image fingerprint changes, so it rebuilds and redeploys, and skips everything else that is unchanged:
Post-Deployment Validation¶
Runs ~40 automated checks across 10 categories. All should pass.
Component checks¶
kubectl get pods -n services # hello-service running
kubectl get pods -n monitoring # prometheus, grafana, alertmanager, pushgateway
kubectl get pods -n external-secrets # external-secrets operator
kubectl get clustersecretstore # aws-secretsmanager → Ready
kubectl get pods -n crossplane-system # crossplane + 5 provider-aws-* pods
kubectl get providers.pkg.crossplane.io # all five INSTALLED=True HEALTHY=True
kubectl get ingress -n services # ALB address
Visit the ALB hostname:
bootstrap.sh installs the full kube-prometheus-stack (Prometheus + Grafana +
AlertManager + Pushgateway) at parity with the local Kind setup. Grafana is
pre-configured with the CloudWatch datasource using IRSA — no static AWS credentials
needed. OPA/Gatekeeper enforces all five golden-path policies (require-health-probes,
require-resource-limits, require-labels, deny-latest-tag, require-cost-tags).
Manual Smoke Tests¶
# Get all ALB URLs
kubectl get ingress -A --no-headers | awk '{print $2, $4}'
# Test Backstage root (should return HTTP 200 with HTML)
curl -I http://<BACKSTAGE_ALB>
# Verify catalog has entities (should return items array)
curl -s http://<BACKSTAGE_ALB>/api/catalog/entities?limit=5 | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Entities: {len(d)}')"
# Verify DORA metrics in Prometheus
kubectl port-forward -n monitoring svc/prometheus-kube-prometheus-prometheus 9090:9090 &
curl -s "http://localhost:9090/api/v1/query?query=dora_deploy_frequency_per_day" | python3 -c "
import sys,json; d=json.load(sys.stdin); print('DORA series:', len(d.get('data',{}).get('result',[])))"
# Verify QA metrics in Grafana
curl -s "http://localhost:9090/api/v1/query?query=e2e_pass_rate" | python3 -c "
import sys,json; d=json.load(sys.stdin); print('QA series:', len(d.get('data',{}).get('result',[])))"
Expected Service URLs¶
Backstage has its own NLB. Every other UI is a listener port on one shared
ALB (IngressGroup idp-platform), so they share a hostname and differ by port.
bootstrap.sh prints them all at the end. To read one later:
| Service | URL | Lookup |
|---|---|---|
| Backstage | http://<backstage-nlb> |
kubectl get svc backstage -n backstage -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' |
| ArgoCD | http://<shared-alb>:8080 |
alb_ingress_url argocd-server argocd |
| Grafana | http://<shared-alb>:3000 (:443 HTTPS with domain_name) |
alb_ingress_url prometheus-grafana monitoring |
| hello-service | http://<shared-alb>:8081 |
alb_ingress_url hello-service-dev-service-template services-dev |
| agent-event-router | http://<shared-alb>:8082/webhook/github (only that path is published) |
AI stack |
| KAgent UI / IDP Assistant | http://<internal-alb>:8083 / :8084 — VPC only |
AI stack |
| MLflow / Langfuse / Argo Workflows | http://<internal-alb>:5000 / :3001 / :2746 — VPC only |
AI stack |
KAgent, the IDP Assistant A2A endpoint, MLflow, Langfuse and Argo Workflows have
no authentication of their own (or, for Argo Workflows, take a bearer token over
plain HTTP), so they sit on a second, internal ALB (group.name:
idp-internal) rather than the internet-facing idp-platform one. Reach them
from inside the VPC (VPN, bastion, SSM port forwarding) or with
kubectl port-forward, e.g.
kubectl port-forward -n kagent svc/kagent-ui 8083:8080.
alb_ingress_url returns their internal hostname the same way.
Adding AWS CD to a Scaffolded Service¶
Nothing to add. Scaffold the service with Deployment Target: AWS and the template sets it up:
idp:provision-ecrcreates the ECR repository<cluster>/<service>and an IAM role,<cluster>-svc-push-<service>, that only the new repository'smainbranch can assume and that can only push to that ECR repository (Terraform's<cluster>-service-image-push-boundarycaps what such roles may ever do).- The role's ARN is written to the new repository as the
AWS_ROLE_ARNsecret. - The GitOps pull request adds
services/<service>/helm-values-aws.yamlto the platform repo, pointing at that ECR repository.
From then on the service's ci.yml pushes every main build to GHCR and to ECR
(SHA and latest tags); with a GH_PAT secret it also bumps the tag in the
platform repo. ArgoCD deploys it — no Helm or kubectl from the service's CI.
The platform's own github-actions role is deliberately not used here: it trusts
only the platform repository and carries near-admin permissions.
Known Issues & Fixes¶
All issues below are already fixed in the codebase. This section is history, not live advice — it is here so a symptom on an older checkout is searchable.
For the failures found during the 2026-08 AWS runs, grouped by the class of mistake that allowed them and naming the file that now prevents each one, see AWS install: known failure modes. For debugging a run right now, start at TROUBLESHOOTING.md.
Issue 1: Backstage shows 404 at root /¶
Symptom: Navigating to the Backstage ALB URL shows "Looks like someone dropped the mic!" 404.
Root cause: Backstage config arrays replace rather than merge across config files. The production ConfigMap's extensions list overwrites the base config's page:catalog: path: / entry.
Fix (already applied): backstage/app-config.aws.yaml (rendered into the backstage-config ConfigMap by bootstrap.sh) now explicitly includes page:catalog: path: / alongside page:kubernetes: disabled: true.
Issue 2: Backstage catalog returns 401 / guest login broken¶
Symptom: All API calls return 401. Guest sign-in doesn't work.
Root cause: dangerouslyDisableDefaultAuthPolicy: true must be under backend.auth, not the top-level auth key.
Fix (already applied): Correct placement in backstage/app-config.aws.yaml:
Issue 3: Kubernetes tab shows 401 UNAUTHORIZED¶
Symptom: hello-service Kubernetes tab shows UNAUTHORIZED_ERROR, status code: 401 for all resources.
Root cause: K8S_SERVICE_ACCOUNT_TOKEN in Secrets Manager was REPLACE_ME — never populated with the real cluster token.
Fix (already applied): bootstrap.sh reads the token from backstage-sa-token secret and injects it into Secrets Manager automatically.
Issue 4: ClusterSecretStore fails — external-secrets-sa not found¶
Symptom: kubectl get clustersecretstore aws-secretsmanager shows InvalidProviderConfig. DORA exporter secret never syncs.
Root cause: ESO Helm chart creates a ServiceAccount named external-secrets, but the IAM trust policy and ClusterSecretStore both reference external-secrets-sa (which doesn't exist by default).
Fix (already applied): bootstrap.sh explicitly creates external-secrets-sa and annotates it with the IRSA role before applying the ClusterSecretStore.
Issue 5: DORA metrics missing from Grafana¶
Symptom: DORA Metrics dashboard shows "No data".
Root cause: dora-exporter.py was writing to CloudWatch only, but the Grafana dashboard queries Prometheus for dora_deploy_frequency_per_day.
Fix (already applied): Exporter now pushes to Pushgateway using the exact metric names the dashboard expects. CloudWatch publishing is retained as a secondary path.
Issue 6: hello-service ImagePullBackOff on EKS¶
Symptom: hello-service pods show ImagePullBackOff or no match for platform in manifest.
Root causes: - Image was in private GHCR (403 Forbidden) - Image built on Apple Silicon (arm64) but EKS nodes are amd64
Fix (already applied): helm-values-aws.yaml points to ECR. Build for the correct platform:
cd services/hello-service
docker buildx build --platform linux/amd64 \
-t "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/idp-mvp/hello-service:latest" \
--push .
Issue 7: TechDocs 404 for hello-service¶
Symptom: hello-service Docs tab shows "no index.md in the root of the docs directory".
Root cause: dir:../../../services/hello-service relative traversal from a GitHub-URL-loaded catalog entity is unreliable.
Fix (already applied): backstage/catalog/services/hello-service/catalog-info.yaml uses an explicit GitHub URL:
backstage.io/techdocs-ref: url:https://github.com/moatazeldebsy/backstage-platform-template/tree/main/services/hello-service
Issue 8: ArgoCD idp-services ApplicationSet missing¶
Symptom: Services don't appear in ArgoCD. platform-services app shows ComparisonError: app path does not exist.
Root cause: The idp-services ApplicationSet (which auto-discovers services/*/) was never applied.
Fix (already applied): bootstrap.sh Phase 4.6 applies aws/argocd/app-of-apps.yaml.
Issue 9: OpenCost and Pushgateway have no ALB ingress¶
Symptom: No public URL for OpenCost or Pushgateway after deployment.
This is intentional, not a fault. Both used to publish their own internet-facing ALB (~$16/mo each, no authentication in front of either). They are operator tools — OpenCost's data reaches users through the Backstage FinOps tab and the Grafana FinOps dashboard, and Pushgateway is written to by CI and scraped by Prometheus in-cluster. Reach the raw UIs with:
kubectl port-forward -n opencost svc/opencost 9090:9090
kubectl port-forward -n monitoring svc/prometheus-pushgateway 9091:9091
The same applies to Prometheus, Alertmanager and the Argo Rollouts dashboard;
bootstrap.sh prints every port-forward command in its closing banner.
Issue 10: Tech Radar shows empty / "not allowed to read"¶
Symptom: Tech Radar page is blank or shows a reading permission error.
Root cause: backend.reading.allow was missing from the ConfigMap, so Backstage blocked reads from raw.githubusercontent.com.
Fix (already applied): backstage/app-config.aws.yaml includes:
Issue 11: Grafana login fails with multiple replicas¶
Symptom: Logging into Grafana succeeds but immediately redirects to login again.
Root cause: 2 Grafana replicas with ALB (no sticky sessions) — session created on pod A, next request hits pod B.
Fix (already applied): aws/observability/prometheus-stack-values.yaml sets replicas: 1.
Troubleshooting¶
For a comprehensive fresh-setup troubleshooting guide covering all AWS phases (Terraform, EKS, Backstage, ArgoCD, Crossplane, Observability), see docs/TROUBLESHOOTING.md.
Backstage pod not starting¶
kubectl logs -n backstage deployment/backstage --tail=50 | grep -E "error|Error|FATAL"
kubectl describe pod -n backstage -l app=backstage | grep -A10 "Events:"
Common causes:
- Database not ready: check kubectl get pods -n backstage | grep postgres
- Secret not synced: kubectl get externalsecret backstage-secrets -n backstage
- Config YAML parse error: check logs for YAMLException
Stale Terraform lock¶
EKS nodes not ready¶
kubectl get nodes
kubectl describe nodes | grep -A5 "Conditions:"
kubectl wait --for=condition=Ready node --all --timeout=600s
Re-seed QA metrics after Pushgateway restart¶
PUSHGATEWAY_URL=http://$(kubectl get ingress prometheus-pushgateway -n monitoring \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}') \
./scripts/seed-qa-metrics.sh
Trigger DORA metrics collection manually¶
kubectl create job dora-manual-$(date +%s) --from=cronjob/dora-exporter -n monitoring
kubectl logs -n monitoring -l job-name=dora-manual-* --tail=20
Force Backstage catalog refresh¶
Cost Optimization¶
Free-tier reality¶
EKS is not free-tier compatible. Fixed monthly costs that cannot be eliminated while the cluster is running:
| Resource | Monthly floor | Free tier? |
|---|---|---|
| EKS control plane | ~$73 | No |
| NAT Gateway | ~$33 + data | No |
| Secrets Manager (5 secrets) | ~$2 | No |
Lowest cost approach: use ./scripts/bootstrap-local.sh --full for daily development. Deploy to AWS only for demos, then clean up immediately.
Default cost-optimized settings¶
terraform/terraform.tfvars.example, which setup.sh copies on first run, is
tuned for the lowest spend that still actually runs the platform:
node_group_min_size = 0 # scale-to-zero allowed (cost optimizer)
node_group_desired_size = 6
node_group_max_size = 8
enable_cost_optimizer = true # overnight Lambda scaler (see below)
budget_monthly_limit_usd = "100" # SNS alert at $80 actual / $100 forecasted
This used to recommend desired_size = 1. A single t3.medium is 2 vCPU / 4 GiB,
and rendering every chart and manifest this repo installs gives roughly 2.4 vCPU
and 5.7 GiB of declared requests before per-node DaemonSet overhead (~0.3 vCPU
and ~900 MiB each, for aws-node, kube-proxy, ebs-csi-node, promtail and the
Datadog agent) — so one node could never have run it, and following that advice
produced a cluster stuck in Pending.
Six is deliberately conservative rather than measured on a live cluster. Once the platform is up, check the real numbers before going lower — OpenCost and Prometheus are both deployed, and the Grafana FinOps dashboard reports per- namespace cost and efficiency. Note that within the t3 family the $/vCPU and $/GiB are identical, so only the node count changes the bill; moving to t3.large buys memory at exactly proportional cost.
Overnight node scaler¶
When enable_cost_optimizer = true, two EventBridge-triggered Lambdas run on schedule:
| Lambda | Schedule (UTC) | Action |
|---|---|---|
| EKS node scaler | 8 PM daily | Scale node group → 0 |
| RDS scheduler | 8 PM daily | Stop RDS instance |
| Both | 7 AM daily | Scale back to desired / start RDS |
This saves ~60% of EC2 and RDS costs (assuming 9 h active / 24 h). To adjust the schedule:
# terraform/terraform.tfvars
cost_optimizer_scale_down_cron = "cron(0 22 * * ? *)" # 10 PM UTC
cost_optimizer_scale_up_cron = "cron(0 6 * * ? *)" # 6 AM UTC
To disable entirely (always-on cluster):
Prometheus storage (already optimized)¶
aws/observability/prometheus-stack-values.yaml ships with cost-optimized defaults:
prometheusSpec:
retention: 3d # time-based cap — reduce scrape history
retentionSize: 15GB # size-based cap — this is the load-bearing one
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: gp3 # 20% cheaper than gp2
resources:
requests:
storage: 20Gi # headroom above retentionSize
Do not drop storage below retentionSize with room to spare, and do not
remove retentionSize. retention alone is a time cap, so a busy cluster can
fill the volume long before the window expires — a previous 5Gi volume filled in
about four days and silently wedged ingestion. retentionSize is what actually
bounds the volume; storage: 20Gi exists to give it headroom.
For longer history, raise both together (e.g. retention: 15d,
retentionSize: 45GB, storage: 60Gi) and expect the EBS cost to rise with it.
Skip the AI/ML stack¶
KAgent + MLflow + MCP servers add ~6 platform ALBs plus one per MCP server per environment (~$36–72/month for the platform pieces alone, more once the MCP servers are promoted to staging and prod) and ~6 Gi EBS. Skip them unless needed:
# Core platform only (no AI/ML)
./scripts/bootstrap.sh
# Add AI/ML later when needed
./scripts/bootstrap-ai.sh --aws
# Remove it
./scripts/bootstrap-ai.sh --aws --destroy
Estimated monthly cost by configuration¶
Rough us-east-1 on-demand estimates at ~730 h/month. Treat them as order-of- magnitude only — the authoritative numbers are the AWS budget alert below and the OpenCost/Grafana FinOps dashboard, both of which this platform deploys.
The fixed floor is ~$135/mo before a single workload runs: EKS control plane
(~$73), one NAT gateway (~$33), RDS db.t3.micro (~$17), plus EBS/S3/ECR/
Secrets (~$15). Nodes and ALBs are what actually move.
| Config | EC2 nodes | Cost optimizer | AI/ML | ALBs | Est. monthly |
|---|---|---|---|---|---|
| Default | 6× t3.medium | On (8 PM–7 AM UTC) | Off | 3 | ~$280–310 |
| Always-on, no AI | 6× t3.medium | Off | Off | 3 | ~$370–400 |
| Always-on + AI | 6× t3.medium | Off | On | 6 | ~$430–490 |
| Production | per profiles/medium.tfvars |
Off | Optional | 6 | ~$700–900 |
The ALB column understates a full AI deployment. Measured on the live cluster 2026-08-17, 27 ALB Ingresses existed with
--adpand all three environments populated (dev/staging/prod each publish their own), not 6 — one per Ingress, sincegroup.nameis unused. Removing the six dead internal ones below brings that to 21. Treat the table as the core-platform baseline.Scaffolded services no longer add to this by default: since #397 their
helm-values-aws.yamlshipsingress.enabled: false, so a new service publishes no ALB unless you opt in. The 27 measured above predate that change and reflect environments whose services all had ingress enabled.
Where the money goes, and what has already been done about it:
- Nodes dominate — 6× t3.medium is ~$182/mo on-demand.
enable_cost_optimizertakes ~45% off that by scaling to zero overnight. Within the t3 family the $/vCPU and $/GiB are identical, so only the node count matters, not the size. - ALBs used to be the silent second-biggest line: every ALB Ingress
provisions its own load balancer at ~$16/mo, and there were eleven. Prometheus,
Alertmanager, Pushgateway, OpenCost and the Argo Rollouts dashboard no longer
publish one — they are operator tools, reachable with
kubectl port-forward(the bootstrap banner prints the exact commands). That is ~$82/mo, and it also took five unauthenticated endpoints off the public internet. - Six internal ALBs were pure waste and have been removed (2026-08-18).
approval-serviceand theargocd/cost/github/incident/securityMCP servers each published an internal ALB with a<service>.internalhostname. No private hosted zone exists, so those names resolved nowhere, and every real caller uses in-cluster Service DNS anyway —app-config.aws.yamlproxies straight to*.services-dev.svc.cluster.local. They routed nothing and billed ~$96/mo. Theiringress.enabledis nowfalse, which is also more private than an internal ALB. - Consolidating the eight remaining internet-facing ALBs onto one would save ~$112/mo more. See Consolidating ALBs — it needs real DNS, so it is a project rather than a flag.
Measured: a full six-node cluster with AI/ML¶
Measured against a running idp-mvp cluster in us-east-1 on 2026-08-14, at
on-demand list prices, for continuous 24/7 running — that is, before the
overnight scale-down enable_cost_optimizer performs by default.
Local is free — this is only the EKS path. These are AWS infrastructure
charges only; see what the measurement excludes below
before treating the total as your bill.
This is a measurement, not the repo default. The table reflects a running
idp-mvpcluster with the node group scaled up to sixt3.large.terraform/variables.tfshipsnode_group_desired_size = 1(min 0, max 2), so a bootstrap using the defaults costs substantially less — and will not fit the full stack. Scale the node group deliberately rather than inferring it from this table.
| Component | Qty | ~$/month |
|---|---|---|
| EKS control plane | 1 | 73 |
Worker nodes (t3.large) |
6 | 364 |
| NAT gateway | 1 | 33 + data |
| Application Load Balancers — core | 4 | 66 |
RDS for Backstage (db.t3.micro) |
1 | 12 |
| S3 / ECR / Secrets Manager / CloudWatch | — | ~15 |
| Core platform subtotal | ~565 | |
| Application Load Balancers — AI/ML | 11 | 181 |
RDS for Langfuse (db.t4g.micro) |
1 | 11 |
| S3 for MLflow + Langfuse artifacts | 2 | ~2 |
AI/ML layer subtotal (--with-ai) |
~195 | |
| Total with AI/ML | ~760 |
The load balancers are the surprise. Every ALB is ~$16/month before traffic,
and the AI/ML layer creates eleven of the fifteen — one per MCP server, plus
KAgent, the IDP assistant, MLflow and Langfuse. That is more than the RDS
instances and S3 combined, and it is why the AI layer is opt-in
(./scripts/bootstrap.sh --with-ai) rather than default.
Ways to spend less, roughly in order of effect:
- Already on by default: spot nodes for every non-
prodinstall (node_capacity_type, ~60–70% off the node line), and one shared ALB for all platform UIs instead of one each (see Consolidating ALBs). medium/large stay on-demand. Keepcluster_versionin EKS standard support too: an extended-support version bills the control plane at 6×.
- Skip the AI/ML layer. Saves ~$195/month.
enable_aiandenable_langfusegate the infrastructure too, so nothing is provisioned for it. - Leave
enable_cost_optimizer = true(the default). Scales nodes to zero and stops RDS overnight — roughly halves the node and RDS lines if you only work office hours. Because it is on by default, a stock deployment on an office-hours schedule already lands well under the table above, which measures the optimizer-disabled case. - Drop the node count. Six
t3.largeis sized for the full stack including AI; the core platform alone fits in fewer. Note the constraint documented onnode_instance_types: nodes are sized by pod IP capacity, not CPU/RAM. - Tear down when idle.
./scripts/cleanup.sh, not a bareterraform destroy. Several things the platform runs are created by in-cluster controllers rather than Terraform: ALBs, the EBS disks behind PersistentVolumes, Crossplane resources. They surviveterraform destroyand keep billing. On one account the PVC disks alone kept ~$13/month running after the cluster was gone.cleanup.shdeletes them, and its final verification reports anything left over. The full list is in what Terraform cannot destroy.
What the measurement excludes¶
The total is the platform's own AWS footprint. It is not an all-in run rate:
| Not counted | Why it can matter |
|---|---|
| Data transfer and NAT data processing | Charged per GB on top of the NAT hourly rate. Image pulls and cross-AZ traffic dominate it, so it scales with your workload, not with the platform |
| LLM API spend | KAgent runs Claude and GPT-4o, and AI Search needs a VOYAGE_API_KEY. Those are Anthropic / OpenAI / Voyage bills, not AWS — and on an agent-heavy platform they can exceed the infrastructure. This is precisely what the Langfuse page exists to show you |
| Datadog | Third-party SaaS priced per host and per ingested GB, alongside the Prometheus/Grafana stack that is included |
| Your own services | Everything above is the platform. Whatever your teams scaffold onto it is additional |
| Savings Plans / Reserved Instances | List prices only. Committed-use discounts take a meaningful cut off the node line |
Verify against the AWS pricing calculator for your region before committing — this table is a measurement of one cluster, not a quote.
Consolidating ALBs¶
Done without a domain: one ALB, one listener port per service. Every
platform Ingress carries alb.ingress.kubernetes.io/group.name: idp-platform
and its own listen-ports (ArgoCD :8080, Grafana :3000, hello-service
:8081, agent-event-router :8082; the unauthenticated AI-stack UIs
:2746–:8084 use the same pattern on the internal idp-internal group — see
the URL table above). Rules live
per listener, so the host: "" + path: / entries no longer collide. That
replaced ~9 ALBs, each ~$0.0375/h with its three public IPv4 addresses, with
one. validate-deployment.sh flags any internet-facing ALB Ingress that is
missing the group annotation. To add one, pick an unused port, set both
annotations, and read the URL with alb_ingress_url.
Scaffolded services' own helm-values-aws.yaml are not part of the group: a
new service gets its own ALB until it is given a port here, or a hostname as
below.
The host-based setup below is the upgrade path once a domain exists. It gives port-free URLs and real TLS for every service. Without a domain, it would need the hostnames to differ, which they cannot:
One ALB is not achievable while keeping internal services private. An
IngressGroup maps to exactly one ALB with one scheme, so internal and
internet-facing Ingresses cannot share a group. The realistic floor is one
shared internet-facing ALB; anything that must stay private should publish no
Ingress at all and be reached in-cluster, which is what the six removed above
now do.
Steps, in order:
- Set
domain_nameinterraform/terraform.tfvars(a Route 53 zone must already exist for it).terraform/acm.tfis already gated on this and will provision a wildcard*.${domain_name}certificate with DNS validation — no new Terraform is needed for TLS. - Give every internet-facing service a hostname in its
helm-values-aws.yaml, e.g.host: hello-service.idp.example.com. - Add a shared group to those same annotations:
Every Ingress carrying the same
alb.ingress.kubernetes.io/group.name: idp-public alb.ingress.kubernetes.io/group.order: "10" # lower = evaluated first alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]' alb.ingress.kubernetes.io/certificate-arn: <wildcard cert ARN from acm.tf>group.namemerges onto one ALB. - Point DNS at it — see below.
DNS: wildcard now, external-dns at scale¶
For this platform, start with a single wildcard ALIAS: one
*.idp.example.com record to the shared ALB, matching the wildcard certificate
acm.tf already issues. No controller, no extra IAM, and every future
scaffolded service resolves the moment its Ingress joins the group. For a
template repo that others adopt, that is the right default because it adds no
prerequisites.
At organisation scale, the standard is external-dns over a delegated subdomain. The reason is lifecycle, not convenience: records are created and deleted with the Ingress, so a scaffolded service needs no ticket and no Terraform PR. If you adopt it:
- Delegate, never grant apex access. NS-delegate
dev.idp.example.comto a zone the platform owns. A controller with write access to the company apex zone is an unacceptable blast radius. - Scope the IRSA role to that hosted zone ARN only.
- Set
--txt-owner-id=<cluster-name>so several clusters can share a zone without overwriting each other's records. This is what makes it multi-cluster safe, and it is the setting most often missed. - Choose
--policydeliberately.syncdeletes records when the Ingress goes;upsert-onlyis safer on day one but leaks records forever, which becomes its own cleanup project. - Pair it with the wildcard cert or cert-manager. DNS automation without matching certificate automation just moves the ticket.
Further cost optimisations (proposed, not yet implemented)¶
Measured against the real August 2026 bill, for a cluster that ran six days (2026-08-12 to 08-18) at $45–51/day:
79.04 EKS control plane (fixed, $0.10/hr per cluster)
67.99 EC2 - Compute (the nodes)
56.26 Elastic Load Balancing (one ALB per Ingress)
29.02 VPC (NAT gateway)
27.54 EC2 - Other (EBS)
5.28 RDS
Already implemented — single NAT gateway, Karpenter with a spot-first
NodePool and spot-interruption handling, managed node group scaled to
desired_size = 1 with Karpenter taking the burst, overnight scale-to-zero,
7-day EKS log retention, Prometheus storage limits, and eleven ALBs removed
(five operator tools, then six dead internal ones).
Ranked by what is actually left:
| # | Proposal | Est. saving | Effort / risk |
|---|---|---|---|
| 1 | Destroy between sessions. Nothing else comes close — the platform costs ~$48/day running and ~$1.60/month destroyed. The state bucket and lock table are preserved precisely so a rebuild is cheap. | ~$1,400/mo vs always-on | None; already the practice |
| 2 | Consolidate the eight internet-facing ALBs onto one host-routed ALB | ~$112/mo | Medium — needs domain_name, DNS, ACM |
| 3 | Drop the NAT gateway in dev. ~$32/mo plus data processing, purely so private nodes can reach the internet. A dev cluster can run nodes in public subnets, or keep NAT and add S3/ECR/STS VPC endpoints so image pulls stop crossing it. | $29–32/mo | Medium; endpoints have their own hourly cost, so measure first |
| 4 | Force gp3 EBS. No volume_type is set anywhere in terraform/, so volumes land on the driver default. gp3 is ~20% cheaper than gp2 and decouples IOPS from size. |
~$5/mo at current usage | Low — a StorageClass change |
| 5 | Set retention on non-EKS log groups. Only eks.tf sets retention_in_days; the Lambda log group had none, meaning never expire. |
Small now, grows forever | Low |
| 6 | Right-size the memory-optimized node group. It defaults to desired_size = 0 and only scales for AI workloads — confirm it returns to zero after bootstrap-ai.sh runs, rather than idling. |
Up to ~$60/mo if it idles | Low — verification, not code |
Not ours, but the largest residual line in this account once the platform is
destroyed: a 50 GB RDS snapshot (infra-landscape-dev-postgres-final-snapshot,
~$4.75/mo) and a disabled KMS key (alias/mokey, created 2021, ~$1/mo) — both
left over from an unrelated project. Together they cost roughly 3.5× everything
this platform leaves behind.
Budget alert¶
A budget is provisioned in Terraform at $100/month with SNS alerts at:
- 80% actual (~$80 spent) — early warning
- 100% forecasted — projected overage
Set budget_alert_email in terraform/terraform.tfvars to receive email alerts directly. The SNS → Slack Lambda bridge fires if idp-mvp/slack-webhook is populated in Secrets Manager.
Production Hardening¶
Scale EKS for production load¶
# terraform/terraform.tfvars
node_instance_types = ["t3.large"]
node_group_min_size = 2
node_group_desired_size = 3
node_group_max_size = 8
enable_cost_optimizer = false # keep nodes up 24/7 for production
Enable RDS deletion protection and backups¶
# terraform/terraform.tfvars
environment = "prod" # enables deletion_protection=true, skip_final_snapshot=false
# Note: backup_retention_period is currently set to 1 (cost-optimized default).
# For production, increase it in terraform/rds.tf:
# backup_retention_period = 7
Enable ALB sticky sessions (if scaling Grafana)¶
Add to Grafana ALB ingress annotations:
alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=86400
Rotate GitHub token¶
aws secretsmanager get-secret-value --secret-id idp-mvp/backstage --region us-east-1 \
--query SecretString --output text | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['GITHUB_TOKEN'] = 'ghp_NEW_TOKEN_HERE'
print(json.dumps(d))
" | aws secretsmanager put-secret-value \
--secret-id idp-mvp/backstage --region us-east-1 --secret-string file:///dev/stdin
kubectl annotate externalsecret backstage-secrets -n backstage force-sync=$(date +%s) --overwrite
kubectl rollout restart deployment/backstage -n backstage
Set PagerDuty / Jira credentials (optional)¶
Both are optional — left unset, the On-Call and Issues tabs render their empty state rather than erroring, exactly as they do locally. Supply them either through Terraform:
# terraform.tfvars
pagerduty_token = "your-read-only-rest-api-key"
jira_token = "base64-of-email:api_token"
jira_url = "https://your-company.atlassian.net"
...or by patching the existing secret in place, which avoids a Terraform apply:
aws secretsmanager get-secret-value --secret-id idp-mvp/backstage --region us-east-1 \
--query SecretString --output text | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['PAGERDUTY_TOKEN'] = 'YOUR_PAGERDUTY_KEY'
d['JIRA_TOKEN'] = 'YOUR_BASE64_EMAIL_COLON_TOKEN'
d['JIRA_URL'] = 'https://your-company.atlassian.net'
print(json.dumps(d))
" | aws secretsmanager put-secret-value \
--secret-id idp-mvp/backstage --region us-east-1 --secret-string file:///dev/stdin
kubectl annotate externalsecret backstage-secrets -n backstage force-sync=$(date +%s) --overwrite
kubectl rollout restart deployment/backstage -n backstage
JIRA_TOKEN is base64(email:api_token), not the raw API token. Generate the
token at https://id.atlassian.com/manage-profile/security/api-tokens, then
printf 'you@example.com:TOKEN' | base64.
Leaving JIRA_URL empty keeps app-config.aws.yaml on its RFC 2606
https://jira.invalid default, which never resolves — deliberate, so an
unconfigured proxy cannot send requests to a domain nobody here controls.
Pod Disruption Budgets¶
kubectl apply -f - <<EOF
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: backstage-pdb
namespace: backstage
spec:
minAvailable: 1
selector:
matchLabels:
app: backstage
EOF
Cleanup & Destroy¶
# Safe teardown — runs all phases in the correct order
./scripts/cleanup.sh --cluster-name idp-mvp --force
cleanup.sh runs nine ordered phases (0–8). The order is the whole point —
see why terraform destroy alone is not enough.
| Phase | What it does |
|---|---|
| 0 — Stop the writers | Deletes ArgoCD ApplicationSets, then workload Applications with cascade, so the load balancer controller tears down its own ALBs instead of leaking them. Also scales the Loki write path to zero — Loki is Helm-installed, not ArgoCD-managed, and will otherwise keep writing into the S3 bucket Phase 5 is trying to empty. |
| 1 — ALBs | Deletes any orphaned Kubernetes-managed ALBs (k8s-*) and stale k8s-* security groups. Both block VPC deletion, and the security groups only release asynchronously, so this retries. |
| 2 — RDS protection | Disables deletion protection on the Backstage RDS instance |
| 3 — Scaffolded services | Auto-discovers user-scaffolded services in services/ (excludes built-ins). Deletes their ArgoCD Applications, uninstalls Helm releases, removes services/<name>/ and commits the deletion. Runs while EKS is still up so ArgoCD can cascade cleanly. |
| 4 — Crossplane resources | Finds everything tagged idp:provisioner=crossplane via the Resource Groups Tagging API and deletes it: S3 buckets (all versions + delete markers), RDS instances, DynamoDB tables, SQS queues. MSK topics go with the cluster in Phase 6. |
| 5 — S3 + ECR empty | Empties Terraform-managed S3 buckets and force-deletes <cluster>-prefixed ECR repos. AWS blocks bucket deletion when non-empty and force_destroy = false, which is the case for every bucket here. |
| 6 — Terraform destroy | Destroys all Terraform-managed resources: EKS, VPC, IAM/IRSA, RDS, ECR, KMS, Secrets Manager. Retried, because BucketNotEmpty and security-group DependencyViolation are both expected and self-healing. |
| 7 — CloudWatch | Deletes log groups EKS and Lambda create at runtime (/aws/eks/<cluster>, /aws/containerinsights/<cluster>, /aws/lambda/<cluster>-*) — these survive the cluster and keep billing |
| 8 — Verify | Checks EKS, RDS, ALBs, Crossplane-tagged resources and log groups are all zero |
Preserved deliberately: the Terraform state bucket
(<cluster>-terraform-state-*), its DynamoDB lock table
(<cluster>-terraform-locks), and any ECR repo whose name does not contain
the cluster name. Those bare-named repos were never Terraform-managed, so a
teardown leaves them — see the ownership table below.
What Terraform owns, and what it cannot destroy¶
No, terraform destroy on its own will not clean up this platform, and the
gap is not an oversight — it follows from how the platform is built. Terraform
owns the account and cluster foundation. Controllers running inside the
cluster create AWS resources of their own in response to Kubernetes objects, and
Terraform has never heard of those.
| Resource | Owner | Why |
|---|---|---|
| EKS, VPC, subnets, NAT, IAM/IRSA, OIDC provider, RDS, KMS, Secrets Manager, budgets, ACM | Terraform (terraform/, ~270 resources) |
Account/cluster foundation, applied once by bootstrap.sh |
ECR repos named <cluster>/<service> |
Terraform (force_delete = true) |
Declared in ecr.tf; images are removed with the repo |
ALBs, target groups, k8s-* security groups |
AWS Load Balancer Controller | Created at runtime from Ingress objects. They exist in no state file, and they block VPC deletion. Delete the Ingress and the controller cleans up after itself; delete the cluster first and they orphan at ~$16/mo each, invisible to Terraform. This is why Phase 0 runs before Phase 6. |
| S3/RDS/DynamoDB/SQS/MSK from Claims | Crossplane | Provisioned from Claims committed to Git. Compositions set deletionPolicy: Orphan on purpose — a deleted Claim must not silently destroy a team's data. They are found by the idp:provisioner=crossplane tag instead. |
ECR repos created by bootstrap-ai.sh |
The script, imperatively | AI/MCP service repos are created on demand, not declared in Terraform |
| Contents of S3 buckets and ECR repos | Nobody — runtime data | Terraform can delete a bucket but AWS refuses while it holds objects and force_destroy = false. Contents must be emptied first, which is Phase 5. |
| EBS volumes behind PersistentVolumes (Prometheus, Alertmanager, Loki, the MLflow SQLite PVC, …; KAgent's database is RDS when the AI layer is on) | EBS CSI driver | Created on demand for each PVC and tagged kubernetes.io/cluster/<cluster>=owned. Deleting the cluster detaches them but never deletes them; they keep billing as available volumes (18 volumes / 145 GB had piled up across deploy cycles by 2026-09). Phase 7b deletes them by that tag after terraform destroy. |
Karpenter nodes, launch templates, instance profiles (only with enable_karpenter = true) |
Karpenter | Launched by the controller from NodePools, not by Terraform. If the controller is uninstalled while nodes are still terminating they orphan at full EC2 price, and their ENIs block the VPC delete. Phase 0b drains the NodePools while the controller is alive; Phase 5b terminates stragglers before terraform destroy; Phase 7c deletes the launch templates (karpenter.k8s.aws/cluster) and <cluster>_* instance profiles afterwards. |
Secret <cluster>/langfuse/project-keys |
bootstrap-ai.sh, imperatively |
Created with the CLI when Langfuse is enabled on AWS; deleted in Phase 5b with the same zero-day recovery window Terraform uses for its own secrets. |
/aws/eks/*, /aws/lambda/* log groups |
EKS / Lambda, at runtime | Created by the services themselves and outlive the cluster |
| Kubeconfig contexts | Local machine | Not an AWS resource; prune with kubectl config delete-context |
Anything else still tagged Project=<cluster> or kubernetes.io/cluster/<cluster> after all that is reported by a tag sweep in Phase 8, which then refuses to call the cleanup complete. That is how an orphaned Terraform launch template from an interrupted May destroy would have been caught at the time instead of months later. KMS keys and secrets that are only scheduled for deletion are expected and skipped.
Two more reasons ordering matters, both learned the hard way:
- KMS keys and Secrets Manager secrets are never deleted immediately. Terraform "destroys" them, but AWS only schedules deletion — 7–30 days for a KMS key, 7–30 for a secret. They keep billing (~$1/mo and ~$0.40/mo) until the window closes. This is AWS behaviour, not a bug in the teardown.
- Once EKS is gone, Terraform cannot even plan. The
kubernetes,helmandkubectlproviders inmain.tftake their host frommodule.eks.cluster_endpoint. With the cluster destroyed that value is empty and the provider fails to configure, so a destroy interrupted after the cluster goes but before the VPC does cannot simply be re-run.cleanup.shdetects this and falls back to per-resource-targetdestroys.
If a teardown stalls, check the object count of the bucket being emptied
rather than the log — _empty_versioned_bucket only logs when it finishes, so a
slow phase and a wedged one look identical from the output.
Note on Crossplane resources: Because all Compositions use
deletionPolicy: Orphan, Crossplane resources survive Claim deletion. Phase 3 ofcleanup.shhandles them automatically by querying theidp:provisioner=crossplanetag. RDS deletions are asynchronous — the script initiates them and notes it in the summary.
To tear down only the AI/ML components on AWS without touching the core platform:
Important — local vs AWS separation:
bootstrap-ai.sh --destroyscopes deletions to the active environment. Passing--awsremovesaws/ingresses and the MLflow AWS overlay; omitting it removes onlylocal/ingresses. Never runbootstrap-ai.sh --destroy(without--aws) whileKUBECONFIGpoints at EKS — it will target the wrong cluster.
To tear down only the local Kind/Rancher cluster (no AWS resources are affected):
This runs the same scaffolded-service cleanup logic (ArgoCD Applications deleted, Helm releases uninstalled, services/<name>/ directories removed and committed) before the Kind cluster or Rancher Desktop namespaces are deleted.