Multi-root Terraform monorepo for Durianpy infrastructure. Manages AWS resources across four accounts (root, prod, nonprod, sandbox) and GCP production resources, each as an independent Terraform Cloud workspace.
This repo was refactored using elemnta-infra-monorepo as structural inspiration. The following patterns were studied and adapted:
| Elemnta pattern | What was adopted | What was intentionally left out |
|---|---|---|
workspaces/{env}/ multi-root layout |
Adopted verbatim — one directory per account/workspace | Elemnta's tier-based dependency ordering within a single workspace (Durianpy's roots are simpler) |
assume_role in every AWS provider |
Adopted — all four workspaces assume durianpy-terraform-admin in their target account |
Elemnta's separate elemnta-admin-role vs elemnta-root role split (Durianpy uses a single role name per account) |
Shared reusable modules under modules/ |
Adopted — modules/aws/ and modules/gcp/ |
Elemnta's large ECS/LGTM/observability modules (Durianpy doesn't run those stacks yet) |
| Outputs contract between workspaces | Adopted lightly — each workspace exports key values via TFC workspace outputs | Elemnta's separate outputs/ Terraform root reading from S3 state (overkill for current Durianpy scale) |
| Root account owns shared services | Adopted — DNS, CDN, OIDC, connections stay in root workspace | Elemnta's Transit Gateway / VPN / Firewall topology (not needed) |
Per-workspace terraform.tfvars |
Adopted | Elemnta's .auto.tfvars pattern (either works; explicit tfvars preferred here) |
durianpy-root-infra/
├── workspaces/
│ ├── root/ # AWS root account — shared DNS, CDN, CI/CD identity
│ ├── prod/ # AWS prod account + GCP production project
│ ├── nonprod/ # AWS nonprod account — shared non-production infra
│ └── sandbox/ # AWS sandbox account — isolated/test resources
├── modules/
│ ├── aws/
│ │ ├── cdn/ # S3 origin + CloudFront + ACM + Route53
│ │ ├── github-oidc/ # GitHub Actions OIDC provider + IAM role
│ │ ├── budget/ # Cost budget with forecast alert
│ │ ├── connections/ # AWS CodeConnections for GitHub
│ │ └── terraform-state/ # S3 bucket for Terraform state
│ └── gcp/
│ └── project/ # GCP project API enablement + IAM + state bucket
├── package/ # LEGACY — previous single-root layout; kept for reference
│ ├── root/ # do not apply from here; use workspaces/root/ instead
│ └── sandbox/ # do not apply from here; use workspaces/sandbox/ instead
└── README.md
The legacy package/ tree is retained as a reference while moved blocks in
workspaces/root/moved.tf are live. It can be removed after the first successful
apply from workspaces/root/.
| Directory | TFC workspace | AWS account | Purpose |
|---|---|---|---|
workspaces/root/ |
durianpy-root |
Root / management | Shared DNS, CDN, CI/CD identity, state bucket |
workspaces/prod/ |
durianpy-prod |
Prod | Production AWS infra + GCP project |
workspaces/nonprod/ |
durianpy-nonprod |
Nonprod | Shared non-production infra |
workspaces/sandbox/ |
durianpy-sandbox |
Sandbox | Isolated testing |
All four workspaces live in the durianpy Terraform Cloud organization.
durianpy.orgpublic Route53 hosted zone (pre-existing; records managed here)cdn.durianpy.orgCloudFront distribution + S3 origin + ACM certificate- GitHub Actions OIDC provider and CI/CD IAM role (
github_oidc_role) - AWS CodeConnections entries for GitHub pipelines
- Root account monthly cost budget ($20)
durianpy-infra-terraform-stateS3 bucket (legacy; state now primarily in TFC)
- Production AWS resources (add modules here as the account grows)
- GCP production project — API enablement, IAM bindings, optional state bucket
- Prod account monthly cost budget
- Shared non-production AWS infrastructure
- Nonprod account monthly cost budget
- Isolated / ephemeral test resources
1. workspaces/root/ — owns shared DNS, OIDC, state bucket
2. workspaces/prod/ — independent; apply after root if consuming root outputs
3. workspaces/nonprod/ — independent; apply after root if consuming root outputs
4. workspaces/sandbox/ — fully independent
Prod, nonprod, and sandbox are independent of root today. If they later consume root outputs (e.g. Route53 zone ID for subdomain delegation), apply root first.
Inspired by the Elemnta repo's pattern where every provider block explicitly calls
sts:AssumeRole rather than relying on ambient credentials for the target account.
Terraform Cloud identity (base credentials)
│
├─► assume_role → arn:aws:iam::ROOT_ACCOUNT_ID:role/durianpy-terraform-admin
│ └─► runs workspaces/root/ plan + apply
│
├─► assume_role → arn:aws:iam::PROD_ACCOUNT_ID:role/durianpy-terraform-admin
│ └─► runs workspaces/prod/ plan + apply
│
├─► assume_role → arn:aws:iam::NONPROD_ACCOUNT_ID:role/durianpy-terraform-admin
│ └─► runs workspaces/nonprod/ plan + apply
│
└─► assume_role → arn:aws:iam::SANDBOX_ACCOUNT_ID:role/durianpy-terraform-admin
└─► runs workspaces/sandbox/ plan + apply
The TFC base identity only needs sts:AssumeRole permission for the four admin
role ARNs. All infra permissions are granted via the assumed role.
Cross-account provider aliases: the root workspace also declares an
aws.us_east_1 provider alias that assumes the same root role but targets
us-east-1. This is required because CloudFront mandates ACM certificates in
us-east-1 regardless of where other resources are deployed.
Role name: durianpy-terraform-admin
Trust policy: must trust the TFC base identity (OIDC or IAM user ARN).
Permissions: AdministratorAccess (or a tighter custom policy per account).
The assume-role pattern requires durianpy-terraform-admin to exist before
Terraform can run. First-time setup:
-
Create
durianpy-terraform-adminin each target account manually (or via AWS CloudFormation StackSets from the org management account):{ "RoleName": "durianpy-terraform-admin", "AssumeRolePolicyDocument": { "Statement": [{ "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::TFC_BASE_IDENTITY_ACCOUNT_ID:root" }, "Action": "sts:AssumeRole" }] }, "ManagedPolicies": ["arn:aws:iam::aws:policy/AdministratorAccess"] }Replace
TFC_BASE_IDENTITY_ACCOUNT_IDwith the account ID of the IAM user or role used by Terraform Cloud. -
Configure TFC workspace variables (sensitive):
TF_VAR_root_account_id(root workspace)TF_VAR_prod_account_id(prod workspace)TF_VAR_nonprod_account_id(nonprod workspace)TF_VAR_sandbox_account_id(sandbox workspace)- AWS credentials:
AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEYfor the TFC base identity, or configure TFC's native dynamic AWS credentials.
-
Apply workspaces/root/ first — it processes the
movedblocks that migrate existing resources from the oldmodule.package.module.root.*address hierarchy.
workspaces/root/ points to the same durianpy-root TFC workspace as the
old main.tf. The moved.tf file remaps all live resource addresses:
module.package.module.root.module.budget.aws_budgets_budget.cost
→ module.budget.aws_budgets_budget.cost
module.package.module.root.module.iam.aws_iam_role.github_oidc
→ module.github_oidc.aws_iam_role.github_oidc
module.package.module.root.module.s3.aws_s3_bucket.cdn \
module.package.module.root.module.cloudfront.* ├─ → module.cdn.*
module.package.module.root.module.route53.* /
...and so on (see workspaces/root/moved.tf for the full list)
On the first terraform plan, Terraform reads the existing TFC state and applies
the moved blocks — no resources are destroyed or re-created. After a successful
terraform apply the moved blocks become permanent no-ops.
The previous package/sandbox/ used a local state file. To migrate to the new
durianpy-sandbox TFC workspace without recreating 150 IAM resources:
cd workspaces/sandbox
terraform login
terraform init # initialises the TFC backend
# Push the old local state to TFC (Option A — recommended)
terraform state push ../../package/sandbox/terraform.tfstate
terraform plan # should show 0 changes if resource addresses are identicalResource addresses in workspaces/sandbox/main.tf are kept flat (identical to the
old workspace) so no terraform state mv operations are needed.
GCP credentials for the durianpy-prod workspace are sourced in priority order:
-
Workload Identity Federation (recommended) — configure TFC's OIDC provider in GCP IAM. Set
GOOGLE_APPLICATION_CREDENTIALSin the TFC workspace to a generated impersonation credential file. This avoids static keys entirely. -
Service account key — store the raw JSON key (or base64-encoded) in the TFC workspace variable
GOOGLE_CREDENTIALS. Simpler to bootstrap but less secure. -
Application Default Credentials — for local development only (
gcloud auth application-default login).
No service account JSON files should ever be committed to this repository.
Creates an end-to-end CDN stack: S3 origin bucket, CloudFront distribution,
ACM certificate (us-east-1), and Route53 DNS records. Requires two provider
configurations: default (primary region) and aws.us_east_1 (for ACM).
Creates a GitHub Actions OIDC provider and IAM role with controlled IAM + Terraform state + STS permissions. Dangerous operations (user creation, access key management, OIDC provider modification) are explicitly denied.
AWS Budgets cost budget with a forecasted threshold notification.
AWS CodeConnections (GitHub) for pipeline integrations. Connections require manual activation in the AWS Console after apply.
S3 bucket with versioning and AES256 encryption for storing Terraform state files.
GCP project API enablement, project-level IAM bindings, and optional GCS state bucket. See the module variables for credential configuration options.