Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Multi-Account AWS with Terraform

By Kokil Thapa | Last reviewed: September 2026

Running everything in one AWS account works until a staging deploy touches production data or a billing spike hits the wrong card. Multi-Account AWS with Terraform gives you account boundaries, separate blast radius, and repeatable infrastructure across dev, staging, and prod. If you already treat infrastructure as code with Terraform as production discipline, extending that model across accounts is the natural next step. This guide walks through Organizations setup, provider configuration, state design, and patterns I have seen work on real deployments — including the mistakes that waste weekends.

Why should you use Multi-Account AWS with Terraform instead of one account?

AWS recommends multiple accounts for security, billing isolation, and workload separation. Terraform gives you a single workflow to provision VPCs, RDS instances, and IAM roles consistently across all of them. Without that discipline, each account becomes a snowflake configured by console clicks.

On client projects where I handle both application code and Linux server administration, the same principle applies at the cloud layer. Separate accounts for production and non-production stop a misconfigured security group in dev from exposing prod databases. Service Control Policies at the organization level enforce guardrails Terraform alone cannot guarantee.

Multi-Account AWS TopologyManagementOrganizations + SCPsSecurityLogs + GuardDutyShared ServicesState + CI runnersAuditRead-only accessDev AccountVPC + EKSStaging AccountPre-prod workloadsProd AccountLive trafficTerraform applies via assume_role from CI
Typical Multi-Account AWS with Terraform layout: management account owns Organizations, workload accounts hold environments, shared services hosts remote state.

A practical account map for a mid-size team looks like this:

  • Management account — Organizations, billing consolidation, SCPs only; no application workloads.
  • Security / audit — CloudTrail aggregation, GuardDuty admin, read-only compliance tooling.
  • Shared services — Terraform state buckets, ECR repos, CI runners, DNS if centralised.
  • Workload accounts — One or more per environment or per product line.

For a Laravel application on EC2 — similar to deployments I maintain with Deployer 7 and GitLab CI — you might run dev in one account and prod in another. The app code stays identical; only Terraform variables and account IDs change per environment.

How do you set up AWS Organizations before running Terraform?

Start in the management account. Enable AWS Organizations, create Organizational Units, and invite or create member accounts. Do not run application Terraform from the management account root user — ever.

Create the organization and member accounts

Use the AWS CLI or a bootstrap Terraform stack in the management account. Account creation via Organizations is idempotent when you tag accounts consistently.

aws organizations create-organization --feature-set ALL

aws organizations create-account \
  --email aws-dev@yourdomain.com \
  --account-name "workload-dev" \
  --role-name OrganizationAccountAccessRole

aws organizations create-account \
  --email aws-prod@yourdomain.com \
  --account-name "workload-prod" \
  --role-name OrganizationAccountAccessRole

The OrganizationAccountAccessRole is created automatically in each new account. Your Terraform execution role in the shared-services account will assume this role during the bootstrap phase, then switch to a tighter custom role afterward.

Apply Service Control Policies before workloads land

SCPs restrict what member accounts can do regardless of IAM permissions inside them. Common baseline policies deny root user access keys, restrict regions to ap-south-1 and us-east-1, and block public S3 ACLs. Apply SCPs at the OU level before any workload Terraform runs — retrofitting is painful.

If you adopt AWS Control Tower, it provisions the OU structure, baseline guardrails, and account factory for you. You can still manage custom resources with Terraform in member accounts; Control Tower handles the landing zone, not every S3 bucket.

How do you configure Terraform providers for multiple AWS accounts?

The HashiCorp AWS provider supports assume_role blocks and provider aliases. One root module can target multiple accounts in a single plan when configured correctly. Pin the provider version — drift in provider behaviour breaks multi-account pipelines silently.

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70"
    }
  }
}

provider "aws" {
  region = "ap-south-1"

  assume_role {
    role_arn     = "arn:aws:iam::111111111111:role/TerraformExecutionRole"
    session_name = "terraform-shared-services"
  }
}

provider "aws" {
  alias  = "dev"
  region = "ap-south-1"

  assume_role {
    role_arn     = "arn:aws:iam::222222222222:role/TerraformExecutionRole"
    session_name = "terraform-dev"
  }
}

