Terraform is easy to start and surprisingly easy to get subtly wrong in ways that don't surface until months later — usually as a state file conflict during a critical deploy, or a module that's been copy-pasted into five slightly different versions across a codebase. Here's what actually matters for using it well long-term.
Table of Contents
- Why Terraform State Is the Part That Bites People
- Remote State and Locking
- Module Design That Doesn't Become Spaghetti
- Workspaces vs Separate State Files
- A Real Module Example
- Common Mistakes
- Security Considerations
- CI/CD Integration
- FAQs
- Key Takeaways
Why Terraform State Is the Part That Bites People
Terraform's core model is straightforward: you declare desired infrastructure, Terraform diffs it against a state file representing what it last knew to exist, and applies the difference. The state file is the actual source of truth for what Terraform believes is real — not your .tf files, and not the actual cloud provider (though it should track it). When state drifts from reality (someone made a manual console change, or two people applied conflicting changes without locking), Terraform's next plan can produce confusing or destructive results.
Remote State and Locking
Local state files (the default) are fine for solo experimentation and actively dangerous for team use — no locking means two people running terraform apply simultaneously can corrupt state or apply conflicting changes. Remote state with locking is non-negotiable for any team:
terraform { backend "s3" { bucket = "my-terraform-state" key = "prod/network/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-locks" encrypt = true } }
The DynamoDB table provides locking — Terraform acquires a lock before any state-modifying operation and releases it after, preventing concurrent applies from corrupting state. S3 versioning on the state bucket is also worth enabling separately — it gives you a rollback path if state does get corrupted despite locking.
Module Design That Doesn't Become Spaghetti
The most common Terraform anti-pattern I see: a single sprawling root module with every resource for an entire environment declared flat, or the opposite extreme — modules so granular and over-abstracted that using them requires reading their source to understand what they actually do.
A workable middle ground: modules scoped around a genuine unit of reuse (a VPC, an ECS service, an RDS instance with its supporting resources), with clear, minimal required inputs and sensible defaults for the rest.
modules/ vpc/ ecs-service/ rds-postgres/ environments/ staging/ main.tf # calls modules with staging-specific inputs production/ main.tf # calls modules with production-specific inputs
This structure lets staging and production share the same module logic while differing only in the input variables — which is the actual point of modules, not code reuse for its own sake.
Workspaces vs Separate State Files
Terraform workspaces let you maintain multiple state files from the same configuration, switched via terraform workspace select. They seem convenient for staging/production separation, but in practice, separate state files per environment (via distinct backend configuration, as in the module structure above) are usually the safer choice — workspaces make it easy to run terraform apply against the wrong environment by forgetting which workspace is currently selected, which is a much easier mistake to make than accidentally pointing at the wrong backend config entirely.
A Real Module Example
# modules/ecs-service/main.tf variable "service_name" { type = string } variable "cpu" { type = number default = 256 } variable "desired_count" { type = number default = 2 } resource "aws_ecs_service" "this" { name = var.service_name cluster = var.cluster_id task_definition = aws_ecs_task_definition.this.arn desired_count = var.desired_count deployment_circuit_breaker { enable = true rollback = true } } resource "aws_ecs_task_definition" "this" { family = var.service_name cpu = var.cpu # ... }
Notice the deployment_circuit_breaker block — a small but meaningful detail: it enables automatic rollback if a deployment fails health checks, which is the kind of production-hardening detail that's easy to skip when copying a "getting started" example.
Common Mistakes
- Storing state locally for team projects. Non-negotiable to fix — remote state with locking, from day one.
- Hardcoding values that should be variables, making a module unusable in a second environment without editing its source.
- No
.tfvarsseparation between environments, leading to accidental cross-environment applies. - Skipping
terraform planreview before apply in CI/CD. Automated applies without a human (or at minimum, an automated policy check) reviewing the plan output is how a bad configuration change becomes a production incident. - Overusing
count/for_eachin ways that make state addressing fragile — reordering a list backing acountloop can cause Terraform to destroy and recreate resources unexpectedly; preferfor_eachwith stable keys for anything where resource identity matters.
Security Considerations
Terraform state files can contain sensitive values (database passwords, API keys) in plaintext within the state itself, even if marked as sensitive in the configuration (the sensitive flag hides values from CLI output, it does not encrypt them in state). Always enable encryption at rest on your state backend (S3 with SSE, or an equivalent for other backends), and treat state file access as equivalent in sensitivity to secrets manager access — restrict who can read it via IAM, not just who can modify it.
CI/CD Integration
A reasonably safe pattern for team Terraform workflows: terraform plan runs automatically on every pull request (posted as a PR comment for review), and terraform apply runs only after merge to the main branch, ideally with a manual approval gate for production environments specifically. This gives you review-before-apply without requiring every engineer to have local apply credentials.
FAQs
Should I use Terraform Cloud/HCP Terraform instead of self-managed state? Worth considering for teams that want managed state, locking, and run history without operating the backend infrastructure themselves — it removes some of the S3/DynamoDB setup at the cost of a dependency on HashiCorp's hosted service (or self-hosted Terraform Enterprise).
Is Terraform better than CloudFormation for AWS-only infrastructure? Terraform's multi-cloud support and generally more readable HCL syntax are its main advantages; CloudFormation has tighter native AWS integration (StackSets, drift detection built into the AWS console) and no separate state file to manage since AWS tracks state itself. Both are legitimate choices — see my Docker vs Kubernetes piece for a similar "it depends on your actual constraints" framework applied to infrastructure tooling choices.
How do I handle secrets in Terraform without storing them in state in plaintext?
Reference secrets from a secrets manager (AWS Secrets Manager, HashiCorp Vault) via data sources rather than declaring them as variables — this keeps the actual secret value out of your .tf files, though the resolved value can still end up in state, which is why state encryption and access control both matter.
What's the difference between a Terraform module and a root configuration?
A root configuration is what you actually run terraform apply against; modules are reusable, parameterized configuration blocks that root configurations (or other modules) call — a root config typically composes several modules together.
Key Takeaways
- Remote state with locking (S3 + DynamoDB, or an equivalent) is mandatory for any team usage, not an optional best practice.
- Design modules around genuine units of reuse with minimal required inputs, not maximal abstraction.
- Separate state files per environment are generally safer than workspaces for preventing accidental cross-environment applies.
- State files can contain plaintext secrets even with
sensitive = true— encrypt state at rest and restrict read access accordingly.
Related Reading
- Docker vs Kubernetes: When You Actually Need Which
- CI/CD Pipelines: A Practical Guide for Small Teams
- AWS for Startups: What Actually Matters in Year One
Next Steps
Setting up infrastructure as code for your team, or need a Terraform codebase audit? Let's talk.