Introduction
As your infrastructure grows from a single dev environment to handling staging and production workloads, the question of how to manage multiple environments with Terraform becomes critical. Managing multiple environments: best practices explained is not just about duplication avoidance—it's about consistency, safety, and the ability to promote changes confidently through your pipeline. We'll walk through a battle-tested approach that uses shared modules, environment-specific root configurations, and proper separation of concerns to keep your codebase clean and your deployments predictable.
The single codebase, multiple environments strategy
The fundamental principle is clear: one Terraform codebase, one source of truth, but completely separate state and configuration per environment. This prevents configuration drift, makes it easier to spot differences, and ensures that changes validated in dev or staging can be safely promoted to production.
At a high level, your directory structure should look like this:
terraform/
├── modules/
│ ├── vpc/
│ ├── rds/
│ ├── ecs/
│ └── networking/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── shared/
├── versions.tf
└── outputs.tfThe modules/ directory contains reusable building blocks. Each environment in environments/ is a separate Terraform "project" (root module) that calls the shared modules with environment-specific parameters. This separation ensures that a module update doesn't accidentally break one environment while fixing another.
Shared modules as the foundation
Modules are the linchpin of managing multiple environments efficiently. A well-designed module accepts variables that let you customize behavior without forcing you to maintain separate module code per environment.
Consider a VPC module that both dev and prod need:
# modules/vpc/main.tf
variable "cidr_block" {
type = string
description = "CIDR block for the VPC"
}
variable "enable_nat_gateway" {
type = bool
default = false
description = "Enable NAT gateway for private subnets"
}
variable "environment" {
type = string
description = "Environment name"
}
variable "az_count" {
type = number
default = 2
description = "Number of availability zones"
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 2, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.environment}-private-subnet-${count.index + 1}"
}
}
resource "aws_nat_gateway" "main" {
count = var.enable_nat_gateway ? var.az_count : 0
subnet_id = aws_subnet.private[count.index].id
allocation_id = aws_eip.nat[count.index].id
tags = {
Name = "${var.environment}-nat-${count.index + 1}"
}
}The module is environment-agnostic. Dev might use a smaller CIDR block and skip NAT gateways for cost savings, while prod enables them. Both call the same module code, reducing maintenance burden and ensuring consistency in how resources are built.
Per-environment root configurations
Each environment directory is a separate Terraform root module that orchestrates the shared modules. This is where environment-specific decisions live.
In environments/dev/main.tf:
# environments/dev/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
enable_nat_gateway = false
environment = "dev"
az_count = 2
}
module "rds" {
source = "../../modules/rds"
database_name = "devdb"
instance_class = var.rds_instance_class
allocated_storage = 20
skip_final_snapshot = true
environment = "dev"
vpc_id = module.vpc.vpc_id
}
output "vpc_id" {
value = module.vpc.vpc_id
}
output "rds_endpoint" {
value = module.rds.endpoint
}And in environments/prod/main.tf, you'd call the same modules but with production-grade parameters:
# environments/prod/main.tf
# Same provider and terraform block...
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
enable_nat_gateway = true
environment = "prod"
az_count = 3
}
module "rds" {
source = "../../modules/rds"
database_name = "proddb"
instance_class = var.rds_instance_class
allocated_storage = 200
skip_final_snapshot = false
backup_retention_period = 30
multi_az = true
environment = "prod"
vpc_id = module.vpc.vpc_id
}This structure makes differences explicit and traceable. You can code-review changes to prod's configuration separately from dev's, and the blast radius of a mistake is limited to a single environment.
Managing configuration with terraform.tfvars
Each environment should have its own terraform.tfvars file, defining the variable values specific to that environment. Never hardcode values in your module code.
environments/dev/terraform.tfvars:
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
rds_instance_class = "db.t3.micro"
app_instance_type = "t3.micro"
desired_capacity = 1
environments/staging/terraform.tfvars:
aws_region = "us-east-1"
vpc_cidr = "10.1.0.0/16"
rds_instance_class = "db.t3.small"
app_instance_type = "t3.small"
desired_capacity = 2
environments/prod/terraform.tfvars:
aws_region = "us-east-1"
vpc_cidr = "10.2.0.0/16"
rds_instance_class = "db.r5.xlarge"
app_instance_type = "c5.2xlarge"
desired_capacity = 5
The variables.tf file in each environment root defines the schema:
# environments/dev/variables.tf
variable "aws_region" {
type = string
}
variable "vpc_cidr" {
type = string
}
variable "rds_instance_class" {
type = string
}
variable "app_instance_type" {
type = string
}
variable "desired_capacity" {
type = number
}This approach ensures that all environment-specific values are version-controlled, auditable, and easy to compare. You can diff the three tfvars files side by side and immediately see why prod is sized larger than dev.
Separating prod into its own AWS account
A critical best practice is to run production in a separate AWS account from dev and staging. This provides a hard security boundary and prevents a mistake in dev from accidentally affecting prod infrastructure.
To support this, your backend configuration must be account-aware. The backend stores your Terraform state, and each environment should have its own isolated state bucket in its respective account.
environments/dev/backend.tf:
terraform {
backend "s3" {
bucket = "my-org-terraform-state-dev"
key = "infrastructure.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-lock-dev"
}
}
environments/prod/backend.tf (in a different AWS account):
terraform {
backend "s3" {
bucket = "my-org-terraform-state-prod"
key = "infrastructure.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-lock-prod"
}
}
The prod S3 bucket and DynamoDB table would be created in the production AWS account with restricted access. Your CI/CD pipeline would use different IAM roles for each account, ensuring that even if dev credentials are compromised, prod remains protected.
In practice, your pipeline would look like this: assume the dev role, run Terraform for dev; then assume the prod role (which may require additional approval), and run Terraform for prod. This separation is non-negotiable for production systems.
Unified CI/CD pipeline for all environments
Despite separate configurations, you want the same pipeline logic running for all environments. This means a single set of GitHub Actions or GitLab CI rules that orchestrates the deployment.
A typical approach uses a matrix strategy to run tests and plans for all environments, but only applies changes to prod after manual approval:
# .github/workflows/terraform.yml (simplified)
name: Terraform
on:
pull_request:
paths:
- 'terraform/**'
push:
branches:
- main
paths:
- 'terraform/**'
jobs:
plan:
strategy:
matrix:
environment: [dev, staging, prod]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- name: Terraform Init
run: terraform -chdir=terraform/environments/${{ matrix.environment }} init
- name: Terraform Plan
run: terraform -chdir=terraform/environments/${{ matrix.environment }} plan -out=tfplan
env:
AWS_ROLE_ARN: ${{ secrets[format('AWS_ROLE_{0}', matrix.environment)] }}
- name: Upload plan artifact
uses: actions/upload-artifact@v3
with:
name: tfplan-${{ matrix.environment }}
path: terraform/environments/${{ matrix.environment }}/tfplan
apply:
needs: plan
if: github.ref == 'refs/heads/main'
strategy:
matrix:
environment: [dev, staging]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- uses: actions/download-artifact@v3
with:
name: tfplan-${{ matrix.environment }}
path: terraform/environments/${{ matrix.environment }}
- name: Terraform Apply
run: terraform -chdir=terraform/environments/${{ matrix.environment }} apply -auto-approve tfplan
env:
AWS_ROLE_ARN: ${{ secrets[format('AWS_ROLE_{0}', matrix.environment)] }}
apply-prod:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: prod
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- uses: actions/download-artifact@v3
with:
name: tfplan-prod
path: terraform/environments/prod
- name: Terraform Apply (Prod)
run: terraform -chdir=terraform/environments/prod apply -auto-approve tfplan
env:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_PROD }}This pipeline creates a plan for all three environments on every pull request, allowing the team to see what would change. For dev and staging, apply happens automatically on merge to main. For prod, it requires an explicit approval via GitHub's environment protection rules, adding a critical gate before production changes land.
Common mistakes
Using a single state file for multiple environments: This is dangerous. If you corrupt or accidentally destroy the state, all environments go down. Always use separate backends per environment.
Hardcoding values in module code: If your module has instance_type = "t3.micro" baked in, you can't use it for prod. Pass everything as variables.
Storing secrets in tfvars files: Never commit database passwords or API keys to git, even in tfvars. Use AWS Secrets Manager, HashiCorp Vault, or your CI/CD secrets manager, and reference them in Terraform.
Forgetting to lock module versions: Always pin module versions in your root configurations. Without source = "../../modules/vpc?ref=v1.0" semantics (or for local modules, using version constraints in a shared registry), a module update can silently break an environment.
Not testing changes in dev first: The whole point of multiple environments is to validate in lower environments before prod. Skipping this defeats the purpose.
Key takeaways
- Organize your codebase with reusable modules in a
modules/directory and separate root configurations for each environment underenvironments/. - Use
terraform.tfvarsfiles to parameterize each environment's configuration without duplicating module code. - Run production in a separate AWS account with separate state buckets and DynamoDB locks to enforce a security boundary.
- Build a single CI/CD pipeline that plans all environments but requires explicit approval before applying changes to prod.
- Pin backend configurations per environment to ensure state isolation and prevent cross-environment accidents.
- Keep modules generic and environment-agnostic; let root configurations decide sizing, redundancy, and feature flags.






Comments
Loading comments…