StackPractices
intermediate By Mathias Paulenko

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.

flowchart diagram: Root module<br/>environments/prod

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.

ApproachBest For
WorkspacesSimple environments (dev/staging/prod)
Separate directoriesComplex environments with different configurations
Separate backendsMaximum 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 import for 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.