Terraform makes it easy to create infrastructure and surprisingly hard to keep a large codebase maintainable. The problems show up around the eighteen-month mark: modules nobody dares change, state files that take twenty minutes to plan, and a terraform apply that everyone runs from their laptop while holding their breath.

These are the structural decisions that prevent that.

State boundaries decide everything else#

Before modules, before naming conventions, decide how state is split. This is the hardest thing to change later.

A single state file for an entire environment is the default and it fails in three ways: plan time grows linearly with resource count, the blast radius of a mistake is the whole environment, and only one person can apply at a time.

Split state along axes that change at different rates:

Text
├── foundation/          # accounts, org policy, DNS zones — changes yearly
│   └── prod/
├── platform/            # VPCs, clusters, shared databases — changes monthly
│   ├── prod/
│   └── staging/
└── services/            # application infrastructure — changes daily
    ├── prod/
    │   ├── api/
    │   └── worker/
    └── staging/

The rule: things that change together live together; things that change at different rates live apart. A deploy to the API service should not require a plan that evaluates your DNS zones.

Cross-state references go through data sources or a registry, not terraform_remote_state:

HCL
# Avoid — couples the consumer to the producer's internal state layout
data "terraform_remote_state" "platform" {
  backend = "s3"
  config  = { bucket = "tf-state", key = "platform/prod/terraform.tfstate" }
}

# Prefer — a published contract the producer maintains deliberately
data "aws_ssm_parameter" "vpc_id" {
  name = "/platform/prod/vpc/id"
}

Remote state reads mean any refactor of the producer's internals can break every consumer. A parameter store entry is an explicit, versionable interface.

Module design#

A good module has a narrow interface and does one thing. The test: can you describe what it does in one sentence without using "and"?

Structure#

Text
modules/postgres-instance/
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── examples/
    ├── minimal/
    └── production/

versions.tf in a reusable module declares minimum versions only. Never an exact pin, and not ~> either — an upper bound in a shared module makes it uncomposable when two modules disagree. The tight constraint belongs in the root module, as ~>, with the committed .terraform.lock.hcl recording the exact versions chosen:

HCL
terraform {
  required_version = ">= 1.9"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.60"
    }
  }
}

Variables with real validation#

Validation catches mistakes at plan time rather than at apply time, twenty minutes in:

HCL
variable "instance_class" {
  description = "RDS instance class. Must be a memory-optimised class in production."
  type        = string

  validation {
    condition     = can(regex("^db\\.(t4g|m7g|r7g)\\.", var.instance_class))
    error_message = "instance_class must be a Graviton class: db.t4g.*, db.m7g.*, or db.r7g.*."
  }
}

variable "backup_retention_days" {
  description = "Days to retain automated backups."
  type        = number
  default     = 14

  validation {
    condition     = var.backup_retention_days >= 7 && var.backup_retention_days <= 35
    error_message = "backup_retention_days must be between 7 and 35."
  }
}

