Summary
The bootstrap phase runs on every staging and production deploy, but a single Terraform root (infrastructure/bootstrap/) manages both environments in one state file via for_each = local.environments (default: ["staging", "production"]).
A staging deploy therefore updates production IAM roles and policies (nest-production-terraform, associated policies), and vice versa. The workflow environment input is used only for GitHub Environment approval — it is not passed to Terraform.
We should split bootstrap into per-environment Terraform roots with separate state, matching the pattern used by infrastructure/live/, so each pipeline only applies IAM bootstrap for its own environment.
Problem
Current behavior
ci-cd-staging.yaml ──┐
├──► run-ci-cd.yaml
ci-cd-production.yaml ┘ │
▼
run-infrastructure-bootstrap.yaml
│
▼
infrastructure/bootstrap/ (single root)
environments = ["staging", "production"]
state key: bootstrap/terraform.tfstate
│
▼
Updates BOTH nest-staging-terraform
AND nest-production-terraform
Root causes
infrastructure/bootstrap/variables.tf defaults environments to ["staging", "production"].
- All bootstrap resources use
for_each = local.environments (main.tf, iam_policy_size_checks.tf).
run-infrastructure-bootstrap.yaml does not pass environment to tfvars — only aws_region, project_name, aws_role_external_id.
- Single state file (
bootstrap/terraform.tfstate in BOOTSTRAP_TF_STATE_BUCKET_NAME) holds both environments.
- Shared
aws_role_external_id is applied to every role's trust policy in one apply — if staging and production GitHub Environments use different ExternalIds, a staging bootstrap run can overwrite production's trust policy.
Impact
- Staging nightly deploy modifies production IAM without a production release.
- IAM policy changes intended for one environment affect the other.
- Larger blast radius and harder auditing ("who changed production IAM?").
- Violates environment isolation expected from separate GitHub Environments and deploy pipelines.
What works correctly today (for comparison)
infrastructure/live/ is scoped per environment: tf_state_key: staging/terraform.tfstate vs production/terraform.tfstate, and nest-${{ inputs.environment }}-terraform role assumption.
- Bootstrap should follow the same model.
Proposed solution
Split bootstrap into separate Terraform roots per environment with isolated state and environment-scoped CI/CD applies.
Target architecture
infrastructure/modules/bootstrap-iam/ # shared module (single environment)
infrastructure/bootstrap-staging/ # root: staging IAM only
infrastructure/bootstrap-production/ # root: production IAM only
State (same bootstrap bucket, separate keys):
staging/bootstrap/terraform.tfstate
production/bootstrap/terraform.tfstate
CI/CD:
staging pipeline → apply bootstrap-staging only
production pipeline → apply bootstrap-production only
Module extraction
Refactor current infrastructure/bootstrap/main.tf into a reusable module (e.g. infrastructure/modules/bootstrap-iam/):
- Remove
for_each = local.environments — module takes a single environment input.
- Keep existing IAM policy documents, size checks, role, and policy attachments — parameterized by
environment.
- Each environment deploy calls the module with its environment name.
This avoids duplicating large IAM policy documents while achieving state isolation.
Per-environment roots
Each root contains:
backend.tf — distinct state key
main.tf — module invocation with environment = "staging" or "production"
variables.tf, providers.tf, outputs.tf
terraform.tfbackend.example, terraform.tfvars.example
tests/ — env-specific tftest (policy size checks for that environment only)
Documentation updates
infrastructure/bootstrap/README.md → split or replace with per-env docs
infrastructure/README.md — manual bootstrap setup steps
infrastructure/live/README.md — remove implication that any deploy updates both roles
infrastructure/state/README.md — clarify bootstrap state key layout if bucket/key conventions change
Acceptance criteria
Non-goals
- Changing
infrastructure/live/ deploy scoping (already correct).
- Splitting
infrastructure/state/ (separate concern; state buckets already per environment).
- Migrating
nest-bootstrap IAM user to OIDC (related, separate issue).
- Removing bootstrap from every deploy.
Summary
The bootstrap phase runs on every staging and production deploy, but a single Terraform root (
infrastructure/bootstrap/) manages both environments in one state file viafor_each = local.environments(default:["staging", "production"]).A staging deploy therefore updates production IAM roles and policies (
nest-production-terraform, associated policies), and vice versa. The workflowenvironmentinput is used only for GitHub Environment approval — it is not passed to Terraform.We should split bootstrap into per-environment Terraform roots with separate state, matching the pattern used by
infrastructure/live/, so each pipeline only applies IAM bootstrap for its own environment.Problem
Current behavior
Root causes
infrastructure/bootstrap/variables.tfdefaultsenvironmentsto["staging", "production"].for_each = local.environments(main.tf,iam_policy_size_checks.tf).run-infrastructure-bootstrap.yamldoes not passenvironmentto tfvars — onlyaws_region,project_name,aws_role_external_id.bootstrap/terraform.tfstateinBOOTSTRAP_TF_STATE_BUCKET_NAME) holds both environments.aws_role_external_idis applied to every role's trust policy in one apply — if staging and production GitHub Environments use different ExternalIds, a staging bootstrap run can overwrite production's trust policy.Impact
What works correctly today (for comparison)
infrastructure/live/is scoped per environment:tf_state_key: staging/terraform.tfstatevsproduction/terraform.tfstate, andnest-${{ inputs.environment }}-terraformrole assumption.Proposed solution
Split bootstrap into separate Terraform roots per environment with isolated state and environment-scoped CI/CD applies.
Target architecture
Module extraction
Refactor current
infrastructure/bootstrap/main.tfinto a reusable module (e.g.infrastructure/modules/bootstrap-iam/):for_each = local.environments— module takes a singleenvironmentinput.environment.This avoids duplicating large IAM policy documents while achieving state isolation.
Per-environment roots
Each root contains:
backend.tf— distinct state keymain.tf— module invocation withenvironment = "staging"or"production"variables.tf,providers.tf,outputs.tfterraform.tfbackend.example,terraform.tfvars.exampletests/— env-specific tftest (policy size checks for that environment only)Documentation updates
infrastructure/bootstrap/README.md→ split or replace with per-env docsinfrastructure/README.md— manual bootstrap setup stepsinfrastructure/live/README.md— remove implication that any deploy updates both rolesinfrastructure/state/README.md— clarify bootstrap state key layout if bucket/key conventions changeAcceptance criteria
nest-staging-terraformand its policies.nest-production-terraformand its policies.for_eachbootstrap state).aws_role_external_idis scoped per environment (no cross-env trust policy overwrite).Non-goals
infrastructure/live/deploy scoping (already correct).infrastructure/state/(separate concern; state buckets already per environment).nest-bootstrapIAM user to OIDC (related, separate issue).