AWS install: known failure modes¶
The AWS path went effectively untested between May and 2026-08-11. Bringing a real cluster up over the following days surfaced roughly twenty distinct defects. Every one of them is fixed in the scripts, Terraform, or manifests — none was a manual workaround — so a fresh install gets the fixes automatically.
This page exists for two reasons. If your run fails, the symptom is probably here along with the file that was supposed to prevent it. And if you are changing the bootstrap path, these are the shapes of mistake it has actually made, which is more useful than a checklist of things that have never gone wrong.
For live debugging start with TROUBLESHOOTING.md; this page is about why each class of failure was possible.
The classes¶
1. Placeholders that were never substituted¶
The most common defect by a wide margin. A manifest ships with a literal token, and either nothing substitutes it or the substitution targets a different file.
| Symptom | Cause | Fixed in |
|---|---|---|
First terraform init fails with AccessDenied / NoSuchBucket |
main.tf pinned the backend to the maintainer's own bucket. Not a YOUR_* token, so placeholders.conf never rewrote it |
terraform/main.tf, ensure_tf_state_backend() in scripts/lib.sh |
terraform/global could never initialise |
Its bucket name had a different shape from anything the repo creates | terraform/global/main.tf |
Backstage pods point at http://BACKSTAGE_ALB_URL, KAgent links dead, Datadog shows version BACKSTAGE_IMAGE_TAG |
bootstrap.sh's sed matched only the image: line; bootstrap-ai.sh patched the ConfigMap, but the scaffolder reads the deployment env |
scripts/bootstrap.sh, scripts/bootstrap-ai.sh |
External Secrets always resolved against us-east-1 regardless of --region |
Region hardcoded in the manifest | aws/backstage/external-secret.yaml |
If you are adding a placeholder: grep for every consumer of the value before assuming one substitution covers it. Config maps and deployment envs are different places, and something usually reads both.
2. Cold-account Terraform ordering¶
An empty account is a different environment from one that already has a cluster, and the difference is not cosmetic.
- The
alekc/kubectlprovider (used for the KarpenterEC2NodeClass/NodePoolmanifests) cannot configure itself whilemodule.eks.cluster_endpointis unknown. It fails the entire plan before creating anything, so a single untargeted apply cannot bootstrap an empty account.bootstrap.shtherefore appliesmodule.vpc+module.eksfirst. module.vpcmust be targeted explicitly alongsidemodule.eks.-targetpulls in only what the target depends on, and the cluster depends on subnet IDs — not on the NAT gateway, its EIP, or the private route tables. Targetingmodule.eksalone builds a cluster whose private subnets have no0.0.0.0/0route, and every node failsNodeCreationFailure: Instances failed to join the kubernetes clusterabout twenty minutes in.- Detecting "cold start" by
terraform state listreturning nothing is wrong: a stale lock, expired credentials and an unreachable backend all produce empty output too.bootstrap.shfails closed when the state cannot be read.
All three live in scripts/bootstrap.sh, Phase 1.
3. Control flow that silently discarded configuration¶
- The Phase 3.7 credential merge was nested inside the
elseof aGITHUB_TOKENcheck, so when a token was present it discardedAUTH_GITHUB_*,GRAFANA_ADMIN_PASSWORD, fiveGITHUB_APP_*values andTEAM_MAP, and never generatedBACKSTAGE_CATALOG_TOKENorAUTH_SESSION_SECRET. --skip-obswas parsed and never read.poll_untilcalled an undefinedinfo()for three of its four callers.
4. Placeholder values that passed a non-empty check¶
REPLACE_ME is not empty, so [[ -n "$KEY" ]] accepts it. KAgent installed
"successfully" against a placeholder Anthropic key and failed at first use. Any
check on an optional key must test for the placeholder explicitly — see
scripts/verify-secrets.sh for the pattern.
5. Things that were never installed on AWS at all¶
Each of these worked locally and was assumed to work on EKS:
metrics-server(not installed on EKS; also needed a control-plane SG path)- Sloth SLO rules
- The catalog and Tech Insights exporters
- Groups and Users — seventeen
type: filecatalog locations resolved to nothing in the container, leaving the catalog with zeroGroup/Userentities - A GitHub token for Backstage, without which catalog, scaffolder and TechDocs cannot start
The pattern: local uses a bind mount and a Kind-specific path; EKS has neither. Anything that reads from disk or assumes a local addon needs an explicit AWS path.
6. Over-broad IAM¶
The GitHub Actions OIDC trust policy was scoped repo:<org>/*:* — PowerUser plus
IAMFullAccess for every repository in the org, including every scaffolded service.
Now scoped to var.platform_repo.
8. A 5-second timeout on an LLM call¶
Found by firing a synthetic critical alert through the incident pipeline on the live cluster, then reading the router log rather than trusting the outcome.
The GitHub incident issue was created correctly — right labels, right marker. The log said otherwise:
1 2 | |
agent-event-router used one HTTP_TIMEOUT_MS (default 5000ms) for every
outbound call, including the KAgent A2A dispatch. That dispatch is an LLM turn,
and several more once the agent starts calling MCP tools.
Measured on the live incident-agent rather than guessed:
| Prompt | Time |
|---|---|
| Trivial, no tool calls | 4.3s |
| Real triage, multi-tool | 42.9s |
So 5s aborted even the trivial case, by a 700ms margin.
60s was tried next and still failed. KAgent's message/send is synchronous
and concurrent alerts serialise: two alerts 35s apart put the second past the
limit while the first was still thinking. That is the behaviour under any alert
burst, which is exactly when incident triage matters.
The failure was near-invisible. The incident issue is created before dispatch, so the pipeline still produced its most visible artefact while the agent half silently never ran. Nothing was marked failed.
Agent dispatch now has its own AGENT_TIMEOUT_MS (default 180s — a
multi-tool turn plus a couple queued behind it), and an abort logs which agent
timed out and that the record was still created, instead of a bare
DOMException.
That is a mitigation, not the fix. The real fix is to stop awaiting the whole agent turn in a webhook handler — the router's job is to dispatch, not to wait for an LLM to finish. Left as a follow-up rather than done blind.
The general lesson: a timeout tuned for an HTTP API is wrong for an LLM call by an order of magnitude, and when the slow call is the last step its failure does not look like failure.
Argo Workflows: four faults behind one install that had never run on AWS¶
Until 2026-08-16 nothing installed Argo Workflows during bootstrap-ai.sh, so
the live cluster had no argo-workflows namespace and the AWS values file had
never been rendered against a real cluster. The first real run failed four
times in a row, each on a different fault:
nil pointer evaluating interface {}.key. The chart'suseStaticCredentialsdefaults totrue, and its config-map template then dereferencesartifactRepository.s3.accessKeySecret.keyunconditionally. With IRSA there is no such secret. Fix:useStaticCredentials: falseplusartifactRepository.s3.useSDKCreds: true.spec.rules[0].http.paths[0].path: Invalid value: "map[path:/ pathType:Prefix]".server.ingress.pathsis a list of path strings withpathTypeas a sibling scalar, not a list of objects.- An ingress host that can never resolve. The values file built its host as
argo-workflows.${BACKSTAGE_ALB_URL}— a subdomain prefixed onto an ALB's own DNS name. The ALB was provisioned and every request missed the rule. Every other public ingress on this platform rendersHOSTS=*; this one now does too, which also deletes a placeholder from the substitution chain. argo_workflows_role_arndoes not exist.bootstrap.shhad been reading that Terraform output since the feature was written, and there is no such output and no Argo Workflows IAM role interraform/. It resolved to empty every time, so S3 artifact upload has never worked — andkubernetes/argo-workflows/rbac.yamlships a literalREPLACE_WITH_ARGO_WORKFLOWS_ROLE_ARNwhose comment claims the bootstrap patches it. Nothing did. The install now disablesarchiveLogswhen the ARN is missing (otherwise every workflow fails trying to upload its logs) and removes the annotation rather than leaving a placeholder that reads as a configured role. The Terraform role is tracked in #357.
The general lesson, and it is the same one as the approval gate in
docs/agent-approvals.md: an install path that has never run is not "probably
fine", and a placeholder that nothing substitutes is indistinguishable from a
real value until something tries to use it. Three of these four faults were
invisible to helm lint, CI, and every dashboard.
What still is not covered¶
Being explicit about the gaps, because a green CI run should not imply more than it proves:
- No CI job runs an actual AWS bootstrap.
aws-plan-checkvalidates that the Terraform configuration parses, resolves and plans; it cannot catch a runtime ordering bug, a missing IAM permission, or anything in the ~4,500 lines of bash. - Ten of the eleven services have never been built by CI. The build matrix now
covers them, but that only exercises on a change under
services/<name>/. - Sloth has no in-cluster operator. The CRD is inert and the rules are vendored;
editing the source without the
slothbinary on PATH silently does nothing. Seedocs/sre-reliability.md.
If you hit something new¶
Fix it in the script, not by hand on the cluster — a manual fix leaves the next person with the identical failure. Then add the symptom here.