provider "aws" {
  alias  = "prod"
  region = "ap-south-1"

  assume_role {
    role_arn     = "arn:aws:iam::333333333333:role/TerraformExecutionRole"
    session_name = "terraform-prod"
  }
}

Pass the alias into modules explicitly. A common mistake is forgetting providers = { aws = aws.dev } inside a child module, which silently deploys to the default account.

Terraform assume_role FlowGitLab CIOIDC authShared SvcExecution roleTerraform CLIplan and applyDev Accountassume_roleStagingassume_roleProd Accountassume_roleTrust policy allows only shared-services role ARN
Multi-Account AWS with Terraform authentication chain: CI assumes a role in shared services, then Terraform assumes per-account execution roles.

IAM trust policies that actually work

Each workload account needs a TerraformExecutionRole trusted by the shared-services CI role. Keep the trust policy narrow — one principal ARN, optional external ID for third-party runners.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::999999999999:role/GitLabTerraformRunner"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "your-org-terraform-external-id"
        }
      }
    }
  ]
}

Attach least-privilege policies per account. Dev roles might manage EC2 and RDS; prod roles should require manual approval in your CI pipeline before terraform apply. This mirrors how I gate production Deployer releases behind tagged pipelines on sister sites sharing the same GitLab CI workflow.

For deeper provider alias patterns across clouds, see the companion post on Terraform provider aliases for multi-cloud — the assume_role mechanics transfer directly to AWS-only setups.

Where should remote state live in a multi-account AWS setup?

Never store Terraform state in the same account it manages if you can avoid it. A dedicated shared-services account hosts S3 buckets and DynamoDB lock tables. State files contain secrets — treat the bucket like a database backup.

The pattern I recommend matches what we document for managing Terraform state safely:

  1. Create one S3 bucket per environment or one bucket with strict prefix isolation.
  2. Enable versioning, SSE-KMS encryption, and block all public access.
  3. Use a DynamoDB table for state locking — one table can serve multiple state keys.
  4. Restrict bucket policy to Terraform execution role ARNs only.
  5. Enable S3 access logging to the audit account.
terraform {
  backend "s3" {
    bucket         = "org-terraform-state-ap-south-1"
    key            = "workloads/dev/vpc/terraform.tfstate"
    region         = "ap-south-1"
    encrypt        = true
    kms_key_id     = "arn:aws:kms:ap-south-1:999999999999:key/abc-123"
    dynamodb_table = "terraform-locks"
    role_arn       = "arn:aws:iam::999999999999:role/TerraformStateAccessRole"
  }
}

Split state by blast radius, not by convenience. A VPC stack, an EKS cluster stack, and an application stack should each have separate state files. One giant state across three accounts makes every plan slow and every mistake catastrophic. Terragrunt helps keep backend configuration DRY when you have dozens of stacks.

