Terraform is the dominant infrastructure as code tool for AWS, GCP, and Azure workloads. Getting started is straightforward. Operating it at scale — across multiple teams, environments, and hundreds of resources — surfaces complexity that the getting-started tutorials don’t address.
This article focuses on the operational lessons: state management, module architecture, and the patterns that keep Terraform manageable as infrastructure grows.
Note: Terraform is developed by HashiCorp under the BSL license. OpenTofu is the open-source MIT-licensed fork, API-compatible with Terraform 1.5. For teams that require a fully open-source toolchain, OpenTofu is the alternative to evaluate.
State: The Source of Truth and the Source of Pain
Terraform state is the file that maps your configuration to actual cloud resources. It contains resource IDs, attribute values, and dependency relationships. Without state, Terraform can’t know what it has already created.
State management is where most Terraform operational problems originate.
Remote State Is Required for Teams
The default local state file (terraform.tfstate) is incompatible with team usage. Two engineers running terraform apply simultaneously against the same state file corrupt it.
Remote backends solve this:
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/services/api/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # State locking
}
}
The DynamoDB table provides state locking — preventing concurrent writes. When one terraform apply is running, others wait. Without locking, concurrent applies can corrupt state.
State Key Hierarchy
The key in the S3 backend is the path to the state file. Design it hierarchically:
production/networking/vpc/terraform.tfstate
production/services/api/terraform.tfstate
production/services/worker/terraform.tfstate
production/data/postgres/terraform.tfstate
staging/networking/vpc/terraform.tfstate
staging/services/api/terraform.tfstate
Each logical component has its own state. This provides:
- Blast radius isolation (a mistake in
services/apican’t destroynetworking) - Parallel applies (different components can run simultaneously)
- Clear ownership (each team owns their state files)
State Operations You Need to Know
# List resources in state
terraform state list
# Show specific resource
terraform state show aws_rds_instance.postgres
# Move a resource (rename, reorganize)
terraform state mv aws_s3_bucket.old_name aws_s3_bucket.new_name
# Import existing resource into state
terraform import aws_rds_instance.postgres mydb-identifier
# Remove resource from state without destroying it
terraform state rm aws_s3_bucket.logs
terraform import is particularly important when adopting Terraform for existing infrastructure. You import existing resources into state so Terraform can manage them without recreating them.
Module Architecture
Modules are Terraform’s unit of reuse. Without module architecture, Terraform code accumulates as monolithic configurations that are hard to understand and impossible to reuse.
Module Levels
A practical three-level hierarchy:
Foundation modules: wrappers around single resources with sensible defaults.
# modules/rds-postgres/main.tf
variable "name" { type = string }
variable "instance_class" { type = string }
variable "storage_gb" { type = number }
variable "subnet_ids" { type = list(string) }
resource "aws_db_instance" "this" {
identifier = var.name
engine = "postgres"
engine_version = "16"
instance_class = var.instance_class
allocated_storage = var.storage_gb
db_subnet_group_name = aws_db_subnet_group.this.name
backup_retention_period = 7
storage_encrypted = true
deletion_protection = true # Sensible defaults
# ... other standard configuration
}
Composite modules: combine multiple resources for a logical component (an ECS service with its task definition, IAM roles, and ALB target group).
Stack modules: complete application environments that compose composite modules.
# stacks/production/main.tf
module "vpc" {
source = "../../modules/vpc"
# ...
}
module "api_service" {
source = "../../modules/ecs-service"
vpc_id = module.vpc.vpc_id
# ...
}
module "api_database" {
source = "../../modules/rds-postgres"
subnet_ids = module.vpc.private_subnet_ids
# ...
}
Module Versioning
Modules should be versioned, especially when shared across teams:
module "api_service" {
source = "git::https://github.com/mycompany/tf-modules//ecs-service?ref=v2.3.0"
# Or from Terraform Registry:
# source = "mycompany/ecs-service/aws"
# version = "~> 2.3"
}
Versioned modules allow teams to upgrade on their own schedule. latest from a module registry is a reliability risk — a breaking module change can affect every consumer simultaneously.
Drift Detection and Prevention
Drift occurs when actual infrastructure differs from Terraform state. Someone makes a manual change in the console. A resource is modified by an external process. An auto-scaling event changes instance counts.
terraform plan detects drift:
terraform plan -refresh-only
This updates state to match actual infrastructure without making changes. It shows what has drifted.
Detecting drift in CI/CD:
# GitLab CI — daily drift detection
detect-drift:
script:
- terraform init
- terraform plan -detailed-exitcode -refresh-only
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
allow_failure: false
An exit code of 2 from terraform plan means changes are needed (drift detected). Alert when this happens outside of expected deployment windows.
Preventing drift: the cultural and process side matters as much as tooling.
- All infrastructure changes go through code review and the Terraform pipeline
- Console access is read-only or restricted for production environments
- Breaking-glass procedures for emergency manual changes are followed by immediate Terraform reconciliation
Plan/Apply Workflow
The safe deployment pattern:
# 1. Plan and save the plan to a file
terraform plan -out=tfplan
# 2. Review the plan output carefully
terraform show tfplan
# 3. Apply the saved plan (applies exactly what was planned)
terraform apply tfplan
The -out flag is important: it saves the exact planned changes. terraform apply without a saved plan recalculates the plan at apply time — if infrastructure changed between plan and apply, the applied changes may differ from what was reviewed.
In CI/CD:
plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths: [tfplan]
apply:
stage: apply
script:
- terraform apply tfplan
when: manual # Require human approval for production
dependencies: [plan]
Workspace Patterns
Terraform workspaces allow the same configuration to manage multiple environments. They’re simpler than separate state files per environment but have limitations.
terraform workspace new staging
terraform workspace select staging
terraform apply # Applies to staging state
Within the configuration, reference the workspace:
variable "instance_counts" {
default = {
production = 3
staging = 1
}
}
resource "aws_instance" "app" {
count = var.instance_counts[terraform.workspace]
}
Workspace limitations: they share the same backend, same code, same variables — just different state. If staging and production need fundamentally different configurations (different regions, different VPC layouts), separate directories with separate state is cleaner.
The workspace approach works well for: same architecture, different sizes (prod: 3 instances, staging: 1). It works poorly for: structurally different environments.
Secrets Management
Never store secrets in Terraform state or code. Secrets in state are stored in plaintext (the state file is JSON).
Patterns:
- Use AWS Secrets Manager, Vault, or similar — reference the secret by ARN/path, not value
- Terraform can read secrets at plan time without storing them in state
- Sensitive values marked with
sensitive = trueare redacted in plan output but still stored in state
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/postgres/password"
}
resource "aws_db_instance" "postgres" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
# This is sensitive — will not appear in plan output
}
The Discipline
The teams that operate Terraform reliably at scale share common practices:
- Remote state with locking, organized hierarchically by component
- All changes through CI/CD, never manual console changes in production
- Reviewed, versioned modules for reusable patterns
- Regular drift detection
- Plan output reviewed before every apply
Terraform is a powerful tool. The accidents come from treating it as a scripting tool rather than a program with state, dependencies, and operational concerns. Apply the same care you’d apply to application code.