Skip to content

Best Practices

First PublishedLast UpdatedByAtif Alam

A single directory with separate files by concern:

terraform/
main.tf # resources
variables.tf # input variables
outputs.tf # output values
providers.tf # provider config + required_providers
terraform.tfvars # default variable values
backend.tf # remote state backend config

Separate directories per environment, shared modules:

terraform/
modules/
vpc/
main.tf
variables.tf
outputs.tf
app/
main.tf
variables.tf
outputs.tf
environments/
dev/
main.tf # calls modules with dev values
terraform.tfvars
backend.tf
staging/
main.tf
terraform.tfvars
backend.tf
prod/
main.tf
terraform.tfvars
backend.tf

Each environment has its own state, its own backend config, and its own variable values — but they all call the same modules. For a full sample with code, see Sample — Dev environment.

Split infrastructure into layers that can be planned/applied independently:

terraform/
modules/
vpc/
rds/
app/
layers/
network/ # VPC, subnets, NAT gateways
main.tf
backend.tf
database/ # RDS, security groups
main.tf
backend.tf
application/ # ECS/EKS, load balancer
main.tf
backend.tf

Layers reference each other’s outputs through remote state data sources:

layers/database/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_db_subnet_group" "main" {
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
}

This limits the blast radius — a change to the app layer can’t accidentally break the network.

Use descriptive, lowercase names with underscores:

# Good
resource "aws_instance" "web_server" { ... }
resource "aws_security_group" "web_sg" { ... }
resource "aws_db_instance" "primary" { ... }
# Avoid
resource "aws_instance" "instance1" { ... } # meaningless name
resource "aws_instance" "WebServer" { ... } # mixed case

When there’s only one of something, use this or main:

resource "aws_vpc" "this" { ... }

Prefix with the resource or purpose:

variable "vpc_cidr" { ... }
variable "app_instance_type" { ... }
variable "db_password" { ... }

Apply consistent tags to all resources:

locals {
common_tags = {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
Owner = var.team
}
}

Use default_tags in the provider for AWS:

provider "aws" {
region = "us-east-1"
default_tags {
tags = local.common_tags
}
}

Workspaces let you use the same config with separate state files. Each workspace has its own state:

Terminal window
terraform workspace list # * default
terraform workspace new dev
terraform workspace new prod
terraform workspace select dev
terraform workspace show # dev

Reference the workspace in config:

resource "aws_instance" "web" {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
tags = {
Environment = terraform.workspace
}
}
  • Quick environment switching for simple projects.
  • Testing changes in isolation.
  • When environments differ significantly (different providers, regions, accounts) — use separate directories instead.
  • In CI/CD — separate directories are easier to reason about than workspace switching in a pipeline.

Most teams at scale prefer the directory-per-environment pattern over workspaces.

  • Always use a remote backend for team or CI/CD workflows.
  • Enable encryption on the storage backend (S3, Azure Blob, GCS).
  • Enable state locking (DynamoDB for S3, built-in for Azure/GCS/Terraform Cloud).
  • Restrict access — Only CI/CD and authorized humans should read/write state.
  • Use separate state per environment/layer — Never share a single state across environments.
# dev backend
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "dev/terraform.tfstate" # separate key per environment
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}

The standard Terraform CI/CD pattern:

PR opened → terraform plan → post plan output as PR comment
PR merged → terraform apply -auto-approve
name: Terraform
on:
pull_request:
paths: ["terraform/**"]
push:
branches: [main]
paths: ["terraform/**"]
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
working-directory: terraform/environments/prod
- run: terraform fmt -check
working-directory: terraform/environments/prod
- run: terraform validate
working-directory: terraform/environments/prod
- run: terraform plan -no-color -out=tfplan
working-directory: terraform/environments/prod
# Post plan output as PR comment (use a community action)
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
working-directory: terraform/environments/prod
- run: terraform apply -auto-approve
working-directory: terraform/environments/prod
  • Run terraform fmt -check and terraform validate on every PR.
  • Run terraform plan on every PR, post the output as a comment for review.
  • Run terraform apply only on merge to main (or a deploy branch).
  • Use saved plans (-out=tfplan + apply tfplan) to ensure you apply exactly what was reviewed.
  • Store cloud credentials as CI/CD secrets, never in code.
  • Use OIDC (OpenID Connect) for keyless authentication to AWS/Azure/GCP from GitHub Actions.
  • Don’t repeat yourself — Extract shared patterns into modules.
  • Pin provider versions — Avoid >= x.x without an upper bound:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.5"
}
  • Commit the lock file.terraform.lock.hcl ensures consistent provider versions across machines.
  • Don’t commit state — Add *.tfstate and .terraform/ to .gitignore.
  • Don’t commit .tfvars with secrets — Use environment variables, CI/CD secrets, or Vault instead.
  • Run terraform plan before every apply — Even in CI. Never blind-apply.
  • Use terraform_data over null_resource — It’s the modern replacement (Terraform 1.4+).