Cross-account state access requires the state bucket policy to trust workload-account roles only for their prefix. Dev roles read/write workloads/dev/*; prod roles cannot touch dev prefixes. Validate this with Checkov scans on Terraform before merging.

What deployment patterns work best for Multi-Account AWS with Terraform?

Three patterns cover most teams. Pick based on team size, release cadence, and how much autonomy each product line needs.

PatternStructureBest forTrade-off
Monorepo + aliasesOne repo, provider aliases per accountSmall teams, shared modulesPlan scope grows; careful CI filtering needed
Repo per accountSeparate root modules per accountStrong isolation, different teamsModule versioning overhead
Hub + TerragruntShared modules, account/env folders10+ accounts, DRY backendsLearning curve for Terragrunt

Reusable modules across accounts

Extract VPC, ECS, and RDS patterns into versioned modules — the same approach as reusable Terraform modules. Pass account-specific CIDR blocks and instance sizes as variables. Tag every resource with Environment, ManagedBy = terraform, and AccountId for cost allocation.

module "vpc_dev" {
  source = "git::https://gitlab.com/org/terraform-modules.git//vpc?ref=v2.4.0"

  providers = {
    aws = aws.dev
  }

  cidr_block   = "10.10.0.0/16"
  environment  = "dev"
  account_name = "workload-dev"
}

module "vpc_prod" {
  source = "git::https://gitlab.com/org/terraform-modules.git//vpc?ref=v2.4.0"

  providers = {
    aws = aws.prod
  }

  cidr_block   = "10.30.0.0/16"
  environment  = "prod"
  account_name = "workload-prod"
}

Pin module refs to tags, not main. A breaking change in a shared VPC module should not auto-deploy to prod on the next plan.

Deployment Pattern ChoiceMonorepoProvider aliasesSingle CI pipelineBest: 2-5 accountsRepo / AccountIsolated stateTeam ownershipBest: complianceTerragrunt HubDRY backendsEnv hierarchyBest: 10+ accountsAvoid: one state file for all accountsSlow plans, wide blast radius, lock contentionStart monorepo, split when CI plan time hurtsMost teams never need pattern three on day one
Choosing a Multi-Account AWS with Terraform repo strategy: start simple, split state and repos when plan times or team boundaries demand it.

CI pipeline design

Run terraform plan on every merge request. Gate prod applies behind manual approval and branch protection. Use OIDC federation from GitLab or GitHub Actions to assume the shared-services role — no long-lived access keys in CI variables.

Structure pipelines so a change to dev modules does not trigger prod plans. Path-based rules or Terragrunt run-all plan with include filters keep feedback fast. For drift checks after deploy, schedule read-only plans nightly — the same mindset as Terraform drift detection strategies.

On an enterprise booking platform or similar multi-environment app, this pipeline model prevents a developer from accidentally applying staging RDS sizing to production. The code review sees the plan output before anyone clicks approve.

What mistakes break Multi-Account AWS with Terraform in production?

These failures show up repeatedly across teams new to multi-account setups. Most are fixable in an afternoon if you catch them early.

Hard-coded account IDs in modules

Account IDs belong in tfvars or Terragrunt config, not inside shared modules. Pass role_arn as a variable so the same module works in every account without forking.

Missing provider passthrough in nested modules

Terraform does not inherit provider aliases into child modules automatically. Every nested module needs a configuration_aliases declaration and explicit provider mapping. Run terraform validate in CI — it catches some but not all alias errors.

State bucket in a workload account

If the prod account holds its own state bucket and an attacker compromises prod IAM, they can read and rewrite state. Centralise state in shared services with cross-account bucket policies.

Skipping secrets management

Database passwords and API keys in tfvars files end up in state as plaintext. Use AWS Secrets Manager or SSM Parameter Store and reference values with data sources. Our guide on multi-cloud secrets management covers rotation patterns that apply directly here.

Production GotchasWrong account deployExplicit provider mapState in prod accountCentral S3 + KMSRoot user in CIOIDC + assume_roleOne giant state fileSplit by stack
Four frequent Multi-Account AWS with Terraform failures and the corrective pattern for each.

Compare your approach against AWS CloudFormation fundamentals if stakeholders ask why Terraform over native tooling. StackSets solve multi-account deployment too, but Terraform wins when you already manage Azure or on-prem with the same workflow — see managing multi-cloud state with Terraform for that broader picture.

Before your first prod apply, validate JSON policies with the JSON formatter tool and run a manual assume_role test from your CI runner account. A two-minute STS check saves hours of permission debugging.

For teams building custom platforms — not just infra — pairing this setup with enterprise application development or custom software development keeps application and infrastructure lifecycles aligned. Several sister sites I maintain on shared EC2 use the same GitLab CI discipline even when the cloud layer is simpler than full multi-account AWS.

Official references worth bookmarking: the HashiCorp AWS provider documentation for assume_role syntax, and the AWS Organizations user guide for SCP and OU behaviour.

Key Takeaways

  • Enable AWS Organizations first, apply SCPs at the OU level, and keep the management account free of workloads.
  • Use provider aliases with explicit assume_role blocks — never deploy to member accounts from the management root.
  • Host remote state in a shared-services account with KMS encryption, versioning, and prefix-scoped IAM policies.
  • Split state by stack and environment; pin module and provider versions to tagged releases.
  • Authenticate CI via OIDC, gate prod applies behind manual approval, and scan Terraform with Checkov before merge.
  • Start with a monorepo and provider aliases; adopt Terragrunt when account count or DRY backend config demands it.

People Also Ask

Can one Terraform workspace manage multiple AWS accounts?

Terraform workspaces isolate state within a single backend key — they do not isolate AWS accounts by themselves. For multi-account setups, use separate state keys or separate root modules with provider aliases. Workspaces suit environment variants within one account, not cross-account boundaries.

Do you need AWS Control Tower for Multi-Account AWS with Terraform?

No. Control Tower accelerates landing-zone setup with pre-built OUs and guardrails. You can create Organizations, accounts, and IAM roles manually or with bootstrap Terraform. Control Tower and custom Terraform coexist — many teams use Control Tower for account vending and Terraform for workload resources.

How much does a multi-account AWS setup cost?

AWS accounts are free; you pay for resources inside them. Expect roughly Rs 3,000–8,000/month (~USD 22–60) for baseline shared-services tooling — state storage, CloudTrail, GuardDuty — before application workloads. Actual spend depends on instance sizes and data transfer across accounts.

Is Terragrunt required for multi-account Terraform?

Not required. Terragrunt reduces repetition when you have many similar stacks across accounts. Teams with three to five accounts often succeed with a monorepo, provider aliases, and copy-paste backend blocks. Add Terragrunt when backend and variable duplication becomes the main maintenance burden.

Build your multi-account foundation the right way

Multi-Account AWS with Terraform is not optional once you have production workloads and more than one engineer touching infrastructure. Start with Organizations, centralise state, wire assume_role correctly, and split stacks before complexity compounds. The patterns here mirror what I apply on production deployments — boring infrastructure that survives staff turnover and 2 a.m. incidents.

If you need help designing a landing zone, migrating from a single account, or integrating Terraform into your existing GitLab CI pipeline, contact us to discuss your setup. For related reading, explore Terraform workspaces and environments and the Notary Kathmandu deployment pipeline as examples of disciplined release workflows.

Frequently Asked Questions

It uses AWS Organizations and IAM role assumption so one Terraform codebase deploys to separate accounts. Remote state lives in a shared-services account, with pinned providers and modules or Terragrunt for DRY account values.

AWS recommends separate accounts for security, billing isolation, and workload separation. Terraform keeps VPCs, RDS, and IAM consistent across them instead of console snowflakes. On projects where I handle both application code and infrastructure, splitting production and non-production stops a misconfigured dev security group from exposing prod databases. Service Control Policies add organization-level guardrails Terraform alone cannot enforce. A practical map uses a management account for Organizations and SCPs, a security account for CloudTrail and GuardDuty, shared services for state and CI, and workload accounts per environment or product line.

Start in the management account and enable Organizations with feature-set ALL. Create OUs, then invite or create member accounts using the CLI or bootstrap Terraform. Use aws organizations create-account with a unique email, account name, and role-name OrganizationAccountAccessRole, which AWS creates automatically in each new account. Never run application Terraform from the management account root user. Apply Service Control Policies at the OU level before workloads land — common baselines deny root access keys, restrict regions to ap-south-1 and us-east-1, and block public S3 ACLs. Retrofitting SCPs after resources exist is painful.

Use the HashiCorp AWS provider with assume_role blocks and provider aliases. Pin Terraform to at least 1.9.0 and the AWS provider to roughly 5.70 — provider drift breaks multi-account pipelines silently. Define a default provider for shared services and aliased providers for dev and prod, each with its own role_arn and session_name. Pass aliases into modules explicitly with providers = { aws = aws.dev }. Forgetting provider passthrough in child modules silently deploys to the default account. The auth chain is CI assuming a role in shared services, then Terraform assuming per-account execution roles.

Never store state in the same account it manages when you can avoid it. Host S3 buckets and DynamoDB lock tables in a dedicated shared-services account — state files contain secrets, so treat the bucket like a database backup. Enable versioning, SSE-KMS encryption, and block all public access. Use one bucket per environment or strict prefix isolation, with bucket policies restricting each workload role to its own prefix. Split state by blast radius: separate files for VPC, cluster, and application stacks. Terragrunt keeps backend configuration DRY across dozens of stacks. Enable S3 access logging to the audit account.

Three patterns cover most teams. Monorepo plus provider aliases suits small teams sharing modules but needs careful CI filtering as plan scope grows. Repo per account gives strong isolation for different teams but adds module versioning overhead. Hub plus Terragrunt — shared modules with account and environment folders — scales past ten accounts while keeping backends DRY, at the cost of a Terragrunt learning curve. Extract reusable VPC, ECS, and RDS patterns into versioned modules, pass account-specific CIDR blocks and instance sizes as variables, and pin module refs to tags rather than main. Start simple; split repos when plan times or team boundaries demand it.

Hard-coded account IDs inside shared modules force forks — pass role_arn and account values through tfvars or Terragrunt instead. Missing provider passthrough in nested modules causes silent deploys to the wrong account; declare configuration_aliases and map providers explicitly, and run terraform validate in CI. Storing state in a workload account means a compromised prod IAM role can read and rewrite state — centralise in shared services with cross-account bucket policies. Putting database passwords and API keys in tfvars leaves plaintext secrets in state; reference AWS Secrets Manager or SSM Parameter Store via data sources instead. Before your first prod apply, test assume_role manually from the CI runner.

No. Workspaces isolate state within one backend key, not AWS accounts. Use separate state keys or root modules with provider aliases for cross-account boundaries.

No. Control Tower accelerates landing-zone setup with pre-built OUs and guardrails. You can create Organizations, accounts, and IAM roles manually or with bootstrap Terraform instead.

A management account owns Organizations, billing consolidation, and SCPs only — no application workloads. A security or audit account aggregates CloudTrail, hosts GuardDuty admin, and runs read-only compliance tooling. Shared services holds Terraform state buckets, ECR repos, CI runners, and centralised DNS if needed. Workload accounts hold one or more environments or product lines. For a Laravel application on EC2 — similar to deployments I maintain with Deployer 7 and GitLab CI — dev runs in one account and prod in another. Application code stays identical; only Terraform variables and account IDs change per environment.

Each workload account needs a TerraformExecutionRole trusted by the shared-services CI role. Keep trust policies narrow: one principal ARN and an optional external ID for third-party runners. Attach least-privilege policies per account — dev roles might manage EC2 and RDS, while prod roles should require manual CI approval before terraform apply. This mirrors how I gate production Deployer releases behind tagged pipelines on sister sites sharing the same GitLab CI workflow. During bootstrap, the execution role assumes OrganizationAccountAccessRole, then switches to the tighter custom role afterward. Validate JSON policies with a formatter and run a manual STS assume_role test before debugging permissions in pipelines.

Run terraform plan on every merge request and gate prod applies behind manual approval and branch protection. Authenticate via OIDC federation from GitLab or GitHub Actions to assume the shared-services role — avoid long-lived access keys in CI variables. Structure pipelines so a dev module change does not trigger prod plans; use path-based rules or Terragrunt run-all with include filters. Schedule read-only plans nightly for drift detection. Scan Terraform with Checkov before merge, especially to validate cross-account state bucket prefix restrictions. On a multi-environment booking platform, this model prevents a developer from accidentally applying staging RDS sizing to production because code review sees plan output before anyone approves.

Extract VPC, ECS, and RDS patterns into versioned modules in a shared repository and pass account-specific CIDR blocks, instance sizes, and environment names as variables. Tag every resource with Environment, ManagedBy = terraform, and AccountId for cost allocation. Pin module refs to release tags like v2.4.0, not main — a breaking VPC module change should not auto-deploy to prod on the next plan. Pass providers explicitly into each module invocation. Hard-coded account IDs belong in tfvars or Terragrunt config, not inside shared modules. The same module source works in every account when role_arn and account identifiers arrive as variables rather than being embedded in module code.

AWS Organizations itself has no per-account fee beyond normal resource usage. Costs come from workloads in each account plus shared-services resources like S3 state storage, DynamoDB locks, and CI runners — typically modest compared to production compute.

Database passwords and API keys placed in tfvars files end up as plaintext inside Terraform state, which lives in a shared-services S3 bucket any compromised role could read. Reference values from AWS Secrets Manager or SSM Parameter Store using data sources instead of embedding them in variable files. This applies directly across dev, staging, and prod accounts where the same module might provision RDS or application secrets. Pair this with KMS encryption on the state bucket and prefix-scoped IAM so only the correct account role can read its own state prefix. Rotation patterns from multi-cloud secrets management guides transfer cleanly to this AWS-only setup.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: