Deploying Terraform to Multiple AWS Accounts with GitHub Actions

When you start working on new solution most likely you would have at least two separate AWS accounts one for testing (e.g. sandbox) and one for production. Managing infrastructure across multiple accounts can get messy and it is crucial to setup separate state backends, clear separation of environments and also secure authentication for you CI/CD pipelines.

Even though for very small projects you can still go with single account approach and then maybe use Terraform workspace for distinguishing between dev and prod resources however the best practice is to have multiple accounts because you achieve:

  • separation of concerns (isolation)
  • reduce blast radius (bug in dev will not be influence production)
  • cost tracking & governance (each account can have its own budget and policies)

In this blog post we will be deploying Terraform to multiple AWS accounts with GitHub actions and we’ll walk through a production-ready setup where Terraform is deployed via GH Actions and ODIC with environment specific configuration.

Bootstraping

The /bootstrap directory contains the foundation which is the Terraform code that prepares each AWS account to host Terraform-managed infrastructure. It is deployed manually from your local machine, once per account.

What Bootstrap Does
  1. Terraform Backend Resources
    • Creates the S3 bucket that stores Terraform state files.
    • Creates the DynamoDB table used for state locking to avoid race conditions when multiple runs happen.
    • Optionally, you can pre-create the S3 bucket manually in AWS before running bootstrap.
  2. GitHub Actions Role
    • Provisions an IAM OIDC provider for GitHub.
    • Creates an IAM role that GitHub Actions can assume when running Terraform, with policies granting permissions for deployments.
    • This allows secure, short-lived authentication without storing AWS keys in GitHub.
  3. Supporting Files
    • provider.tf → configures the AWS provider.
    • variables.tf and locals.tf → define inputs like environment name and bucket names.
    • s3.tf and dynamodb.tf → define backend resources.
    • gh_actions_role.tf → defines the OIDC provider and IAM role for GitHub.
Environment-Specific Deployment

Additionally you can create env.backend.config files (e.g. dev.backend.config and prod.backend.config) to point bootstrap state into correct AWS account and S3 bucket.

This makes it easy to deploy the same bootstrap stack into multiple accounts (sandbox, qa, prod) without changing code, just by running:

terraform init -backend-config=stages/dev/dev.backend.config
terraform apply -var="env=dev"

where file look like this:

bucket         = "my-bootstrap-tfstate-bucket-prod"
key            = "bootstrap/terraform.tfstate"
region         = "eu-central-1"
dynamodb_table = "my-bootstrap-lock-table-prod"
encrypt        = true

The /boostrap structure could look like this:

bootstrap/
├── .terraform/                # Local Terraform directory (can be removed)
├── stages/                    # Environment-specific backend configs
│   ├── dev/
│   │   └── dev.backend.config
│   └── prod/
│       └── prod.backend.config
├── dynamodb.tf                # DynamoDB table for state locking
├── gh_actions_role.tf         # OIDC provider + IAM role for GitHub Actions
├── locals.tf                  # Local values (naming, conventions)
├── provider.tf                # AWS provider + backend definition
├── s3.tf                      # S3 bucket for Terraform state
└── variables.tf               # Input variables

To summarize the steps:

  • Manually create an S3 bucket in each AWS account to hold the bootstrap state file.
  • Create env.backend.config file to point to that S3 bucket.
  • Initialize Terraform with the backend config: terraform init -backend-config=stages/env/env.backend.config.
  • (Optional) Migrate existing local state to S3 using -migrate-state.
  • Apply bootstrap resources with: terraform apply -var=”env=dev”
IAM Role & OIDC trust policy

Running Terraform from GitHub Actions requires AWS access. Instead of storing long-lived access keys which is risky, you can use OIDC. With OIDC, GitHub issues short-lived tokens that AWS trusts, so your workflows assume a role securely without secrets.

How It Works
  • OIDC provider → tells AWS to trust GitHub (https://token.actions.githubusercontent.com).
  • IAM role with conditions → allows only your repo/branches to assume the role.
  • Policy attachment → defines what Terraform can do once inside AWS.

Like your S3 state bucket and DynamoDB lock table, this role is foundational. You should deploy it once per account, and then all pipelines can use it. Keeping it in bootstrap/ makes it clear it’s a prerequisite, not regular infrastructure.

data "tls_certificate" "github" {
  url = "https://token.actions.githubusercontent.com"
}

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}

resource "aws_iam_role" "github_tf_deploy" {
  name = "GithubTerraformDeploy"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      },
      Action = "sts:AssumeRoleWithWebIdentity",
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
        },
        StringLike = {
          "token.actions.githubusercontent.com:sub" : [
            "repo:my-org/my-repo:ref:refs/heads/dev",
            "repo:my-org/my-repo:ref:refs/heads/main",
            "repo:my-org/my-repo:pull_request"
          ]
        }
      }
    }]
  })
}

resource "aws_iam_policy" "tf_permissions" {
  name = "TfDeployPermissions"
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        "Effect" : "Allow",
        "Action" : "*",
        "Resource" : "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.github_tf_deploy.name
  policy_arn = aws_iam_policy.tf_permissions.arn
}
S3 + DynamoDB table for Terraform backend