The simplest check — verifies syntax and internal consistency without calling any APIs:

Terminal window
terraform validate

Run this in CI on every PR. It catches typos, missing variables, and type errors.

Terraform’s built-in test framework. Tests live in .tftest.hcl files alongside your config:

tests/main.tftest.hcl
variables {
instance_type = "t3.micro"
environment = "test"
}
run "creates_instance" {
command = plan # or apply (creates real resources)
assert {
condition = aws_instance.web.instance_type == "t3.micro"
error_message = "Instance type should be t3.micro"
}
assert {
condition = aws_instance.web.tags["Environment"] == "test"
error_message = "Environment tag should be test"
}
}
run "validates_vpc_cidr" {
command = plan
assert {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid CIDR block"
}
}

Run tests:

Terminal window
terraform test # run all tests
terraform test -filter=tests/main.tftest.hcl # specific test file
terraform test -verbose # detailed output

With command = plan, tests validate the plan output without creating resources. With command = apply, tests create real resources and destroy them after.

terraform-docs generates module documentation (inputs, outputs, providers) from your .tf files. Keep a generated README.md or inject docs in CI so consumers see what variables and outputs a module has:

Terminal window
terraform-docs markdown table --output-file README.md .

tflint catches issues that terraform validate misses — invalid instance types, deprecated arguments, naming violations:

Terminal window
tflint --init # download rule plugins
tflint # run checks
tflint --fix # auto-fix where possible

Example .tflint.hcl config:

plugin "aws" {
enabled = true
version = "0.31.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_naming_convention" {
enabled = true
}

Checkov scans Terraform for security and compliance issues:

Terminal window
checkov -d . # scan current directory
checkov -f main.tf # scan specific file

It flags things like:

  • S3 buckets without encryption
  • Security groups open to 0.0.0.0/0
  • IAM policies that are too permissive
  • Missing logging or monitoring

When reviewing Terraform (e.g. in exercises or PRs), watch for:

  • Security group ingress from 0.0.0.0/0 — Opening SSH, RDP, or app ports to the whole internet is a common mistake. Prefer variables for allowed CIDRs (e.g. office VPN, CI IPs) or use a known prefix; restrict to the minimum required.
  • Overly permissive IAM — Avoid "Action": "*" or broad wildcards; scope policies to the resources and actions the workload needs.
  • Secrets in code or tfvars — Use variables with sensitive = true, inject via env or a secret store; never commit passwords or keys.

Tools like Checkov and tflint help catch these; design reviews matter for architecture (e.g. “why is this in a public subnet?”).

Terratest (Go) deploys real infrastructure, validates it, and tears it down:

func TestVpc(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
}

Terratest is heavier (requires real cloud resources and a Go test harness) but gives the highest confidence.

LevelToolSpeedWhat It Catches
Syntaxterraform validateInstantMissing vars, type errors
LinttflintSecondsInvalid values, bad patterns
PolicyCheckov / OPASecondsSecurity / compliance
Unitterraform test (plan)SecondsLogic, conditions, outputs
Integrationterraform test (apply) / TerratestMinutesReal resource behavior

Run the fast checks on every PR; reserve integration tests for module changes or nightly runs.

Running terraform fmt, terraform validate, tflint, and (optionally) terraform-docs on every commit keeps the repo clean. pre-commit can run these before a commit is created:

# .pre-commit-config.yaml (example)
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: tflint
- repo: https://github.com/terraform-docs/terraform-docs
rev: v0.18.0
hooks:
- id: terraform_docs
args: [--output-file, README.md, .]

Bonus when discussing engineering practice: using pre-commit (or similar) shows you automate quality checks locally and reduce broken PRs.


.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
  • Organize by environment (directories) and concern (modules). Keep blast radius small with layered state.
  • Use consistent naming: lowercase, underscores, descriptive names.
  • Always use remote state with locking and encryption for team workflows.
  • Prefer directory-per-environment over workspaces for non-trivial setups.
  • CI/CD: fmt -check + validate + plan on PR, apply on merge.
  • Pin provider and Terraform versions. Commit the lock file. Never commit state or secrets.
  • Test in layers: validate + tflint + Checkov on every PR; terraform test for logic; Terratest for integration.