Terraform Best Practices: Modules, State, and Workspaces
A practical guide to Terraform best practices: module design, remote state management, workspaces, and security for production-grade infrastructure as code.
Overview
Terraform has become the go-to infrastructure-as-code tool for many teams. They use it to define, provision, and manage cloud resources through declarative configuration files. Getting started is straightforward — a few HCL blocks and you’ve got a VPC. But keeping it tidy at scale? That’s a different story. It takes discipline — module design, state management, security, collaboration. None of it’s optional.
I learned this the hard way. A few years back, I inherited a Terraform repo where everything
lived in one 2,000-line main.tf. No modules, no remote state, no locking. Two engineers ran
terraform apply on the same workspace at the same time and corrupted the state file. We spent
three days recovering from backups. That experience taught me something: the practices around
Terraform matter way more than the code itself.
This guide covers what separates prototype Terraform from infrastructure you can run in production: module design, remote state, workspaces, security, testing, CI/CD, drift detection, and policy as code. It’s written for teams who already know the basics and want to level up.
Prerequisites: you should already know the basics — resources, variables, outputs. You’ll need an AWS or GCP account and a CI/CD platform (GitHub Actions, GitLab CI, or similar).
When to Use
Terraform pays off when you’re managing cloud infrastructure that changes often, when several team members touch the same resources, and when you need reproducible environments across dev, staging, and production. It’s also the right choice when you want your infrastructure definitions in version control, or when you’re moving from manual provisioning to infrastructure as code.
When NOT to Use
Skip Terraform for a handful of static resources that rarely change. Don’t force it on a team that isn’t ready to manage state files, locks, and backend access. And if you need real-time, event-driven infrastructure reconciliation, tools like Ansible or Kubernetes operators are usually a better fit.
Module Design
The way you structure modules determines whether your Terraform codebase scales or collapses under
its own weight. I’ve seen teams start with a single main.tf and end up with 5,000 lines of
copy-pasted HCL six months later. The fix is almost always the same: break things into small,
composable modules with clear interfaces.
Root module vs child modules
terraform/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ └── main.tf
│ ├── staging/
│ │ └── main.tf
│ └── prod/
│ └── main.tf
Module interface design
Keep inputs explicit and outputs minimal.
# modules/vpc/variables.tf
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "List of AZs to use"
type = list(string)
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
Composition over inheritance
Favor small modules that compose together. One monolithic block will haunt you.
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
module "database" {
source = "../../modules/database"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.r6g.xlarge"
}
For a deeper dive, see Complete Guide to Terraform Modules.
Module versioning
Pin module versions. An unpinned module is a ticking bomb — the maintainer pushes a breaking change
and your next terraform init silently picks it up.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0" # pin to a specific version
cidr = "10.0.0.0/16"
}
For internal modules, use Git tags as versions:
module "database" {
source = "git::https://github.com/myorg/terraform-modules//database?ref=v1.2.0"
}
I recommend semantic versioning for modules: bump the patch for bug fixes, minor for new
resources, major for breaking changes. Your CI pipeline should run terraform plan against
the new version before merging.
State Management
Remote state with locking
Never store state in version control. Use remote backends with locking.
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# Create the backend resources
aws s3api create-bucket --bucket my-terraform-state --region us-east-1
aws s3api put-bucket-versioning --bucket my-terraform-state --versioning-configuration Status=Enabled
aws dynamodb create-table \
--table-name terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
For details, see Terraform Remote State S3 Backend.
State isolation
Give each environment and each component its own state file.
| Approach | Best For |
|---|---|
| Workspaces | Simple environments (dev/staging/prod) |
| Separate directories | Complex environments with different configurations |
| Separate backends | Maximum isolation, different AWS accounts |
Drift detection
Drift happens when someone changes infrastructure outside Terraform — a manual console fix, a script, another tool. Left unchecked, drift causes plans to surprise you and applies to fail.
# Check for drift on a schedule
terraform plan -detailed-exitcode
# Exit codes:
# 0 = no changes
# 1 = error
# 2 = drift detected (changes exist)
Run this in CI on a nightly schedule. If the exit code is 2, open an issue automatically. Some
teams use terraform plan with Slack notifications; others use tools like driftctl (now
driftctl is archived, but alternatives like Terradrift exist).
I’ve seen drift bite teams in two ways: a DB instance class that someone bumped manually (Terraform reverted it on next apply, causing an outage), and a security group rule added during an incident (Terraform removed it, reopening the vulnerability). The fix is the same: detect drift early, investigate why it happened, and either update the Terraform config or revert the manual change.
Workspaces
Terraform workspaces allow several state files within the same configuration.
# Create and switch to a workspace
terraform workspace new prod
terraform workspace select prod
# Use workspace in configuration
locals {
environment = terraform.workspace
instance_count = {
dev = 1
staging = 2
prod = 3
}[terraform.workspace]
}
Workspaces share the same backend configuration. If you need real isolation, workspaces alone won’t cut it. You’ll want separate backend configurations or even different cloud accounts. See Terraform Workspace Environment Isolation.
Importing existing resources
If you’re adopting Terraform on infrastructure that already exists, you don’t have to recreate
everything. Use terraform import to bring existing resources into state:
# Import an existing S3 bucket
terraform import aws_s3_bucket.main my-existing-bucket
# Import an existing RDS instance
terraform import aws_db_instance.main my-db-instance-id
The import command only adds the resource to state — it doesn’t generate the HCL. Here’s the
catch: you’ll need to write the resource block manually and make sure it matches the
real infrastructure. Run terraform plan after import; if it shows no changes, your HCL matches
reality.
For bulk imports, tools like terraformer (GoogleCloudPlatform/terraformer) can generate HCL from
existing cloud resources. I used it once to import 200+ resources from an AWS account in a single afternoon. The generated
code? Needed cleanup, yeah. But it beat writing everything by hand. Trust me on that.
Security Practices
Never commit secrets
# .gitignore
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.auto.tfvars
secrets.tfvars
Use variables for sensitive data
variable "db_password" {
description = "Database administrator password"
type = string
sensitive = true
}
Least privilege for CI/CD
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:*", "rds:*", "s3:*"],
"Resource": "*",
"Condition": {
"StringEquals": {"aws:RequestedRegion": "us-east-1"}
}
},
{
"Effect": "Deny",
"Action": ["ec2:DeleteVpc", "rds:DeleteDBInstance"],
"Resource": "*"
}
]
}
Testing and Validation
Static analysis
# Format check
terraform fmt -check -recursive
# Validate syntax
terraform validate
# Security scanning with Checkov
checkov -d .
Plan review workflow
# Generate a plan file
terraform plan -out=tfplan
# Review the plan
terraform show tfplan
# Apply only the reviewed plan
terraform apply tfplan
CI/CD pipeline for Terraform
A solid CI/CD pipeline catches issues before they hit production. Here’s the workflow that works for me:
# .github/workflows/terraform.yml
name: Terraform CI
on:
pull_request:
paths: ["terraform/**"]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
- run: checkov -d terraform/
plan:
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- run: terraform init
- run: terraform plan -no-color
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
The pipeline runs four checks: format, validate, security scan, and plan. The plan step posts its output as a PR comment so reviewers can see exactly what will change. Apply happens only after merge to main, and only from a protected branch.
One thing I always enforce: the CI role has least-privilege IAM. It can create and modify resources in the target account, but deleting VPCs or RDS instances? That requires a separate approval step. No exceptions.
Explanation
Modules keep code DRY and reusable. Root modules call child modules and pass environment-specific
values through variables. Remote state stores the .tfstate file outside local disks: S3 gives you
durability, and DynamoDB gives you locking to prevent concurrent writes. Workspaces split state by
environment within a single backend. They’re lightweight, sure, but remember: they share the same
backend credentials. For strict
separation, you’re better off with separate backends.
The state file is Terraform’s source of truth. It maps your HCL resources to real cloud objects. Lose it, and Terraform can’t tell what exists and what doesn’t — you’re flying blind. That’s why remote state with versioning and locking is non-negotiable. S3 versioning means you can recover from a corrupted state file; DynamoDB locking means two engineers can’t apply at the same time.
Security starts with never committing secrets, marking variables as sensitive, and giving CI/CD
the smallest permissions needed. Validation with terraform fmt, terraform validate, and
checkov catches syntax and security issues before apply. Policy as code (Sentinel or OPA) adds
guardrails for team-wide rules — no public buckets, no unencrypted databases, no oversized
instances in dev. Cost estimation with infracost closes the loop by showing the financial impact of
every change.
The workflow that ties it all together: write code → run fmt and validate locally → push to
branch → CI runs fmt check, validate, checkov, plan, and infracost → review the plan and cost
estimate in the PR → merge → CI applies the plan on main. This loop, repeated daily, is what
separates a team that ships infrastructure safely from one that’s afraid to touch prod.
Common Mistakes
- Storing state in Git. State files can hold secrets and aren’t designed for version-control resolution. Use a remote backend with encryption and versioning instead.
- Hardcoding credentials. Don’t bake secrets into HCL. Pass them through variables, environment variables, or IAM roles and keep them out of the repository.
- Monolithic modules. Break infrastructure into small, reusable, testable modules rather than one giant file.
- Skipping plan files. The plan is your last line of defense — generate it, read it, and only then apply. Skipping this step is how production outages happen.
- Ignoring provider version pinning. Pin provider and module versions to avoid surprise breaking changes.
- No state locking. Several engineers running Terraform at the same time can corrupt state. Use a backend that supports locking.
- Ignoring drift. If someone changes a resource in the console and you don’t detect it, your next apply will either revert the change (causing an outage) or fail with a confusing error. Run nightly drift checks in CI.
- Not using
terraform importfor existing infrastructure. I’ve seen teams manually recreate 300 resources in Terraform when they could have imported them in an afternoon. Don’t be that team.
See Also
- Terraform official docs — complete reference for configuration, providers, and CLI commands. Start with the “Configuration Language” section.
- Terraform AWS Provider — resource and data source reference for AWS. Bookmark this; you’ll visit it daily.
- Checkov — open-source SAST tool for Terraform. Scans for misconfigurations like public S3 buckets, unencrypted volumes, and overly permissive SGs.
- Infracost — cost estimation for Terraform. Posts cost diffs as PR comments so reviewers see the financial impact before merging.
- Atlantis — open-source Terraform PR automation. Runs plan and apply from PR comments, with locking and concurrent workspace support.
- Sentinel — policy as code for Terraform Cloud / Enterprise. Catches things like “no public S3 buckets” before they reach apply.
Advanced Topics
Modular Terraform for production
# Directory structure
# infra/
# modules/
# vpc/
# eks/
# rds/
# environments/
# dev/
# staging/
# production/
# modules/rds/main.tf
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
variable "instance_class" { type = string }
variable "allocated_storage" { type = number, default = 100 }
variable "multi_az" { type = bool, default = true }
variable "backup_retention" { type = number, default = 7 }
variable "tags" { type = map(string), default = {} }
resource "aws_db_instance" "main" {
engine = "postgres"
engine_version = "16"
instance_class = var.instance_class
allocated_storage = var.allocated_storage
multi_az = var.multi_az
backup_retention_period = var.backup_retention
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
tags = merge(var.tags, {
Name = "postgres-main"
ManagedBy = "terraform"
})
}
resource "aws_kms_key" "rds" {
description = "KMS key for RDS encryption"
enable_key_rotation = true
}
resource "aws_db_subnet_group" "main" {
name = "main-db-subnet-group"
subnet_ids = var.subnet_ids
}
resource "aws_security_group" "rds" {
name = "rds-sg"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_sg_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
output "endpoint" { value = aws_db_instance.main.endpoint }
output "db_arn" { value = aws_db_instance.main.arn }
# environments/production/main.tf
module "rds" {
source = "../../modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.r5.xlarge"
allocated_storage = 500
multi_az = true
backup_retention = 30
tags = { Environment = "production", Team = "platform" }
}
# Environment differences:
# dev: db.t3.medium, 20GB, no multi-az, backup 1 day
# staging: db.t3.large, 100GB, multi-az, backup 7 days
# production: db.r5.xlarge, 500GB, multi-az, backup 30 days
Policy as code
Once your team grows beyond 3-4 engineers, you’ll want guardrails. Policy as code lets you
enforce rules before apply — no public S3 buckets, no unencrypted databases, no instances bigger
than m5.2xlarge in dev.
# Sentinel policy (Terraform Cloud / Enterprise)
import "tfplan/v2" as tfplan
# No public S3 buckets
no_public_s3 = rule {
all tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and rc.change.actions contains "create" implies
rc.change.after.acl is not "public-read" and
rc.change.after.acl is not "public-read-write"
}
}
main = rule {
no_public_s3
}
If you’re not on Terraform Cloud, OPA (Open Policy Agent) with Conftest works as an open-source alternative:
# Run OPA policies against a Terraform plan
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policies/
I’ve found policy as code most useful for cost control. A policy that blocks db.r6g.16xlarge in
dev saves a lot of awkward conversations with the finance team.
Cost estimation
Terraform plan output doesn’t show costs by default. Tools like infracost fill that gap:
# Install infracost
brew install infracost
# Generate a cost estimate from a plan
infracost breakdown --path tfplan.json
# Example output:
# NAME MONTHLY QUANTITY MONTHLY COST
# aws_db_instance.main 730 hours $1,460.00
# ├─ db.r5.xlarge 730 hours $1,460.00
# aws_s3_bucket.data 100 GB $2.30
# TOTAL $1,462.30
Run infracost in CI and post the estimate as a PR comment. Reviewers see the cost impact of every change before merging. This is especially valuable for resources that scale automatically — an Aurora cluster that auto-scales to 5 read replicas can quietly add $3,000/month.
Frequently Asked Questions
Should I use Terraform Cloud?
Terraform Cloud and Enterprise provide remote state, team collaboration, and policy-as-code. For small teams, an S3 + DynamoDB backend is usually enough.
How do I manage secrets in Terraform?
Use environment variables (TF_VAR_*), HashiCorp Vault, or cloud secret managers such as AWS
Secrets Manager. Mark variables as sensitive = true so they don't show up in logs or plan output.
When should I use modules vs workspaces?
Modules are for reusable infrastructure components. Workspaces are for environment-specific state isolation. Use both: modules for DRY code, workspaces or separate directories for environment separation.
How do I handle remote state and locking?
A remote backend like S3 plus DynamoDB for locking is the standard setup. Enable encrypt = true
and
keep .tfstate files out of the repository. For larger teams, Terraform Cloud or Atlantis can push
changes through PRs.
How do I get started with this in an existing project?
Pick a small, isolated part of the codebase — one module or service. Apply these practices there, measure the impact, then expand.
Why does my plan show changes I didn't make?
This is usually drift — someone changed infrastructure outside Terraform. Run terraform plan -detailed-exitcode in CI to detect it. If the exit code is 2, investigate what changed and why,
then either update your Terraform config or revert the manual change.
What's the deal with .terraform.lock.hcl?
It's the dependency lock file, added in Terraform 1.6. It pins provider versions to ensure
terraform init produces the same result across machines. Commit it to version control — don't
gitignore it. The only thing you should gitignore is .terraform/ (the plugins directory).
Can I use Terraform with multiple cloud providers?
Yes, but be careful. Each provider needs its own configuration block, and cross-provider dependencies can get messy. I recommend separate state files per cloud — mixing AWS and GCP resources in one state file makes imports and drift detection harder. Use separate modules and separate backends for each cloud.
Related Resources
Complete Guide to Terraform Modules
Build reusable Terraform modules with proper structure, inputs, outputs, and versioning. Covers module composition, testing, and registry publishing.
RecipeProvision an AWS VPC with Terraform
How to use Terraform to provision a production-ready AWS VPC with public and private subnets, NAT gateways, and security groups
RecipeStore Terraform State in S3 with DynamoDB Locking
How to configure Terraform remote state with S3 backend and DynamoDB locking, covering state isolation, workspace management, encryption, and CI/CD integration.
RecipeIsolate Environments with Terraform Workspaces
How to use Terraform workspaces for environment isolation, covering workspace creation, conditional resources, variable management, and migration to separate state files.
Recipea Custom Terraform Provider with Python and
Extend Terraform with a custom provider using Python and the terraform-plugin-framework to manage external resources.
GuidePlatform Engineering — Building Internal Developer Platforms
A practical guide to platform engineering: IDP concepts, golden paths, self-service infrastructure, and tools like Backstage, Crossplane, and Terraform.