Introduction
After 75 days of Terraform practice, you've likely deployed infrastructure successfully—but success and safety aren't the same thing. We've seen teams move fast early, then hit a wall when a typo in a shared state file causes a production outage, or when developers apply infrastructure changes without review. This article walks through the Terraform best practices checklist: a step-by-step guide that separates teams running stable infrastructure from those fighting constant incidents. These aren't theoretical ideals; they're habits from real teams managing e-commerce platforms, SaaS backends, and trading systems in production.
Remote state with locking—Never local state for shared infrastructure
Local state works fine for learning or solo projects. The moment your infrastructure touches a shared environment—staging, production, or even a team sandbox—local state becomes a liability.
Here's what goes wrong: Developer A runs terraform apply locally. Developer B, unaware, runs terraform plan on outdated state, sees differences that don't exist, and accidentally destroys a database. Or worse, both run apply simultaneously, and Terraform has no way to coordinate—it writes conflicting changes to files on disk.
Remote state backends solve this by storing state in a shared, versioned location. More importantly, they support locking: when Developer A runs apply, the backend locks the state file. Developer B's plan blocks until the lock is released, preventing concurrent modifications.
Setting up remote state with S3 and DynamoDB
AWS S3 paired with DynamoDB is the standard choice for teams already on AWS. S3 stores the actual state; DynamoDB provides the lock mechanism.
terraform {
backend "s3" {
bucket = "my-org-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The encrypt = true flag ensures state is encrypted at rest—critical because Terraform state contains sensitive data: database passwords, API keys, private key material.
Before you push this backend configuration to your repository, ensure the S3 bucket and DynamoDB table exist. Terraform can't bootstrap its own backend, so create them manually or with a separate Terraform root module:
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-org-terraform-state"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Once the backend is configured, terraform init will migrate local state to the remote backend. Subsequent applies automatically acquire and release locks.
Small, focused stacks—Separate state by component and environment
A single Terraform state file managing your entire infrastructure—all environments, all components—feels efficient at first. You run one apply and everything is in sync. This breaks spectacularly as you scale.
Imagine a state file containing VPC configuration, databases, Kubernetes clusters, and Lambda functions. A junior engineer typos a variable in the Lambda section. The apply fails partway through, leaving your database subnet partially modified. Rolling back requires careful manual intervention. Worse, the lock on the state file blocks your entire team until it's resolved.
The solution is to organize Terraform into separate, focused stacks. Each stack has its own state file and backend configuration. A typical structure for a SaaS platform might look like:
infrastructure/network—VPC, subnets, security groupsinfrastructure/database—RDS, Aurora, parameter groupsinfrastructure/kubernetes—EKS cluster, node groups, add-onsapplications/api—Application-specific resources
Each directory is a separate Terraform root module with its own backend.tf:
// infrastructure/database/backend.tf
terraform {
backend "s3" {
bucket = "my-org-terraform-state"
key = "prod/database/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Stacks that depend on outputs from other stacks use data sources to read that remote state. For example, the Kubernetes stack queries the network stack's output to find the VPC ID:
// infrastructure/kubernetes/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-org-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_eks_cluster" "main" {
name = "my-cluster"
vpc_config {
subnet_ids = data.terraform_remote_state.network.outputs.subnet_ids
}
# ...
}
This approach isolates blast radius. If you need to adjust database configuration, only the database state file is locked. Your team can continue deploying application changes without waiting.
Environment separation
Within each component, you typically need separate infrastructure for development, staging, and production. The key variable is environment. Rather than maintaining three separate directories (which leads to code duplication), use a single root module with environment-specific variable files:
// infrastructure/database/terraform.tfvars.prod
environment = "prod"
instance_class = "db.r5.2xlarge"
multi_az = true
backup_retention_days = 30
// infrastructure/database/terraform.tfvars.dev
environment = "dev"
instance_class = "db.t3.micro"
multi_az = false
backup_retention_days = 7
Then initialize the backend with a workspace or key path that includes the environment:
terraform init -backend-config="key=prod/database/terraform.tfstate"
terraform apply -var-file="terraform.tfvars.prod"
Plans in pull requests—Review every change before apply
Automation is powerful, but it's also dangerous if it bypasses human judgment. Running terraform apply without a plan review is like merging code without a code review. We've seen teams accidentally delete production databases this way.
The pattern is straightforward: every infrastructure change starts as a pull request. A CI pipeline runs terraform plan, posts the output to the PR for review, and only after approval does apply run.
GitHub Actions workflow example
name: Terraform Plan & Apply
on:
pull_request:
paths:
- 'infrastructure/**'
push:
branches:
- main
paths:
- 'infrastructure/**'
env:
AWS_REGION: us-east-1
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.7.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Plan
id: plan
run: |
cd infrastructure/database
terraform init
terraform plan -no-color > /tmp/plan.txt
cat /tmp/plan.txt
- name: Comment PR with plan
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('/tmp/plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```\n' + plan + '\n```'
});
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.7.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Apply
run: |
cd infrastructure/database
terraform init
terraform apply -auto-approve
This workflow ensures that every production change is reviewed before it runs. Plan output is posted as a comment on the PR, so reviewers see exactly what Terraform will create, modify, or destroy. A second set of eyes catches mistakes—a -1 replica count that would break your deployment, or a security group rule that's too permissive.
Policies and scans—Security and cost checks in CI
A plan review catches obvious errors, but it doesn't scale for every detail. Policy as code tools enforce standards automatically: security posture, cost controls, naming conventions, and compliance requirements.
Sentinel (HashiCorp's policy language) and OPA (Open Policy Agent) are the main choices. Sentinel is tightly integrated with Terraform Enterprise; OPA is open source and works anywhere.
OPA example: Enforce encryption at rest
package terraform
deny[msg] {
resource := input.planned_values.resources[_]
resource.type == "aws_s3_bucket"
resource.values.server_side_encryption_configuration == null
msg := sprintf("S3 bucket %s must have server-side encryption", [resource.name])
}
deny[msg] {
resource := input.planned_values.resources[_]
resource.type == "aws_rds_cluster"
resource.values.storage_encrypted != true
msg := sprintf("RDS cluster %s must have encryption enabled", [resource.name])
}
Integrate OPA into your CI pipeline by converting the Terraform plan to JSON and passing it to OPA:
- name: Terraform Plan (JSON)
run: |
cd infrastructure/database
terraform plan -json > plan.json
- name: Run OPA checks
uses: open-policy-agent/setup-opa@v1
- name: Evaluate policies
run: |
opa eval -d policies.rego -i plan.json "data.terraform.deny"
If the policy check fails, the pipeline stops and the PR cannot merge. This prevents infrastructure that violates your standards from reaching production.
For cost checks, tools like Infracost integrate directly with Terraform and estimate spend before apply:
- name: Run Infracost
uses: infracost/actions/setup@v2
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate cost estimate
run: |
cd infrastructure/database
infracost breakdown --path . --format json > costs.json
infracost comment github --path costs.json
Cost estimates appear on the PR. If a single change would increase monthly spend by 10x, reviewers see it immediately and question whether the change is necessary.
Common mistakes
- Committing tfstate to version control. Terraform state contains secrets. Even in a private repository, it's a security risk. Always use remote state.
- One massive state file. It slows down operations, increases lock contention, and makes rollback risky. Split into small, focused stacks from the start.
- Running apply without a plan.
terraform apply -auto-approvehas its place in CI, but only after a reviewed plan. Never run it locally without checking the output first. - Ignoring policy violations. If you have OPA policies, don't disable them to "move faster." They exist for a reason. Fix the infrastructure instead.
- Storing sensitive data in variables files. Use AWS Secrets Manager, HashiCorp Vault, or a similar service. Variable files are often logged and cached.
- Not testing Terraform changes.strong> Use tools like Terratest or Terraform Cloud's run tasks to validate changes in a sandbox before production.
Key takeaways
- Remote state with locking is non-negotiable for any shared infrastructure. Use S3 and DynamoDB on AWS, or equivalent services on other clouds.
- Organize infrastructure into small, focused stacks. Separate by component and environment to limit blast radius and reduce lock contention.
- Every production change must go through a PR with a plan review. Automate the plan step, but keep humans in the approval loop.
- Layer in policy as code (Sentinel or OPA) and cost estimation (Infracost) to catch violations and surprises before they reach production.
- These practices compound: remote state enables locking, small stacks reduce risk, plans enable review, and policies enforce consistency. Together, they're the backbone of reliable infrastructure automation.






Comments
Loading comments…