A field-tested collection of patterns for writing maintainable, secure, and team-friendly Terraform configurations. These practices come from managing production AWS infrastructure across dozens of projects.
The single most impactful decision is how you organize files. A flat directory with everything in main.tf works for tutorials but breaks down fast in real projects.
Recommended layout:
project/
├── backend.tf # Provider and backend config
├── variables.tf # All input variables
├── outputs.tf # All outputs
├── terraform.tfvars # Variable values (git-ignored)
├── modules/
│ ├── vpc/
│ ├── ecs/
│ └── rds/
└── environments/
├── dev/main.tf # Composes modules for dev
└── prod/main.tf # Composes modules for prod
Why separate environments into directories instead of workspaces? Workspaces share the same backend config and state bucket key prefix. If you need different provider configurations, different module versions, or different backend settings per environment, directory-based separation is cleaner. Workspaces work well for identical environments that differ only in variable values.
Good modules are the building blocks of maintainable infrastructure. Follow these principles:
Each module should manage one logical resource group. A VPC module creates a VPC, subnets, route tables, and gateways. It should not also create EC2 instances or RDS databases.
# Good: The caller decides the behavior
variable "multi_az" {
type = bool
default = true
}
# Bad: The caller decides the implementation detail
variable "availability_zone_count" {
type = number
default = 3
}Every variable should have a default that works for the most common case. This lets new team members use the module immediately without reading every variable description.
variable "instance_class" {
type = string
default = "db.t3.medium"
description = "RDS instance class. Use db.r6g.* for production workloads."
}validation blocks for input constraintsCatch configuration mistakes at plan time instead of discovering them during apply:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "vpc_cidr" {
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}If a module creates a resource, output its ID, ARN, and any connection strings. It costs nothing and saves future refactoring.
Terraform state is the source of truth for what infrastructure exists. Mismanaging state is the #1 cause of Terraform disasters.
Local state files get lost, cannot be shared, and offer no locking. Use S3 + DynamoDB (AWS), GCS (GCP), or Azure Blob Storage as your backend.
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "project/env/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}Without locking, two engineers running terraform apply simultaneously will corrupt state. DynamoDB-based locking is the standard for AWS.
Never share state between dev and prod. A mistake in dev should not be able to affect prod resources. Use different S3 keys:
dev/terraform.tfstatestaging/terraform.tfstateprod/terraform.tfstateState files contain sensitive data: database passwords, private keys, API tokens. Always enable server-side encryption on your state bucket and consider using a KMS key for additional control.
If you need to move or remove resources from state, use terraform state mv and terraform state rm. Hand-editing terraform.tfstate will break checksums and can corrupt your infrastructure mapping.
Use AWS Secrets Manager or SSM Parameter Store, and reference them in Terraform:
resource "aws_db_instance" "main" {
# Let AWS manage the password in Secrets Manager
manage_master_user_password = true
}For secrets needed during apply (API keys, tokens), use environment variables:
export TF_VAR_datadog_api_key="abc123"
terraform applyFor CI/CD pipelines, use OIDC federation (GitHub Actions, GitLab CI) or IAM roles (EC2, ECS) instead of long-lived access keys. This starter kit includes an OIDC provider configuration for GitHub Actions.
Scope IAM policies to specific resources using ARN patterns:
# Good: scoped to specific bucket
Resource = "arn:aws:s3:::myapp-prod-*/*"
# Bad: wildcard access
Resource = "*"S3 buckets, RDS instances, and Elasticsearch domains should never be publicly accessible. Use security groups and bucket policies to control access.
Consistent tagging is critical for cost allocation, access control, and incident response.
default_tags in the providerApply organization-wide tags automatically:
provider "aws" {
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Team = var.team
}
}
}| Tag | Purpose | Example |
|---|---|---|
Project | Group resources by project | myapp |
Environment | Identify environment | prod |
ManagedBy | Distinguish IaC from manual | terraform |
Team | Cost allocation and ownership | platform |
Never auto-apply on pull request. The workflow should be:
1. PR is opened: terraform plan runs and posts the plan as a PR comment
2. Team reviews the plan diff alongside the code changes
3. PR is merged to main: terraform apply -auto-approve runs
-no-color for CI logsTerraform color codes don't render well in CI logs. Always pass -no-color in CI pipelines.
Inconsistent versions across team members and CI cause drift:
terraform {
required_version = "~> 1.7.0" # Allow 1.7.x patches only
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}count and for_each to conditionally create resourcesDon't pay for NAT Gateways in dev if you don't need them:
resource "aws_nat_gateway" "main" {
count = var.environment == "prod" ? length(var.availability_zones) : 1
# Prod: one NAT per AZ for HA. Dev: one NAT to save ~$32/month per gateway.
}Use db.t3.medium in dev and db.r6g.large in prod. Make instance classes a variable with per-environment defaults.
Transition objects to cheaper storage tiers automatically:
transition {
days = 90
storage_class = "STANDARD_IA" # ~40% cheaper for infrequent access
}
transition {
days = 365
storage_class = "GLACIER" # ~80% cheaper for archival
}terraform validate and terraform fmt in CIThese catch syntax errors and enforce consistent formatting with zero configuration.
terraform plan as a testA clean plan against an existing environment confirms your changes are additive and non-destructive. Look for (destroy) actions in the plan output — they usually indicate a mistake.
For modules that manage production databases or networking, write Go tests with Terratest that create real infrastructure, validate it, and tear it down.
lifecycle.create_before_destroySecurity groups, parameter groups, and IAM policies often need replacement. Without this lifecycle rule, Terraform deletes the old resource before creating the new one, causing downtime.
prevent_destroy for stateful resourcesDatabases and S3 buckets with important data should use prevent_destroy:
lifecycle {
prevent_destroy = true
}depends_on when neededMost dependencies are inferred from resource references. But some (like IAM policy propagation) need explicit depends_on to avoid race conditions during apply.
terraform importIf you find yourself importing many resources, consider whether Terraform is the right tool for that resource. Some resources (DNS records managed by external teams, legacy VPCs) are better left outside Terraform.
*Part of the Terraform Starter Kit by Datanest Digital (datanest.dev)*
Production-ready AWS infrastructure modules with CI/CD, remote state, and environment separation.
A complete Terraform project scaffold for deploying secure, scalable AWS infrastructure. Includes VPC networking, ECS Fargate compute, RDS databases, S3 storage, CloudFront CDN, and IAM — all wired together with remote state, locking, and a GitHub Actions pipeline.
The starter kit follows the recommended Terraform layout with separate environments, reusable modules, and standardized variable/output conventions:
terraform-starter-kit/
├── backend.tf # S3 + DynamoDB remote state config
├── variables.tf # Global input variables
├── outputs.tf # Global outputs
├── terraform.tfvars.example # Variable template (git-ignored)
├── modules/
│ ├── vpc/ # VPC, subnets, route tables, NAT gateways
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── ecs/ # ECS Fargate cluster, task defs, services
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── rds/ # RDS instances, parameter groups, subnets
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── s3/ # S3 buckets with lifecycle policies
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── iam/ # IAM roles, policies, instance profiles
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── cloudfront/ # CloudFront distributions, origins, WAF
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ ├── main.tf # Dev composition (smaller instances, single-AZ)
│ │ ├── terraform.tfvars
│ │ └── backend.conf
│ └── prod/
│ ├── main.tf # Prod composition (HA, multi-AZ, reserved)
│ ├── terraform.tfvars
│ └── backend.conf
├── .github/
│ └── workflows/
│ └── terraform.yml # Plan on PR, apply on merge
├── scripts/
│ ├── bootstrap.sh # Create S3 bucket + DynamoDB lock table
│ └── destroy.sh # Safe teardown with confirmation prompts
└── Makefile # Common commands: validate, plan, apply, destroy
Environments use directory-based separation instead of Terraform workspaces. Each environment has its own state file, provider configuration, and variable values:
# environments/dev/backend.conf
bucket = "myorg-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = trueThis isolation ensures that a misconfiguration in dev cannot corrupt prod state.
The backend configuration uses S3 for state storage and DynamoDB for state locking:
# backend.tf
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}The bootstrap script creates both the S3 bucket and DynamoDB table with encryption and lifecycle policies:
# One-time setup
./scripts/bootstrap.sh myorg-terraform-state us-east-1Each module follows a consistent pattern with variable validation and comprehensive outputs:
# modules/vpc/variables.tf
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "enable_nat_gateway" {
type = bool
default = true
}The kit pins provider versions to avoid unexpected upgrades:
terraform {
required_version = "~> 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}# 1. Clone and bootstrap remote state
git clone <repo> && cd terraform-starter-kit
./scripts/bootstrap.sh
# 2. Configure variables
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your values
# 3. Deploy dev environment
cd environments/dev
terraform init -backend-config=backend.conf
terraform plan -out=tfplan
terraform apply tfplanGet the full Terraform Starter Kit and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.