Skip to content

Terraform Patterns for Team Collaboration

Workspaces, remote state, module registries and code review workflows — the patterns that keep Terraform manageable when a whole team touches infrastructure.

3 min read
Terraform workspace structure with multiple environment configurations

Terraform works well for a solo developer managing a small project. When a team of engineers modifies infrastructure concurrently, everything that worked for one person becomes a source of conflicts: state file corruption, divergent environments, and undocumented resource changes. These patterns address the collaboration challenges that surface when infrastructure becomes a team responsibility.

State Isolation

The most common source of Terraform team disasters is shared state without proper isolation. One engineer runs terraform apply in production while another is testing changes — and both modify the same state file.

hclhcl
# ❌ Single state file for all environments
terraform {
  backend "s3" {
    bucket = "terraform-state"
    key    = "terraform.tfstate"  # One file for everything
    region = "us-east-1"
  }
}
 
# ✅ Separate state per environment
# infrastructure/environments/production/backend.tf
terraform {
  backend "s3" {
    bucket         = "terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}
 
# infrastructure/environments/staging/backend.tf
terraform {
  backend "s3" {
    bucket         = "terraform-state"
    key            = "staging/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

DynamoDB locking prevents concurrent applies. If someone is already modifying production state, the second engineer's terraform apply will wait or fail with a lock error — instead of silently corrupting state.

Module Versioning

Shared modules without versioning create a dangerous coupling: updating a module for one service can break every service that uses it.

hclhcl
# ❌ Referencing modules by local path — changes affect everyone immediately
module "ecs_service" {
  source = "../../modules/ecs-service"
  # If someone modifies this module, every consumer changes on next apply
}
 
# ✅ Versioned module references — consumers upgrade explicitly
module "ecs_service" {
  source  = "app.terraform.io/mycompany/ecs-service/aws"
  version = "~> 2.1.0"  # Accept 2.1.x patches, pin major.minor
 
  service_name   = "api"
  container_port = 3000
  desired_count  = 3
}

When teams publish modules to a registry (Terraform Cloud, Artifactory, or even Git tags), consumers control when they upgrade. A module update goes through its own PR, test, and release cycle before any infrastructure uses it.

Variable Hierarchy

Teams need a consistent pattern for managing variables across environments without duplicating configuration.

hclhcl
# modules/ecs-service/variables.tf — module defines its interface
variable "service_name" {
  description = "Name of the ECS service"
  type        = string
}
 
variable "desired_count" {
  description = "Number of task replicas"
  type        = number
  default     = 2
}
 
variable "cpu" {
  description = "CPU units for the task (1024 = 1 vCPU)"
  type        = number
  default     = 256
}
 
variable "memory" {
  description = "Memory in MB for the task"
  type        = number
  default     = 512
}
hclhcl
# environments/production/terraform.tfvars
desired_count = 5
cpu           = 1024
memory        = 2048
 
# environments/staging/terraform.tfvars
desired_count = 2
cpu           = 256
memory        = 512

Each environment directory contains its own terraform.tfvars file. The module's default values serve as sensible baselines that environments can override.

Code Review Workflow

Every infrastructure change should produce a visible plan that reviewers can evaluate before merging.

ymlyaml
# .github/workflows/terraform-pr.yml
name: Terraform Plan
on:
  pull_request:
    paths: ["infrastructure/**"]
 
jobs:
  plan:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [staging, production]
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
 
      - name: Init
        run: terraform init
        working-directory: infrastructure/environments/${{ matrix.environment }}
 
      - name: Validate
        run: terraform validate
        working-directory: infrastructure/environments/${{ matrix.environment }}
 
      - name: Plan
        id: plan
        run: |
          terraform plan -no-color -input=false \
            -out=${{ matrix.environment }}.tfplan 2>&1 | tee plan_output.txt
        working-directory: infrastructure/environments/${{ matrix.environment }}

The plan output posted to the PR shows exactly which resources will be created, modified, or destroyed. Reviewers can catch dangerous changes (like database deletions) before they reach production.

Naming Conventions

Consistent naming prevents ambiguity about what Terraform manages and what was created manually.

hclhcl
# ❌ Arbitrary names — impossible to trace back to Terraform
resource "aws_security_group" "sg1" {
  name = "my-sg"
}
 
# ✅ Structured naming with standard tags
locals {
  name_prefix = "${var.project}-${var.environment}"
}
 
resource "aws_security_group" "api" {
  name        = "${local.name_prefix}-api-sg"
  description = "Security group for API servers"
  vpc_id      = var.vpc_id
 
  tags = {
    Name        = "${local.name_prefix}-api-sg"
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = "ecs-service"
  }
}

The ManagedBy = "terraform" tag makes it immediately clear in the console which resources are IaC-managed and which were created manually.

Import and Adopt Existing Resources

Teams rarely start with a clean slate. Existing manually-created resources need to be imported into Terraform management without recreation.

shbash
# Import an existing RDS instance into Terraform state
terraform import aws_db_instance.main myapp-production-db
 
# After import, write the matching configuration
# terraform plan should show no changes if config matches reality

Key Takeaways

  1. Isolate state per environment — separate state files with DynamoDB locking prevent concurrent modification disasters
  2. Version your modules — consumers should control when they adopt module changes
  3. Use tfvars per environment — same modules, different parameters, consistent structure
  4. Plan in CI on every PR — make infrastructure changes visible and reviewable before apply
  5. Standardize naming and tagging — ManagedBy = "terraform" instantly identifies IaC resources
  6. Import before recreating — bring existing resources under Terraform management safely
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX