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 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:
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.
Sources#
- Terraform 1.5 brings config-driven import and checks —
importblocks arrived in 1.5 as a plannable operation, with-generate-config-out. - terraform plan command reference — the 0 / 1 / 2 meanings of
-detailed-exitcode. - Tests - Configuration Language —
terraform test(v1.6+),command = plan,assert, andexpect_failureson input variables. - Type Constraints - Configuration Language —
optional()object attributes with default values. - Version Constraints - Configuration Language — the
~>operator, and HashiCorp's advice that reusable modules constrain minimum versions only while root modules use~>. - Dependency Lock File (.terraform.lock.hcl) - Configuration Language — the lock file belongs to the configuration as a whole and should be committed to version control.
- Backup retention period — RDS automated backups can be kept for at most 35 days.
- Configuring OpenID Connect in Amazon Web Services — OIDC federation from GitHub Actions in place of long-lived cloud credentials.
0 comments
Sign in to join the discussion.
No comments yet. If something here is wrong or incomplete, say so — corrections are welcome.