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:
├── 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:
# 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
modules/postgres-instance/
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── examples/
├── minimal/
└── production/
versions.tf pins providers with a pessimistic constraint. Modules should never pin an exact version — that makes them uncomposable when two modules disagree:
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:
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:
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-docsgenerates 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:
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.
# 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:
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:
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:
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:
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 = truediligently, 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:
# 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.
0 comments
Sign in to join the discussion.
No comments yet. If something here is wrong or incomplete, say so — corrections are welcome.