AWS Builder Center
The Multi-Account Terraform Setup Nobody Talks About

The Multi-Account Terraform Setup Nobody Talks About

In this post, I talk about how to set up multi AWS account environments, and how to use Terraform to deploy to each environment using GitHub Actions

AWS Support Engineer
I kept seeing the same advice on LinkedIn: “want to learn DevOps? Build a project, deploy it to AWS, put it on your GitHub.” Good advice. Except every single one of those projects stopped at one environment, one AWS account.
Then I started working with real infrastructure, and that’s not how it works. Every client has multiple environments. And worse, I’ve seen setups where dev, stage, and prod were all crammed into the same AWS account, just separated by naming conventions on the resources. Same ECS cluster, different services tagged “dev” and “prod” sitting right next to each other.
That’s broken in a way that has nothing to do with best practices for the sake of it. If you give a junior engineer permissions to mess around in dev, you don’t want those same permissions to apply to prod. If a dev account gets compromised, that should be the end of the blast radius, not the start of a prod incident. Account-level separation isn’t a nice-to-have, it’s the actual security boundary.
So I built a small Terraform project that does it properly: one repo, one pipeline, two separate AWS accounts for dev and prod, deployed through GitHub Actions with manual approval before anything touches production. No copy-pasted modules, no duplicated workflows, just environment-specific variables and OIDC roles doing the heavy lifting.
This post walks through how it’s structured and how the pipeline works, so you can either use it as a reference or steal the pattern for your own setup.

Here’s the high-level picture before we get into the repo:

Project's diagram


One codebase, two accounts: how the repo actually works

Here’s the problem with a lot of “multi-environment” Terraform repos I’ve seen: people duplicate the whole  main.tf  per environment. A dev folder with its own resources, a prod folder with its own (slightly different, slowly drifting) copy of the same resources. Every change has to be made twice. Eventually someone forgets, and dev and prod quietly stop matching.
The fix is to never define the actual resources more than once. Resources live in modules. Environments only provide values.
So in this repo,  modules/EC2  and  modules/S3  define what gets created: an EC2 instance and an S3 bucket. They don't know or care if they're being deployed to dev or prod. That's the whole point.
Repo Structure

The root  variables.tf  declares what inputs the whole config needs:
variable "region" {
type = string
description = "The preferred region"
}

variable "env" {
type = string
description = "dev, prod"
}

variable "instance_type" {
type = string
description = "The instance type for the EC2 instance"
}

variable "instance" {
type = string
description = "The name tag for the EC2 instance"
}

variable "ami" {
type = string
description = "The AMI ID for the EC2 instance"
}
Then each environment fills in its own values. Here’s dev’s  variables.tfvars :
bucket = "this-is-a-test-bucket"
env = "dev"
region = "us-east-1"
instance_type = "t3.micro"
instance = "dev-instance"
ami = "ami-0521cb2d60cfbb1a6"
Prod has its own  variables.tfvars  with the same keys, different values, bigger instance type, different bucket name, whatever prod actually needs.
And the root  main.tf  just wires it all together:
module "ec2" {
source = "./modules/EC2"
instance_type = var.instance_type
instance = var.instance
env = var.env
region = var.region
ami = var.ami

providers = {
aws = aws
}
}
No  if env == "prod"  logic, no duplicated resource blocks, no separate  prod-main.tf . The pipeline just tells Terraform which  .tfvars  file to load for that run, and the exact same code produces dev's infrastructure or prod's infrastructure depending on which file it read.

The pipeline — what actually happens when you push to main

Before setting up OIDC, you’ll need an OIDC role in each AWS account so the pipeline knows how to authenticate. I covered that setup in detail here:  Getting Started with Terraform and AWS — The Right Way 
Once that’s in place, here’s the flow end to end:

Every push to  main  triggers the whole thing. Dev always runs first, completely on its own. Only once dev has either applied successfully, or confirmed there's nothing to apply, does prod even start planning.
How “no changes” gets detected
The plan step runs with  -detailed-exitcode :
terraform plan \
-var-file="${{ vars.TF_VAR_FILE }}" \
-var="env=dev" \
-var="region=${{ vars.TF_REGION }}" \
-out=tfplan \
-detailed-exitcode
Terraform gives you three possible exit codes:  0  means no changes,  1  means an error,  2  means there are changes to apply. The next step checks that:
if [ "${{ steps.plan.outputs.exitcode }}" == "2" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
else
echo "has_changes=false" >> $GITHUB_OUTPUT
fi
That  has_changes  output is what every later job checks before deciding to run. No changes detected — approval and apply both get skipped, the pipeline just moves on. Changes detected — it waits for a human.
The approval gate
The approval step is dead simple on purpose:
dev-approval:
needs: dev-terraform-plan
if: needs.dev-terraform-plan.outputs.has_changes == 'true'
environment: approval
steps:
- run: echo "Approved for dev deploy"
All the actual logic is in the  if . The job itself does nothing except exist, but because it's tied to a GitHub environment called  approval  with required reviewers configured, GitHub won't let this job run until someone with access approves it. That single line  environment: approval  is doing all the work.

The plan twice, apply once pattern
The apply job doesn’t run  terraform apply  against a fresh plan, it downloads the exact plan artifact that was already reviewed:
- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan-dev
path: .
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
This matters more than it looks. Whoever approves the deploy is approving the exact plan that gets applied, not a new plan generated after the fact. No drift between what got reviewed and what got run.
The issue that took me a while to figure out
When dev has no infrastructure changes, the pipeline skips dev-apply entirely and jumps straight to prod’s plan. The problem: Terraform’s state lock from the dev plan step doesn’t always release fast enough before prod tries to read state from the same backend. The pipeline would just error out, seemingly at random, only when dev had nothing to do.
The fix was adding a force-unlock check at the start of prod’s plan job:
- name: Force unlock stale state lock
run: |
LOCK_ID=$(aws s3api get-object \
--bucket ${{ vars.TF_BACKEND_BUCKET }} \
--key ${{ vars.TF_BACKEND_KEY }}.tflock \
/tmp/lockfile.json 2>/dev/null && jq -r '.ID' /tmp/lockfile.json || echo "")
if [ -n "$LOCK_ID" ]; then
terraform force-unlock -force $LOCK_ID
fi
It checks if a stale lock file exists and clears it before doing anything else. If you’re sharing one backend bucket across environments, this is the kind of thing that’ll bite you and won’t be obvious why.
One backend, two state files
Both dev and prod state files live in the same S3 bucket, just under different keys, and for this demo, that bucket sits in the dev account. That’s fine for a demo, but if you’re doing this for real, put the backend bucket in a third, separate “shared” or “tooling” account, not in dev or prod. You don’t want your prod pipeline depending on a bucket that lives inside the same account a junior engineer has write access to.

Where all those variables actually live
All those  ${{ vars.TF_BACKEND_BUCKET }}  and  ${{ secrets.AWS_ROLE_ARN }}  references aren't hardcoded anywhere. They're stored in GitHub repo settings, under Environments. Each environment ( dev ,  prod ) has its own set of variables and secrets, scoped to that environment only. The OIDC role ARN is stored as a secret, region/bucket/var-file path as plain variables.
There’s also a third environment called  approval , with no variables in it at all. Its only job is holding the required reviewers setting, which is what actually gates the pipeline.

Here’s what it actually looks like running:

And here’s the part that actually matters, proof this isn’t two folders pretending to be separate, it’s two real AWS accounts:

Dev account, dev instance, running.

Prod account, Prod instance, running.
Same repo, same pipeline, completely different AWS account number. Same Terraform code produced both, the only thing that changed between them was which  tfvars  file got loaded.

Best practices (and a couple of things I learned the hard way)

  • Never duplicate resource definitions per environment. Resources go in modules, environments only supply values.
  • Account-level isolation, not naming-convention isolation. Blast radius and permissions should be enforced by the account boundary, not by hoping nobody crosses a naming convention.
  • Approve the plan you apply, not a fresh one. Generate it once, upload it as an artifact, apply that exact file after approval.
  • Put your Terraform backend in its own account. A compromised dev account shouldn’t also expose your prod state.
  • Conditional skips can break things you don’t expect. Test the “nothing to apply” path, not just the happy path.
  • Required reviewers for approval gates need a public repo or a paid GitHub plan. Free personal accounts with private repos won’t show the approval gate in action.
  • GitHub Environments are a free config store, not just a deploy gate. Scoped variables and secrets per environment is useful anywhere you need different config per target without branching your code.

Wrapping up

The whole point of this setup comes down to two things.
First,  variables.tfvars  per environment is all you need to run the same Terraform code against completely different infrastructure. No duplicated modules, no copy-pasted resource blocks that slowly drift apart, no  if env == "prod"  logic scattered through your config. One codebase, swap the values file, done.
Second, dev and prod should be in separate AWS accounts, not separate folders, not separately named resources, separate accounts. That’s where your actual security boundary lives. It’s where you control who can do what, it’s what contains the blast radius if something goes wrong, and it’s how you stop a bad day in dev from becoming a bad day in prod.
This isn’t an advanced pattern. It’s table stakes for how real infrastructure is run, and it’s genuinely not that much more work to set up than a single-account deployment. The repo is there if you want to use it as a starting point or just poke around at how the pieces fit together:
If you’re just getting started with Terraform and AWS and need to set up OIDC and a remote backend before any of this makes sense, I covered that here:
One last thing — everything in this post is based on my own experience working at Rebura, and this is the approach that clicked for me as the cleanest and most straightforward way to handle multi-account deployments. There are plenty of other ways to do this, and I’m not saying this is the only right answer. But from what I’ve seen in real environments, this pattern holds up well and doesn’t overcomplicate things. If you’ve got questions, a different approach you prefer, or just want to chat about it — feel free to reach out.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article