Best Practices
Directory Layout
Section titled “Directory Layout”Small Projects
Section titled “Small Projects”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 configMedium Projects (Environments)
Section titled “Medium Projects (Environments)”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.tfEach 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.
Large Projects (Layered)
Section titled “Large Projects (Layered)”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.tfLayers reference each other’s outputs through remote state data sources:
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.
Naming Conventions
Section titled “Naming Conventions”Resources
Section titled “Resources”Use descriptive, lowercase names with underscores:
# Goodresource "aws_instance" "web_server" { ... }resource "aws_security_group" "web_sg" { ... }resource "aws_db_instance" "primary" { ... }
# Avoidresource "aws_instance" "instance1" { ... } # meaningless nameresource "aws_instance" "WebServer" { ... } # mixed caseWhen there’s only one of something, use this or main:
resource "aws_vpc" "this" { ... }Variables
Section titled “Variables”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
Section titled “Workspaces”Workspaces let you use the same config with separate state files. Each workspace has its own state:
terraform workspace list # * defaultterraform workspace new devterraform workspace new prodterraform workspace select devterraform workspace show # devReference the workspace in config:
resource "aws_instance" "web" { instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro" tags = { Environment = terraform.workspace }}When to Use Workspaces
Section titled “When to Use Workspaces”- Quick environment switching for simple projects.
- Testing changes in isolation.
When Not to Use Workspaces
Section titled “When Not to Use Workspaces”- 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.
Remote State Best Practices
Section titled “Remote State Best Practices”- 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 backendterraform { backend "s3" { bucket = "my-terraform-state" key = "dev/terraform.tfstate" # separate key per environment region = "us-east-1" dynamodb_table = "terraform-locks" encrypt = true }}CI/CD Integration
Section titled “CI/CD Integration”The standard Terraform CI/CD pattern:
PR opened → terraform plan → post plan output as PR commentPR merged → terraform apply -auto-approveGitHub Actions Example
Section titled “GitHub Actions Example”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/prodCI/CD Checklist
Section titled “CI/CD Checklist”- Run
terraform fmt -checkandterraform validateon every PR. - Run
terraform planon every PR, post the output as a comment for review. - Run
terraform applyonly 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.
General Guidelines
Section titled “General Guidelines”- Don’t repeat yourself — Extract shared patterns into modules.
- Pin provider versions — Avoid
>= x.xwithout an upper bound:
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } required_version = ">= 1.5"}- Commit the lock file —
.terraform.lock.hclensures consistent provider versions across machines. - Don’t commit state — Add
*.tfstateand.terraform/to.gitignore. - Don’t commit
.tfvarswith secrets — Use environment variables, CI/CD secrets, or Vault instead. - Run
terraform planbefore every apply — Even in CI. Never blind-apply. - Use
terraform_dataovernull_resource— It’s the modern replacement (Terraform 1.4+).
Testing
Section titled “Testing”terraform validate
Section titled “terraform validate”The simplest check — verifies syntax and internal consistency without calling any APIs:
terraform validateRun this in CI on every PR. It catches typos, missing variables, and type errors.
terraform test (Native, Terraform 1.6+)
Section titled “terraform test (Native, Terraform 1.6+)”Terraform’s built-in test framework. Tests live in .tftest.hcl files alongside your config:
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:
terraform test # run all teststerraform test -filter=tests/main.tftest.hcl # specific test fileterraform test -verbose # detailed outputWith command = plan, tests validate the plan output without creating resources. With command = apply, tests create real resources and destroy them after.
Documentation With terraform-docs
Section titled “Documentation With terraform-docs”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:
terraform-docs markdown table --output-file README.md .Linting With tflint
Section titled “Linting With tflint”tflint catches issues that terraform validate misses — invalid instance types, deprecated arguments, naming violations:
tflint --init # download rule pluginstflint # run checkstflint --fix # auto-fix where possibleExample .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}Policy as Code With Checkov / OPA
Section titled “Policy as Code With Checkov / OPA”Checkov scans Terraform for security and compliance issues:
checkov -d . # scan current directorycheckov -f main.tf # scan specific fileIt 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
Security anti-patterns to avoid
Section titled “Security anti-patterns to avoid”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?”).
Integration Testing With Terratest
Section titled “Integration Testing With Terratest”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.
Testing Strategy
Section titled “Testing Strategy”| Level | Tool | Speed | What It Catches |
|---|---|---|---|
| Syntax | terraform validate | Instant | Missing vars, type errors |
| Lint | tflint | Seconds | Invalid values, bad patterns |
| Policy | Checkov / OPA | Seconds | Security / compliance |
| Unit | terraform test (plan) | Seconds | Logic, conditions, outputs |
| Integration | terraform test (apply) / Terratest | Minutes | Real resource behavior |
Run the fast checks on every PR; reserve integration tests for module changes or nightly runs.
Pre-commit Hooks
Section titled “Pre-commit Hooks”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.
.gitignore for Terraform
Section titled “.gitignore for Terraform”.terraform/*.tfstate*.tfstate.backup*.tfplancrash.logoverride.tfoverride.tf.json*_override.tf*_override.tf.jsonKey Takeaways
Section titled “Key Takeaways”- 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+planon PR,applyon 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 testfor logic; Terratest for integration.