variable "environment" {
  type = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

Use an object type when a variable is really a bundle of related settings — it documents the shape and gives you type checking:

HCL
variable "maintenance" {
  description = "Maintenance and backup windows, in UTC."

  type = object({
    window        = string
    backup_window = string
    auto_upgrade  = optional(bool, true)
  })

  default = {
    window        = "sun:03:00-sun:04:00"
    backup_window = "01:00-02:00"
  }
}

optional() with a default is the feature that makes object variables practical — consumers set what they care about.

Every variable gets a description. terraform-docs generates your README from them, so a described variable documents itself permanently and an undescribed one never gets documented at all.

Outputs are a public API#

Once someone depends on an output, removing it is a breaking change. Export the identifiers consumers genuinely need, not everything you happen to have:

HCL
output "endpoint" {
  description = "Connection endpoint for the primary instance."
  value       = aws_db_instance.this.address
}

output "security_group_id" {
  description = "Security group attached to the instance; attach client rules here."
  value       = aws_security_group.this.id
}

# Sensitive outputs are marked, never logged.
output "master_password_secret_arn" {
  description = "ARN of the Secrets Manager secret holding the master password."
  value       = aws_secretsmanager_secret.master.arn
  sensitive   = true
}

Never output a secret's value. Output the reference to it — state files are plain text, and every consumer's state inherits the secret.

Composition over configuration#

The strongest temptation in Terraform is the mega-module: one module with fifty variables that provisions an entire environment. It is easy to call and impossible to change.

HCL
# The trap — a module that grows a flag for every requirement
module "environment" {
  source = "./modules/environment"

  enable_bastion            = true
  enable_nat_per_az         = false
  enable_flow_logs          = true
  database_multi_az         = true
  # ... 40 more booleans
}

Each flag multiplies the untested combinations. Compose small modules instead:

HCL
module "network" {
  source = "../../modules/vpc"

  name               = "prod"
  cidr               = "10.40.0.0/16"
  availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  nat_gateways       = 3
}

module "database" {
  source = "../../modules/postgres-instance"

  identifier         = "prod-primary"
  subnet_ids         = module.network.private_subnet_ids
  vpc_id             = module.network.vpc_id
  instance_class     = "db.r7g.2xlarge"
  environment        = "prod"
}

The root module is where environment-specific composition belongs. It should be readable as a description of the environment.

Handling drift and imports#

Real environments accumulate resources created outside Terraform. Since 1.5, import blocks make adoption reviewable — the import is planned, not performed imperatively:

HCL
import {
  to = aws_db_instance.legacy_reporting
  id = "prod-reporting-01"
}

resource "aws_db_instance" "legacy_reporting" {
  # Run `terraform plan -generate-config-out=generated.tf` to scaffold this.
}

Detect drift on a schedule rather than discovering it during an incident:

Bash
terraform plan -detailed-exitcode -lock=false
# 0 = no changes, 1 = error, 2 = drift detected

Wire exit code 2 into an alert. Drift that nobody notices becomes drift that blocks an urgent change.

CI/CD#

Applies belong in a pipeline, not on laptops. The minimum viable pipeline:

YAML
name: terraform

on:
  pull_request:
    paths: ['infrastructure/**']

permissions:
  id-token: write      # OIDC — no long-lived cloud credentials
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/terraform-plan
          aws-region: eu-west-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8

      - run: terraform init -backend-config=envs/prod.hcl
      - run: terraform fmt -check -recursive
      - run: terraform validate

      - run: tflint --recursive
      - run: trivy config --severity HIGH,CRITICAL .

      - run: terraform plan -out=tfplan -lock-timeout=5m
      - run: terraform show -no-color tfplan > plan.txt

      - uses: actions/github-script@v7
        with:
          script: |
            const plan = require('fs').readFileSync('plan.txt', 'utf8');
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```\n' + plan.slice(0, 60000) + '\n```'
            });

Two details matter. OIDC federation removes long-lived cloud credentials from CI entirely. And the plan is saved and applied as an artefact — applying a freshly-computed plan after approval means applying something nobody reviewed.

Plan output can contain sensitive values. Posting it to a pull request on a public repository leaks them. Use sensitive = true diligently, and consider posting only the resource change summary rather than the full diff.

Testing#

terraform test gives you assertions without deploying, using plan-time evaluation:

HCL
# tests/defaults.tftest.hcl
variables {
  identifier     = "test-db"
  subnet_ids     = ["subnet-aaa", "subnet-bbb"]
  vpc_id         = "vpc-123"
  environment    = "dev"
  instance_class = "db.t4g.medium"
}

run "encryption_is_enabled_by_default" {
  command = plan

  assert {
    condition     = aws_db_instance.this.storage_encrypted == true
    error_message = "Storage encryption must default to enabled."
  }
}

run "rejects_non_graviton_classes" {
  command = plan

  variables {
    instance_class = "db.m5.large"
  }

  expect_failures = [var.instance_class]
}

Test the defaults and the validation rules. Those are where regressions hide — someone changes a default in a refactor and every new database ships unencrypted.

The short version#

  • Split state by rate of change, not by resource type.
  • Publish cross-stack values through a parameter store, never terraform_remote_state.
  • Small modules, composed in the root, beat one configurable module.
  • Validate variables; the error at plan time is worth ten minutes of writing.
  • Never output secret values, only references.
  • Apply from CI with OIDC, applying the reviewed plan artefact.
  • Alert on drift before it blocks you.

Sources#