An S3 bucket with versioning and encryption stores Terraform state, while a DynamoDB table provides state locking and consistency, together forming a reliable remote backend for Terraform.
For a deeper dive into how the S3 + DynamoDB backend works in Terraform, check out my earlier blog post where I break it down step by step.
Since DynamoDB locking mechanism is deprecated and soon will be removed for minor versions you can switch to S3 use_lockfile. Find more on this link.

Project Structure

The /bootstrap directory was already explain and /terraform pretty much is the main layer and contains the main AWS resources that out solution needs. Similar we can also have /stages directory for main Terraform resources since we will deploy them in different S3 buckets configured inside s3.tf file within /bootstrap.

terraform/
    bootstrap/
    ├── stages/                    
    │   ├── dev/
    │   │   └── dev.backend.config
    │   └── prod/
    │       └── prod.backend.config
    ├── dynamodb.tf               
    ├── gh_actions_role.tf        
    ├── locals.tf                 
    ├── provider.tf              
    ├── s3.tf                    
    └── variables.tf             
 ├──stages/     
 ├── dev/
 │     └── dev.backend.config
 └── prod/
       └── prod.backend.config
  api_gateway.tf
  variables.tf
  cognito.tf
  sqs.tf
  rds.tf
  outputs.tf

GitHub Actions Workflow

We need a CI/CD pipeline that securely runs Terraform against multiple AWS accounts. The pipeline should do terraform plan on pull requests and terraform apply only after merges. It must detect whether we’re targeting the sandbox (dev) or production account, assume the right IAM role via OIDC, and use the correct backend and variable files for that environment.

In practice that means if you open PR from feature branch to dev branch it would output terraform plan and just when you actually click “merge” it would apply changes on sandbox AWS account.

To decide which environment Terraform should target based on branch we use:

echo "TF_ENV=$([ '${{ github.base_ref }}' = 'main' ] && echo 'prod' || echo '${{ github.base_ref }}')" >> $GITHUB_ENV

If the PR is against main branch it sets TF_ENV=prod otherwise it sets to dev. This way, later on we can configure that all steps (terraform init, plan or apply) automatically use right backend and var files without hardcoding in workflow.

After we establish what AWS account we are going to deploy to we can configure the IAM Role which pipeline should assume (IAM Role we deployed via bootstrap TF configuration).

if [ "${TF_ENV}" = "prod" ]; then
            echo "AWS_ROLE_ARN=arn:aws:iam::${{ secrets.PROD_ACCOUNT_ID }}:role/GithubTerraformDeploy" >> $GITHUB_ENV

Full CI/CD pipeline:

name: Terraform Deploy

on:
  pull_request:
    branches: [dev, main]
  push:
    branches: [dev, main]

permissions:
  id-token: write
  contents: read 

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set env name
        run: echo "TF_ENV=$([ '${{ github.base_ref }}' = 'main' ] && echo 'prod' || echo '${{ github.base_ref }}')" >> $GITHUB_ENV

      - name: Set AWS role ARN
        run: |
          if [ "${TF_ENV}" = "prod" ]; then
            echo "AWS_ROLE_ARN=arn:aws:iam::${{ secrets.PROD_ACCOUNT_ID }}:role/GithubTerraformDeploy" >> $GITHUB_ENV
          else
            echo "AWS_ROLE_ARN=arn:aws:iam::${{ secrets.SANDBOX_ACCOUNT_ID }}:role/GithubTerraformDeploy" >> $GITHUB_ENV
          fi

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8

      - name: Terraform init
        working-directory: terraform
        run: terraform init -input=false -reconfigure -backend-config="stages/${{ env.TF_ENV }}/${{ env.TF_ENV }}.backend.config"

      - name: Terraform plan
        working-directory: terraform
        run: terraform plan -input=false -no-color \
          -var="env=${{ env.TF_ENV }}" \
          -var-file="stages/${{ env.TF_ENV }}/${{ env.TF_ENV }}.tfvars"

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set env name
        run: echo "TF_ENV=$([ '${{ github.ref_name }}' = 'main' ] && echo 'prod' || echo '${{ github.ref_name }}')" >> $GITHUB_ENV

      - name: Set AWS role ARN
        run: |
          if [ "${TF_ENV}" = "prod" ]; then
            echo "AWS_ROLE_ARN=arn:aws:iam::${{ secrets.PROD_ACCOUNT_ID }}:role/GithubTerraformDeploy" >> $GITHUB_ENV
          else
            echo "AWS_ROLE_ARN=arn:aws:iam::${{ secrets.SANDBOX_ACCOUNT_ID }}:role/GithubTerraformDeploy" >> $GITHUB_ENV
          fi

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8

      - name: Terraform init
        working-directory: terraform
        run: terraform init -input=false -reconfigure -backend-config="stages/${{ env.TF_ENV }}/${{ env.TF_ENV }}.backend.config"

      - name: Terraform apply
        working-directory: terraform
        run: terraform apply -input=false -auto-approve

Conclusion

Multi-Account Terraform CI/CD setup provides a secure, automated, and scalable way to manage infrastructure across environments. By combining bootstrapped backend resources, GitHub OIDC authentication, and a well-structured pipeline, you can eliminate manual steps, reduce the risk of misconfiguration, and ensure consistent deployments from development to production. This approach not only strengthens security but also improves collaboration and accelerates delivery of cloud infrastructure changes.

If this blog saved you time, support me with a coffee!

Thanks to everyone who’s supported!