WRKSHP
Tutorial Feb 3, 2026 8 min read

Infrastructure as Code: Terraform Patterns for Growing Teams

Infrastructure as code: Your infrastructure grew faster than your practices. Learn how to organize Terraform for collaboration, implement safe workflows, and...

Jansen Fitch

Founder, WRKSHP.DEV

Infrastructure as Code: Terraform Patterns for Growing Teams

Infrastructure as code: Your infrastructure grew faster than your practices. Learn how to organize Terraform for collaboration, implement safe workflows, and... This guide explains Infrastructure as Code: Terraform Patterns for Growing Teams with practical patterns WRKSHP uses on client builds. Use the checklist below to prioritize next steps and avoid common mistakes that waste time and money.

Your infrastructure grew faster than your practices. Learn how to organize Terraform for collaboration, implement safe workflows, and avoid the common pitfalls that cause production incidents. This guide explains Infrastructure as Code: Terraform Patterns for Growing Teams with practical patterns WRKSHP uses on client builds.

Project Structure

How you organize Terraform files determines how easily teams can navigate and modify infrastructure.

The Monorepo Approach

All Terraform code in one repository, organized by environment and component:

infrastructure/
├── modules/                    # Reusable modules
│   ├── vpc/
│   ├── eks-cluster/
│   ├── rds-postgres/
│   └── cloudfront-distribution/
├── environments/
│   ├── production/
│   │   ├── networking/
│   │   │   ├── main.tf
│   │   │   ├── variables.tf
│   │   │   └── terraform.tfvars
│   │   ├── database/
│   │   ├── compute/
│   │   └── cdn/
│   ├── staging/
│   └── development/
└── global/                     # Resources shared across environments
    ├── iam/
    ├── dns/
    └── ecr/

Advantages:

  • Single source of truth
  • Easy to see all infrastructure
  • Shared modules without publishing
  • Atomic changes across components

Disadvantages:

  • Large repository
  • CI runs can be slow
  • Permissions are all-or-nothing

State Isolation

Never put all resources in one state file. Blast radius from a bad apply or corrupted state is catastrophic. Split by:

  • Environment (production, staging, development)
  • Component (networking, database, compute)
  • Team ownership
# Each component has its own state
environments/production/networking/backend.tf

terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/networking/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

Module Design

Good modules encapsulate complexity and enforce standards.

Module Interface Design

Design modules like APIs, stable interfaces that hide implementation details:

# modules/rds-postgres/variables.tf

variable "name" {
  description = "Name prefix for all resources"
  type        = string
}

variable "environment" {
  description = "Environment (production, staging, development)"
  type        = string
}

variable "instance_class" {
  description = "RDS instance class"
  type        = string
  default     = "db.t3.medium"
}

variable "allocated_storage" {
  description = "Allocated storage in GB"
  type        = number
  default     = 20
}

variable "multi_az" {
  description = "Enable Multi-AZ deployment"
  type        = bool
  default     = false
}

# Module sets sensible defaults for everything else
# Users don't need to know about parameter groups, option groups, etc.

Opinionated Defaults

Modules should encode your organization's standards:

# modules/rds-postgres/main.tf

locals {
  # Enforce naming convention
  resource_name = "${var.name}-${var.environment}"
  
  # Standard tags for all resources
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = "rds-postgres"
    Name        = local.resource_name
  }
  
  # Environment-specific defaults
  backup_retention = var.environment == "production" ? 30 : 7
  deletion_protection = var.environment == "production"
}

resource "aws_db_instance" "main" {
  identifier = local.resource_name
  
  # User-configurable
  instance_class    = var.instance_class
  allocated_storage = var.allocated_storage
  multi_az          = var.multi_az
  
  # Standardized settings
  engine                 = "postgres"
  engine_version         = "15.4"
  backup_retention_period = local.backup_retention
  deletion_protection    = local.deletion_protection
  storage_encrypted      = true
  
  tags = local.common_tags
}

Module Versioning

Version modules to prevent unexpected changes:

# Pin module versions in consuming code
module "database" {
  source  = "git::https://github.com/company/terraform-modules.git//rds-postgres?ref=v1.2.0"
  
  name        = "api"
  environment = "production"
}

# Or use a private registry
module "database" {
  source  = "app.terraform.io/company/rds-postgres/aws"
  version = "~> 1.2"
  
  name        = "api"
  environment = "production"
}

Safe Workflows

The Plan-Apply Workflow

Never apply without reviewing a plan:

# Generate plan
terraform plan -out=tfplan

# Review the plan
# (ideally in a PR, not just terminal output)

# Apply the reviewed plan
terraform apply tfplan

CI/CD Pipeline

Automate the workflow with required checks:

# .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    paths:
      - 'infrastructure/**'
  push:
    branches:
      - main
    paths:
      - 'infrastructure/**'

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/environments/production/compute
      
      - name: Terraform Format Check
        run: terraform fmt -check -recursive
        working-directory: infrastructure
      
      - name: Terraform Validate
        run: terraform validate
        working-directory: infrastructure/environments/production/compute
      
      - name: Terraform Plan
        run: terraform plan -no-color -out=tfplan
        working-directory: infrastructure/environments/production/compute
      
      - name: Post Plan to PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const plan = `...` // Parse plan output
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: plan
            })

  apply:
    runs-on: ubuntu-latest
    needs: plan
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Apply
        run: terraform apply -auto-approve tfplan
        working-directory: infrastructure/environments/production/compute

Preventing Disasters

Add guardrails to prevent common mistakes:

# Prevent accidental resource destruction
resource "aws_db_instance" "main" {
  # ...
  
  lifecycle {
    prevent_destroy = true
  }
}

# Require type changes instead of replace
resource "aws_instance" "main" {
  # ...
  
  lifecycle {
    # Force error if Terraform wants to destroy
    prevent_destroy = true
    
    # Ignore changes to certain attributes
    ignore_changes = [
      tags["LastUpdated"]
    ]
  }
}

State Management

Remote State

Never use local state for shared infrastructure:

# Backend configuration
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/compute/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"  # Prevents concurrent applies
    encrypt        = true
  }
}

State Locking

DynamoDB table for state locking:

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  
  attribute {
    name = "LockID"
    type = "S"
  }
}

Accessing Other State

Reference outputs from other state files:

# In compute configuration, reference networking outputs
data "terraform_remote_state" "networking" {
  backend = "s3"
  config = {
    bucket = "company-terraform-state"
    key    = "production/networking/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_ids[0]
  vpc_security_group_ids = [
    data.terraform_remote_state.networking.outputs.app_security_group_id
  ]
}

Testing Infrastructure

Validation

Use validation rules on variables:

variable "environment" {
  type = string
  
  validation {
    condition     = contains(["production", "staging", "development"], var.environment)
    error_message = "Environment must be production, staging, or development."
  }
}

variable "instance_class" {
  type = string
  
  validation {
    condition     = can(regex("^db\\.", var.instance_class))
    error_message = "Instance class must be a valid RDS instance type (e.g., db.t3.medium)."
  }
}

Terratest

Test modules with real infrastructure:

// test/rds_test.go
package test

import (
	"testing"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestRdsModule(t *testing.T) {
	t.Parallel()

	opts := &terraform.Options{
		TerraformDir: "../modules/rds-postgres",
		Vars: map[string]interface{}{
			"name":        "test",
			"environment": "development",
		},
	}

	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)

	// Verify outputs
	endpoint := terraform.Output(t, opts, "endpoint")
	assert.NotEmpty(t, endpoint)
}

Secrets Management

Never store secrets in Terraform state or variables:

# Bad: Secret in terraform.tfvars
# database_password = "supersecret"  # NO!

# Good: Reference from secrets manager
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "production/database/password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db_password.secret_string
}

Conclusion

Terraform at scale requires discipline. The patterns that help:

  • Isolate state by environment and component
  • Build opinionated modules that encode standards
  • Automate plan/apply with required reviews
  • Add guardrails against destructive changes
  • Never store secrets in state

Treat infrastructure code with the same rigor as application code. The cost of getting it wrong is higher, a bad application deploy is recoverable; a bad infrastructure change might not be.

Ready to implement these patterns? Explore enterprise software builds and Growth OS with WRKSHP, or start a conversation.

Work With WRKSHP

WRKSHP builds AI-native software, operated growth systems, and governance layers for teams that sell outcomes, not billable hours.

Explore enterprise software, Growth OS, GaaS governance, contact, or start a conversation about your next build.

Frequently Asked Questions

What is the main takeaway from "Infrastructure as Code: Terraform Patterns for Growing Teams"?

Infrastructure as code: Your infrastructure grew faster than your practices. Use it as a production playbook for operators and engineers, not slide-deck theory.

How does "Project Structure" fit into Infrastructure as Code: Terraform Patterns for Growing Teams?

Project Structure is a core section of this guide. Apply it after you pick one measurable KPI, then instrument the path that moves that KPI before expanding scope.

What should I watch when working through "Module Design"?

Treat "Module Design" as a decision checkpoint: name an owner, define success metrics, and refuse to automate steps that spend money or change production data without an audit trail.

When should I bring in a partner on Infrastructure as Code: Terraform Patterns for Growing Teams?

Bring in help when you need a fixed-outcome delivery model, governance for agent actions, or a single platform spanning build and growth, patterns WRKSHP uses on enterprise software and Growth OS engagements.

#Terraform#Infrastructure#DevOps#AWS#IaC