DEV Community

DatanestDigital
DatanestDigital

Posted on • Originally published at dev.to

7 Terraform Patterns That Survive Production

7 Terraform Patterns That Survive Production

Nobody's Terraform stays clean past the second sprint. You start with a tidy main.tf and a handful of resources. Six months later, you've got a 4000-line monolith, state files you're afraid to touch, and a terraform plan that takes long enough to go make coffee.

After five years of writing infrastructure-as-code across three cloud providers and more refactors than I'd like to admit, here are the seven patterns that actually hold up when your infrastructure stops being a demo and starts being production.

1. Remote State From Day One

If you only remember one thing from this list, make it this one. Local state is a ticking time bomb—it sits on one person's laptop, drifts from reality, and can't be shared across a team.

terraform {
  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "prod/networking/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
Enter fullscreen mode Exit fullscreen mode

The DynamoDB table gives you state locking—no two engineers running terraform apply at the same time and racing each other's changes. Set this up before your first terraform init. Migrating existing local state to remote is doable but painful, and the longer you wait the scarier that migration becomes.

Pro tip: use separate state files per logical environment and per component. One monolithic state file for everything is almost as bad as no remote state at all. Small blast radius, small plan output, small anxiety.

2. Module Composition, Not Module Explosion

The pendulum swung hard. First we had no modules. Then the community decided everything should be a module, and we ended up with 40 tiny modules that each wrap a single resource and add nothing but indirection.

The sweet spot: modules that represent an opinionated deployment unit, not a resource wrapper.

# Good: a module that deploys a complete RDS instance
# with encryption, backups, monitoring, and subnet group
module "primary_db" {
  source = "./modules/rds-postgres"

  name           = "orders-db"
  instance_class = "db.r6g.large"
  subnet_ids     = module.vpc.database_subnet_ids
  vpc_id         = module.vpc.vpc_id
  multi_az       = true
}
Enter fullscreen mode Exit fullscreen mode
# Avoid: a module that just wraps aws_security_group
# with no added value
module "sg" {
  source = "./modules/security-group"
  name   = "allow-http"
  vpc_id = module.vpc.vpc_id
}
Enter fullscreen mode Exit fullscreen mode

A good module encodes your organisation's standards—it ensures every database gets encryption, backup retention, and the right monitoring alarms. A bad module is just a for_each with extra steps.

3. Terraform Workspaces or Directory Separation—Pick One and Commit

There are two camps for managing environments (dev, staging, prod):

Workspace-based: One config, multiple workspaces, differentiated by terraform.workspace lookups:

locals {
  instance_sizes = {
    dev     = "t3.small"
    staging = "t3.medium"
    prod    = "t3.large"
  }
}
resource "aws_instance" "app" {
  instance_type = local.instance_sizes[terraform.workspace]
}
Enter fullscreen mode Exit fullscreen mode

Directory-based: Separate directories per environment, each with its own terraform.tfvars:

infra/
  dev/
    main.tf
    terraform.tfvars
  staging/
    main.tf
    terraform.tfvars
  prod/
    main.tf
    terraform.tfvars
  modules/
    app/
Enter fullscreen mode Exit fullscreen mode

Both work. Pick one and enforce it with CI. The only unforgivable sin is doing both at the same time and confusing the entire team.

I lean toward directory separation for production: it makes the blast radius explicit, gives you independent state files per environment, and makes it impossible to accidentally run terraform destroy in prod because you had the wrong workspace selected.

4. The .tfvars Hierarchy

Variables spread across three layers beats a single terraform.tfvars that nobody can read:

# Layer 1: terraform.tfvars (committed, shared defaults)
region     = "eu-west-1"
node_count = 3

# Layer 2: env.tfvars (committed, environment-specific)
environment = "prod"
node_count  = 5
instance_type = "c5.2xlarge"

# Layer 3: secrets.auto.tfvars (gitignored, CI-injected)
db_password = "<from vault>"
api_key     = "<from secrets manager>"
Enter fullscreen mode Exit fullscreen mode

Terraform automatically loads any .auto.tfvars files, so naming your secret file secrets.auto.tfvars keeps the apply command clean (terraform apply, not terraform apply -var-file=secrets.tfvars -var-file=prod.tfvars).

Keep committed tfvars lean—configuration that changes per environment, not credentials. Sensitive values should hit your state file with the sensitive = true attribute so they don't leak into plan output or CI logs.

5. Plan Output as a PR Gate

A Terraform plan is worth a thousand code review comments. Wire it into your CI so that every pull request posts the plan output as a PR comment, and terraform apply runs only on merge to main.

# GitHub Actions sketch
name: Terraform Plan
on:
  pull_request:
    paths:
      - 'infra/**'

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - id: plan
        run: terraform plan -no-color -out=plan.tfplan
      - uses: actions/github-script@v7
        with:
          script: |
            const output = `### Terraform Plan\n\`\`\`\n${plan.stdout}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });
Enter fullscreen mode Exit fullscreen mode

This catches drift before it reaches production. It also means the person reviewing your PR can see exactly what infrastructure changes your code will make—no guessing, no "seems fine, ship it."

6. Use moved Blocks Instead of State Surgery

Terraform 1.1 introduced moved blocks, and they replaced the most terrifying operation in the Terraform workflow: terraform state mv.

Before moved blocks, renaming a resource or moving it into a module meant carefully running state manipulation commands. Get it wrong and Terraform would destroy the old resource and create a new one—fine for a security group, catastrophic for a database.

# Rename a resource without destroying it
moved {
  from = aws_instance.web_server
  to   = aws_instance.web
}

# Move into a module
moved {
  from = module.old_name
  to   = module.new_name
}
Enter fullscreen mode Exit fullscreen mode

Commit moved blocks alongside the rename. They're declarative, reviewable, and safe. After the apply succeeds and the state has migrated, you can remove them—or leave them in for documentation. They're inert once the move is complete.

7. The terraform plan Litmus Test

Here's a single rule that catches most Terraform hygiene problems: after every apply, run terraform plan again. If it doesn't come back clean ("No changes"), you have a bug.

A non-empty plan after apply means one of:

  • A resource has a computed attribute that isn't stored in state yet (common with AWS default values)
  • A data source is non-deterministic (timestamp(), uuid())
  • You have a null_resource trigger that changes every run
  • A provider default is being applied as an explicit value on the next refresh

Each of these is fixable, and each of them adds noise to real plans that should only show your actual changes. A noisy plan trains the team to ignore plan output—exactly what you don't want when someone accidentally adds force_destroy = true to an S3 bucket.

Add terraform plan -detailed-exitcode to your CI pipeline after every apply and fail the pipeline if the exit code isn't 0 (clean).


These patterns aren't theoretical—they're what's left after the tutorials end and the on-call rotations begin. Each one addresses a real failure mode I've watched teams stumble into, and each one takes almost no effort to adopt from the start.

The best time to add remote state was before your first commit. The second-best time is now.

For more infrastructure and DevOps patterns, check out The Ship Log.

Top comments (0)