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...
Founder, WRKSHP.DEV
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 tfplanCI/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/computePreventing 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.