Skip to content
Infrastructure as Code projects 5 min read

Project: Multi-Environment Pipeline

Real infrastructure is never a single environment—you need a low-risk dev to iterate in, a staging that mirrors production for verification, and a tightly controlled prod. The hard part is not the resources but the workflow: keeping each environment’s state isolated, sharing the same module code across all three, and gating changes behind review and approval. In this project you will compose a shared module, wire three per-environment configurations to isolated S3 remote backends, and drive everything through a GitHub Actions pipeline that plans on every pull request and applies on merge—with a manual approval gate before prod. Everything here runs identically on Terraform 1.5+ and OpenTofu.

Layout and the one-module philosophy

The cardinal rule of multi-environment Terraform is one body of resource code, many configurations. The resource definitions live once, in a shared module; each environment is a thin root that calls the module with different inputs and points at its own backend. This eliminates the copy-paste drift that plagues teams who fork their HCL per environment.

infra/
├── modules/
│   └── platform/          # shared resources, defined once
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── envs/
    ├── dev/
    │   ├── main.tf        # calls module.platform
    │   ├── backend.tf     # isolated state key
    │   └── terraform.tfvars
    ├── staging/
    └── prod/

The shared module

The module captures everything common—here a VPC and an autoscaling app fleet—parameterized by the few things that differ between environments: instance size, fleet count, and a name prefix used for tagging and naming.

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

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

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

variable "max_size" {
  type    = number
  default = 2
}
# modules/platform/main.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_launch_template" "app" {
  name_prefix   = "${var.environment}-app-"
  image_id      = data.aws_ami.al2023.id
  instance_type = var.instance_type
}

resource "aws_autoscaling_group" "app" {
  name             = "${var.environment}-asg"
  min_size         = var.min_size
  max_size         = var.max_size
  desired_capacity = var.min_size

  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }
}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

Per-environment roots and isolated state

Each environment gets its own state file so a bad apply in dev can never corrupt prod. With an S3 backend, isolation is just a distinct key per environment (a separate bucket per account is stronger still). DynamoDB provides state locking so two concurrent CI runs can’t clobber each other.

# envs/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "acme-tf-state"
    key            = "prod/platform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-tf-locks"
    encrypt        = true
  }
}
# envs/prod/main.tf
provider "aws" {
  region = "us-east-1"
}

module "platform" {
  source        = "../../modules/platform"
  environment   = "prod"
  instance_type = "m6i.large"
  min_size      = 3
  max_size      = 12
}

The dev root is identical except for its backend key and modest sizing (t3.micro, min_size = 1). The shared module guarantees the shape of the infrastructure is identical across environments while the inputs scale it appropriately.

EnvironmentState keyinstance_typemin_sizeApply trigger
devdev/platform.tfstatet3.micro1merge to main
stagingstaging/platform.tfstatet3.small2merge to main
prodprod/platform.tfstatem6i.large3merge + manual approval

Never share one state file across environments with workspaces if the environments live in different AWS accounts. Distinct backend keys (or buckets) make blast radius explicit and let you grant CI scoped IAM per environment.

The CI/CD pipeline

The workflow has two phases: plan on pull request (so reviewers see exactly what will change before merging) and apply on merge to main. A matrix runs all three environments, and prod is bound to a GitHub Environment with a required reviewer, which pauses the job until a human approves.

# .github/workflows/terraform.yml
name: terraform
on:
  pull_request:
  push:
    branches: [main]

permissions:
  id-token: write   # for OIDC role assumption
  contents: read
  pull-requests: write

jobs:
  terraform:
    strategy:
      matrix:
        env: [dev, staging, prod]
    runs-on: ubuntu-latest
    environment: ${{ matrix.env }}   # gates prod via required reviewers
    defaults:
      run:
        working-directory: envs/${{ matrix.env }}
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/tf-${{ matrix.env }}
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.5"

      - run: terraform init
      - run: terraform plan -out=tfplan -input=false

      - name: Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false tfplan

On a pull request only init and plan run, producing a reviewable diff. After merge, the Apply step fires; because the prod job declares environment: prod, GitHub holds it until a reviewer clicks approve.

Output:

# pull request — plan only
Terraform will perform the following actions:

  # module.platform.aws_autoscaling_group.app will be updated in-place
  ~ resource "aws_autoscaling_group" "app" {
      ~ max_size = 8 -> 12
    }

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

# after merge — prod job
Waiting for review: 1 required reviewer must approve deployment to "prod"
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Authentication uses OIDC (aws-actions/configure-aws-credentials with role-to-assume) so no long-lived AWS keys live in GitHub secrets—each environment job assumes a scoped IAM role keyed to ${{ matrix.env }}.

Best Practices

  • Define resources once in a shared module and vary only inputs per environment—this is the single most effective defense against environment drift.
  • Give every environment an isolated backend key (ideally a separate AWS account) plus DynamoDB locking so concurrent CI runs serialize safely.
  • Plan on pull requests and apply on merge so every change is reviewed as a concrete diff before it touches infrastructure.
  • Gate prod behind a GitHub Environment with required reviewers; let dev and staging apply automatically.
  • Authenticate CI with OIDC role assumption, not static access keys, and scope each role to one environment’s resources.
  • Pass the saved plan file (terraform plan -out) into apply so what you reviewed is exactly what gets applied.
Last updated June 14, 2026
Was this helpful?