Skip to content
Infrastructure as Code iac environments 5 min read

Multi-Environment Setup

Almost every real system needs more than one environment: a dev you can break freely, a staging that mirrors production, and a prod you guard carefully. The goal is to keep these environments identical in shape but isolated in blast radius — they should run the same infrastructure code but never share state, credentials, or the ability to touch each other. The cleanest way to achieve this in Terraform is a layout of shared modules wrapped by thin per-environment root configurations, each with its own backend and its own .tfvars. This page walks through that layout, how to isolate state, and how to promote a change from dev to prod without an accident.

Why shared modules plus per-env roots

The two extremes both fail. A single configuration with count/if conditionals on the environment name quickly becomes unreadable and couples every environment together. Fully copy-pasted directories drift apart silently. The middle path keeps the logic in one reusable module and the configuration in tiny per-environment roots that only set inputs and a backend.

ApproachState isolationDrift riskReadability
CLI workspacesSame backend, separate keysLowConditionals creep in
Shared module + per-env rootsSeparate backend per envLowHigh
Copy-pasted directoriesSeparate backend per envHighDegrades over time

The shared-module approach gives true backend isolation while keeping the code DRY. It works identically in OpenTofu — swap terraform for tofu throughout.

A worked directory layout

Put all the actual resource definitions in a module under modules/. Each environment gets a directory under environments/ containing only a backend, a provider, a call to the module, and a .tfvars file.

.
├── modules/
│   └── service/
│       ├── main.tf        # all resources live here
│       ├── variables.tf
│       └── outputs.tf
└── environments/
    ├── dev/
    │   ├── main.tf        # calls ../../modules/service
    │   ├── backend.tf
    │   └── dev.tfvars
    ├── staging/
    │   ├── main.tf
    │   ├── backend.tf
    │   └── staging.tfvars
    └── prod/
        ├── main.tf
        ├── backend.tf
        └── prod.tfvars

The shared module declares its inputs and the resources it builds:

# modules/service/variables.tf
variable "environment" {
  type = string
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

variable "min_size" {
  type    = number
  default = 1
}

# modules/service/main.tf
resource "aws_instance" "api" {
  ami           = "ami-0c7217cdde317cfec"
  instance_type = var.instance_type

  tags = {
    Name        = "api-${var.environment}"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

Each environment root is deliberately thin — it wires inputs to the module and nothing else:

# environments/prod/main.tf
provider "aws" {
  region = "us-east-1"
}

module "service" {
  source = "../../modules/service"

  environment   = var.environment
  instance_type = var.instance_type
  min_size      = var.min_size
}

variable "environment" { type = string }
variable "instance_type" { type = string }
variable "min_size" { type = number }

The differences between environments live entirely in the .tfvars:

# environments/prod/prod.tfvars
environment   = "prod"
instance_type = "m5.large"
min_size      = 3
# environments/dev/dev.tfvars
environment   = "dev"
instance_type = "t3.micro"
min_size      = 1

Isolating state per environment

The single most important guarantee is that each environment has its own state in its own backend. A mistake in one environment’s backend or credentials must not be able to corrupt another. Give every environment a distinct backend key — and ideally a distinct cloud account.

# environments/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "devcraftly-tfstate-prod"
    key            = "service/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks-prod"
    encrypt        = true
  }
}
# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "devcraftly-tfstate-dev"
    key            = "service/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks-dev"
    encrypt        = true
  }
}

Warning: Do not store dev, staging, and prod state in one bucket distinguished only by key. Separate buckets (and separate accounts) mean a leaked dev credential cannot read or overwrite prod state.

You operate on one environment at a time by cd-ing into its directory and passing its var file:

cd environments/dev
terraform init
terraform plan -var-file=dev.tfvars
terraform apply -var-file=dev.tfvars

Output:

Terraform will perform the following actions:

  # module.service.aws_instance.api will be created
  + resource "aws_instance" "api" {
      + ami           = "ami-0c7217cdde317cfec"
      + instance_type = "t3.micro"
      + tags          = {
          + "Environment" = "dev"
          + "Name"        = "api-dev"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Promoting changes safely

Promotion is the discipline of applying the same module change to environments in order: devstagingprod. Because all environments call the same module, a change merged into modules/service is automatically picked up everywhere — you just apply it environment by environment and only advance once the previous tier is green.

For stronger control, pin the module to a version so prod does not silently track the latest commit:

module "service" {
  source = "git::https://github.com/devcraftly/infra.git//modules/service?ref=v1.4.0"
  # prod can stay on v1.3.0 until v1.4.0 is proven in staging
}

The promotion gate is simply: review the plan, apply to the lower tier, verify, then bump the next tier’s ref. A wrapper script makes the order explicit:

for env in dev staging prod; do
  echo "==> planning $env"
  terraform -chdir="environments/$env" plan -var-file="$env.tfvars"
done

Avoiding prod accidents

Most production incidents come from running the right command in the wrong place. Defend against it structurally rather than relying on memory.

Tip: Add a prevent_destroy lifecycle block to critical prod resources so even a mistargeted terraform destroy fails loudly instead of deleting them.

resource "aws_db_instance" "main" {
  identifier     = "service-${var.environment}"
  instance_class = "db.r6g.large"
  engine         = "postgres"

  lifecycle {
    prevent_destroy = true
  }
}

Require prod applies to go through CI with a manual approval, never an engineer’s laptop, and assume a distinct IAM role per environment so dev credentials literally cannot reach prod.

Best Practices

  • Keep all resource logic in shared modules; let per-environment roots set only inputs and a backend.
  • Give every environment its own backend (separate bucket, lock table, and ideally cloud account) for true blast-radius isolation.
  • Drive every difference between environments through .tfvars, not conditionals on the environment name.
  • Pin module versions with a ref so prod advances deliberately rather than tracking the latest commit.
  • Promote in order — dev, then staging, then prod — and only advance after verifying the previous tier.
  • Protect critical prod resources with prevent_destroy and run prod applies through approved CI, not local machines.
  • Use a distinct IAM role per environment so lower-tier credentials cannot touch production.
Last updated June 14, 2026
Was this helpful?