Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 017 / 100

Managing multiple environments: best practices explained

dev, staging and prod with one codebase.

Expona Admin10 min read

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:

hcl
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.tf

The 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:

hcl
# 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:

hcl
# 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:

hcl
# 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:

hcl
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:

hcl
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:

hcl
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:

hcl
# 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:

hcl
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):

hcl
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:

hcl
# .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 under environments/.
  • Use terraform.tfvars files 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles