Intermediate
Learn Terraform production patterns through 28 annotated code examples covering modules, remote state, workspaces, provisioners, dynamic configuration, and import workflows. Each example is self-contained and demonstrates real-world infrastructure patterns.
Group 10: Modules & Composition
Example 29: Basic Module Structure
Modules are reusable Terraform configurations called from root configurations. Modules enable DRY principles, standardization, and composition.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161 graph TD A["Root Configuration<br/>main.tf"] --> B["Module: network<br/>modules/network"] A --> C["Module: compute<br/>modules/compute"] B --> D["VPC Resources"] C --> E["Instance Resources"] C --> B style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff style E fill:#CC78BC,color:#fff
Module structure:
project/
├── main.tf # => Root configuration (calls modules)
├── variables.tf
├── outputs.tf
└── modules/
└── storage/ # => Module directory
├── main.tf # => Module resources
├── variables.tf # => Module inputs
└── outputs.tf # => Module outputsModule code - modules/storage/variables.tf:
variable "bucket_prefix" {
# => Input variable
description = "Prefix for bucket name" # => String value
# => Sets description
type = string
# => Sets type
}
variable "environment" {
# => Input variable
description = "Environment name" # => String value
# => Sets description
type = string
# => Sets type
}
variable "tags" {
# => Input variable
description = "Resource tags" # => String value
# => Sets description
type = map(string)
# => Sets type
default = {} # => Map/object definition
# => Sets default
}Module code - modules/storage/main.tf:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
# Module creates local file (simulating cloud storage)
resource "local_file" "bucket" {
# => Resource definition
filename = "${var.bucket_prefix}-${var.environment}-bucket.txt" # => String interpolation
# => Sets filename
content = <<-EOT
# => Sets content
Bucket: ${var.bucket_prefix}-${var.environment}
Environment: ${var.environment}
Tags: ${jsonencode(var.tags)}
EOT
# => Module manages resources independently
}
resource "local_file" "bucket_policy" {
# => Resource definition
filename = "${var.bucket_prefix}-${var.environment}-policy.txt" # => String interpolation
# => Sets filename
content = "Bucket policy for ${local_file.bucket.filename}" # => String value
# => Sets content
}Module code - modules/storage/outputs.tf:
output "bucket_name" {
# => Output value
description = "Name of the bucket" # => String value
# => Sets description
value = local_file.bucket.filename
# => Sets value
}
output "bucket_id" {
# => Output value
description = "ID of the bucket" # => String value
# => Sets description
value = local_file.bucket.id
# => Sets value
}
output "policy_name" {
# => Output value
description = "Name of the policy file" # => String value
# => Sets description
value = local_file.bucket_policy.filename
# => Sets value
}Root configuration - main.tf:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Call module with inputs
module "dev_storage" {
# => Module configuration
source = "./modules/storage" # => Relative path to module
# => Sets source
bucket_prefix = "myapp" # => Pass to var.bucket_prefix
# => Sets bucket_prefix
environment = "development" # => Pass to var.environment
# => Sets environment
tags = { # => Map/object definition
Team = "platform"
# => Sets Team
ManagedBy = "Terraform"
# => Sets ManagedBy
}
# => Module inputs passed as arguments
}
# Call same module with different inputs
module "prod_storage" {
# => Module configuration
source = "./modules/storage" # => String value
# => Sets source
bucket_prefix = "myapp" # => String value
# => Sets bucket_prefix
environment = "production" # => String value
# => Sets environment
tags = { # => Map/object definition
Team = "platform"
# => Sets Team
ManagedBy = "Terraform"
# => Sets ManagedBy
Critical = "true"
# => Sets Critical
}
}
# Use module outputs
output "dev_bucket" {
# => Output value
value = module.dev_storage.bucket_name # => Reference module output
# => Sets value
}
output "prod_bucket" {
# => Output value
value = module.prod_storage.bucket_name
# => Sets value
}
output "all_buckets" {
# => Output value
value = {
dev = module.dev_storage.bucket_name
# => Sets dev
prod = module.prod_storage.bucket_name
# => Sets prod
}
}Usage:
# Initialize (downloads modules)
# $ terraform init
# => Downloads local module (copies to .terraform/modules)
# Plan shows resources from both module calls
# $ terraform plan
# => module.dev_storage.local_file.bucket will be created
# => module.dev_storage.local_file.bucket_policy will be created
# => module.prod_storage.local_file.bucket will be created
# => module.prod_storage.local_file.bucket_policy will be created
# Apply creates resources from all modules
# $ terraform apply
# Access module outputs
# $ terraform output dev_bucket
# => myapp-development-bucket.txtKey Takeaway: Modules are directories containing Terraform configurations with variables.tf (inputs), main.tf (resources), and outputs.tf (outputs). Call modules with module block and source pointing to module directory. Reference module outputs with module.name.output_name. Modules enable reusable infrastructure patterns.
Why It Matters: Modules are the foundation of scalable Terraform infrastructure, enabling reusable patterns that prevent copy-paste duplication across environments and teams. Modules enforce organizational standards: platform team publishes “approved VPC module” ensuring all VPCs follow security policies, preventing ad-hoc configurations that create compliance violations. This standardization is impossible with script-based infrastructure where every team invents their own patterns.
Example 30: Module Versioning with Git Sources
Modules can be sourced from Git repositories with version pinning using tags, branches, or commit SHAs. This enables controlled module updates and rollbacks.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Module from Git tag (recommended for production)
module "storage_v1" {
# => Module configuration
source = "git::https://github.com/example/terraform-modules.git//storage?ref=v1.2.3" # => String value
# => source uses Git URL with subdirectory (//storage) and tag (ref=v1.2.3)
# => terraform init clones repository and checks out v1.2.3 tag
# => Changes to module repository don't affect this configuration until ref updated
bucket_prefix = "app-v1" # => String value
# => Sets bucket_prefix
environment = "production" # => String value
# => Sets environment
}
# Module from Git branch (for testing new features)
module "storage_dev" {
# => Module configuration
source = "git::https://github.com/example/terraform-modules.git//storage?ref=feature/new-policy" # => String value
# => ref=feature/new-policy tracks branch (updates with branch changes)
# => terraform get -update pulls latest branch commits
bucket_prefix = "app-dev" # => String value
# => Sets bucket_prefix
environment = "development" # => String value
# => Sets environment
}
# Module from commit SHA (immutable reference)
module "storage_stable" {
# => Module configuration
source = "git::https://github.com/example/terraform-modules.git//storage?ref=abc123def456" # => String value
# => ref=abc123def456 uses exact commit (fully immutable)
# => Guarantees exact module code, even if tags are moved
bucket_prefix = "app-stable" # => String value
# => Sets bucket_prefix
environment = "staging" # => String value
# => Sets environment
}
output "module_sources" {
# => Output value
value = {
v1 = "Tag v1.2.3 (production-safe)"
# => Sets v1
dev = "Branch feature/new-policy (tracking latest)"
# => Sets dev
stable = "Commit abc123def456 (immutable)"
# => Sets stable
}
}Key Takeaway: Use Git tags (ref=v1.2.3) for production modules to control updates explicitly. Branches track latest changes for development environments. Commit SHAs provide immutable references for critical stability. Always version modules to prevent unexpected changes.
Why It Matters: Module versioning prevents production breakage when module authors push breaking changes— Tag-based versioning enables gradual rollouts: test new module version in dev (branch), validate in staging (tag), then promote to production, with instant rollback capability if issues arise. This controlled update pattern is impossible with always-latest module sources.
Example 31: Module Input Validation
Module variables can include validation rules to catch configuration errors at plan time rather than apply time. This provides early feedback and better error messages.
# modules/validated-storage/variables.tf
variable "environment" {
# => Input variable
description = "Environment name" # => String value
# => Sets description
type = string
# => Sets type
validation {
# => Validation rule enforces constraints
condition = contains( # => Checks list membership
["dev", "staging", "prod"], var.environment)
# => condition must return true for validation to pass
# => contains( # => Checks list membership
) checks if environment is in allowed list
error_message = "Environment must be dev, staging, or prod." # => String value
# => error_message shown when validation fails
}
}
variable "bucket_name" {
# => Input variable
description = "S3 bucket name" # => String value
# => Sets description
type = string
# => Sets type
validation {
# => Validation rule enforces constraints
condition = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.bucket_name))
# => can() returns true if regex succeeds, false if it errors
# => regex validates DNS-compliant bucket names (3-63 chars, lowercase)
error_message = "Bucket name must be 3-63 characters, lowercase letters, numbers, hyphens." # => String value
# => Sets error_message
}
validation {
# => Validation rule enforces constraints
condition = !can(regex("^xn--", var.bucket_name))
# => Second validation ensures name doesn't start with xn-- (reserved prefix)
error_message = "Bucket name cannot start with xn-- (reserved for Punycode)." # => String value
# => Sets error_message
}
}
variable "retention_days" {
# => Input variable
description = "Data retention in days" # => String value
# => Sets description
type = number
# => Sets type
validation {
# => Validation rule enforces constraints
condition = var.retention_days >= 1 && var.retention_days <= 365
# => Numeric range validation
error_message = "Retention must be between 1 and 365 days."
# => Sets error_message
}
}
# modules/validated-storage/main.tf
resource "local_file" "bucket" {
# => Resource definition
filename = "${var.bucket_name}-${var.environment}.txt"
# => All variables validated before this resource executes
content = "Retention: ${var.retention_days} days"
# => Sets content
}Root configuration:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
module "storage" {
# => Module instantiation with input variables
source = "./modules/validated-storage" # => String value
# => Sets source
environment = "prod" # => Valid: in allowed list
# => Sets environment
bucket_name = "my-app-data" # => Valid: matches regex
# => Sets bucket_name
retention_days = 90 # => Valid: within range
# => Sets retention_days
}
# This would fail validation at plan time:
# module "invalid" {
# source = "./modules/validated-storage"
# environment = "production" # => Error: not in [dev, staging, prod]
# bucket_name = "MyBucket" # => Error: uppercase not allowed
# retention_days = 400 # => Error: exceeds 365 day maximum
# }Key Takeaway: Module validation rules enforce constraints at terraform plan time using validation blocks with condition and error_message. Use can() for regex validation and contains( # => Checks list membership ) for allowlist checks. Validation prevents invalid configurations from reaching apply phase.
Why It Matters: Input validation catches configuration errors before infrastructure changes, preventing costly mistakes— Regex validation on bucket names prevents AWS API errors that would waste 15 minutes of developer time debugging “InvalidBucketName” failures. Fail-fast validation at plan time beats discovering issues after partial apply when rollback is complex.
Example 32: Module Composition with Dependencies
Modules can depend on other module outputs, creating composition patterns where infrastructure builds on layers. Use depends_on for explicit ordering when implicit dependencies aren’t detected.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161 graph TD A["Module: network"] --> B["Module: compute"] A --> C["Module: database"] B --> D["Module: monitoring"] C --> D style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff
Code:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Layer 1: Network module (no dependencies)
module "network" {
# => Module configuration
source = "./modules/network" # => String value
# => Sets source
vpc_name = "main-vpc" # => String value
# => Sets vpc_name
cidr = "10.0.0.0/16" # => String value
# => Sets cidr
}
# Layer 2: Compute module (depends on network outputs)
module "compute" {
# => Module configuration
source = "./modules/compute" # => String value
# => Sets source
vpc_id = module.network.vpc_id # => Implicit dependency via output reference
# => Sets vpc_id
subnet_id = module.network.subnet_id # => Terraform creates network before compute
# => Sets subnet_id
instance_count = 3 # => Numeric value
# => Sets instance_count
}
# Layer 2: Database module (parallel to compute, depends on network)
module "database" {
# => Data source
source = "./modules/database" # => String value
# => Sets source
vpc_id = module.network.vpc_id
# => Sets vpc_id
subnet_id = module.network.private_subnet_id
# => Sets subnet_id
db_name = "appdb" # => String value
# => Sets db_name
}
# Layer 3: Monitoring module (depends on compute and database)
module "monitoring" {
# => Module configuration
source = "./modules/monitoring"
# => Sets source
compute_endpoints = module.compute.instance_ips # => Depends on compute
# => Sets compute_endpoints
database_endpoint = module.database.connection_url # => Depends on database
# => Sets database_endpoint
# Explicit dependency when outputs don't capture relationship
depends_on = [ # => Explicit dependency list
# => Sets depends_on
module.compute,
module.database,
]
# => Explicit dependency enforces creation order
# => Use when implicit dependencies (output references) don't capture ordering
}
# Cross-layer outputs
output "infrastructure_map" {
# => Output value
value = {
network = {
vpc_id = module.network.vpc_id
# => Sets vpc_id
subnet_id = module.network.subnet_id
# => Sets subnet_id
}
compute = {
instance_ips = module.compute.instance_ips
# => Sets instance_ips
}
database = {
url = module.database.connection_url
# => Sets url
}
monitoring = {
dashboard_url = module.monitoring.dashboard_url
# => Sets dashboard_url
}
}
# => Single output capturing entire infrastructure state
}Key Takeaway: Module dependencies are implicit through output references (Terraform detects module.network.vpc_id usage) or explicit with depends_on meta-argument. Layer infrastructure with independent modules at bottom (network) and dependent modules above (compute, database, monitoring). Explicit depends_on ensures ordering when outputs don’t capture relationships.
Why It Matters: Module composition enables layered infrastructure where teams can own separate concerns—platform team manages network module, application teams consume VPC outputs without understanding networking internals. This separation of concerns prevents the “big ball of infrastructure” anti-pattern where one team controls everything and becomes a bottleneck for all changes.
Example 33: Module Count and For Each
Modules support count and for_each meta-arguments to create multiple instances with different configurations. This enables multi-region deployments and resource multiplication.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Module with count (indexed instances)
module "regional_storage_count" {
# => Module configuration
source = "./modules/storage" # => String value
# => Sets source
count = 3 # => Create 3 module instances
# => Creates specified number of instances
bucket_prefix = "region-${count.index}" # => count.index is 0, 1, 2
# => Produces: region-0, region-1, region-2
environment = "production" # => String value
# => Sets environment
}
# Module with for_each (named instances)
module "regional_storage_map" {
# => Module configuration
source = "./modules/storage" # => String value
# => Sets source
for_each = { # => Map/object definition
# => Creates multiple instances from collection
us-east = { location = "us-east-1", tier = "standard" }
# => Sets us-east
eu-west = { location = "eu-west-1", tier = "premium" }
# => Sets eu-west
ap-south = { location = "ap-south-1", tier = "standard" }
# => Sets ap-south
}
# => for_each creates module.regional_storage_map["us-east"], ["eu-west"], ["ap-south"]
# => each.key is "us-east", "eu-west", "ap-south"
# => each.value is the map value
bucket_prefix = "app-${each.key}" # => each.key references map key
# => Sets bucket_prefix
environment = each.value.tier # => each.value.tier references nested attribute
# => Sets environment
tags = { # => Map/object definition
Location = each.value.location
# => Sets Location
Tier = each.value.tier
# => Sets Tier
}
}
# for_each with set (unique region names)
variable "regions" {
# => Input variable
type = set(string)
# => Sets type
default = ["us-west-2", "eu-central-1", "ap-northeast-1"]
# => Sets default
}
module "regional_storage_set" {
# => Module configuration
source = "./modules/storage"
# => Sets source
for_each = var.regions # => for_each works with sets
# => Creates multiple instances from collection
bucket_prefix = "backup-${each.key}" # => each.key is set element
# => Sets bucket_prefix
environment = "production"
# => Sets environment
}
# Accessing module outputs with count
output "count_bucket_names" {
# => Output value
value = [
# => Sets value
for i in range(3) : module.regional_storage_count[i].bucket_name
]
# => Output: ["region-0-production-bucket.txt", "region-1-..", "region-2-.."]
}
# Accessing module outputs with for_each
output "map_bucket_names" {
# => Output value
value = {
for k, m in module.regional_storage_map : k => m.bucket_name
# => Sets for k, m in module.regional_storage_map : k
}
# => Output: {us-east = "app-us-east-premium-bucket.txt", eu-west = .., ap-south = ..}
}Key Takeaway: Use count for indexed module instances when order matters or quantity is dynamic. Use for_each with maps or sets for named instances with different configurations per region/environment. Access count outputs with module.name[index] and for_each outputs with module.name[key]. for_each is preferred for production (stable references).
Why It Matters: for_each prevents resource address shifting when middle elements are removed—Stripe uses for_each for multi-region infrastructure because removing “eu-west” doesn’t renumber all subsequent regions (unlike count where removing index 1 shifts 2→1, 3→2, causing unintended resource replacement). Map-based for_each enables per-region configuration: us-east gets high-availability tier, eu-west gets standard tier, without duplicating module code. This pattern scaled Stripe from 3 regions to 12 without modifying module logic, just expanding the regions map.
Example 34: Terraform Registry Modules
Public and private Terraform Registry modules enable sharing reusable infrastructure patterns. Registry modules use semantic versioning and documentation standards.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
required_providers {
# => Provider configuration
aws = { # => Map/object definition
source = "hashicorp/aws" # => String value
# => Provider source location
version = "~> 5.0" # => String value
# => Sets version
}
}
}
provider "aws" {
# => Provider configuration
region = "us-west-2" # => String value
# => Sets region
}
# Public registry module (Terraform Registry: registry.terraform.io)
module "vpc_public" {
# => Module configuration
source = "terraform-aws-modules/vpc/aws" # => String value
# => source format: NAMESPACE/NAME/PROVIDER
# => Resolves to: registry.terraform.io/terraform-aws-modules/vpc/aws
version = "5.1.0" # => String value
# => Semantic version (major.minor.patch)
# => terraform init downloads version 5.1.0 from registry
name = "production-vpc" # => String value
# => Sets name
cidr = "10.0.0.0/16" # => String value
# => Sets cidr
azs = ["us-west-2a", "us-west-2b", "us-west-2c"] # => List definition
# => Sets azs
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] # => List definition
# => Sets private_subnets
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] # => List definition
# => Sets public_subnets
enable_nat_gateway = true # => Boolean value
# => Sets enable_nat_gateway
enable_vpn_gateway = false # => Boolean value
# => Sets enable_vpn_gateway
tags = { # => Map/object definition
Environment = "production"
# => Sets Environment
ManagedBy = "Terraform"
# => Sets ManagedBy
}
# => Module inputs documented in registry UI
}
# Private registry module (Terraform Cloud/Enterprise)
module "internal_app" {
# => Module configuration
source = "app.terraform.io/my-org/app-module/aws"
# => Private registry requires organization prefix (app.terraform.io/my-org)
# => Authenticated via TF_TOKEN_app_terraform_io environment variable
version = "~> 2.1"
# => ~> 2.1 allows 2.1.x (patch updates), blocks 2.2.0 (minor updates)
app_name = "payment-service"
# => Sets app_name
environment = "production"
# => Sets environment
}
# Version constraints
module "versioned_example" {
# => Module configuration
source = "terraform-aws-modules/s3-bucket/aws"
# => Provider source location
version = ">= 3.0, < 4.0"
# => Allows any 3.x version, blocks 4.0 (major version upgrade)
# => Multiple constraints combined with commas
bucket = "my-versioned-bucket"
# => Sets bucket
}
output "vpc_id" {
# => Output value
value = module.vpc_public.vpc_id
# => Public module outputs documented in registry
}Key Takeaway: Public registry modules use NAMESPACE/NAME/PROVIDER format and resolve to registry.terraform.io. Private registry modules require organization prefix (app.terraform.io/ORG). Always specify version constraint to control updates. Use semantic versioning: ~> 1.2 (allow patch), >= 1.0, < 2.0 (allow minor).
Why It Matters
Registry modules prevent reinventing common patterns—the terraform-aws-modules VPC module is used by thousands of companies, benefits from community bug fixes, and receives AWS best practice updates without requiring each company to maintain their own VPC logic. Version constraints prevent breaking changes: ~> 3.0 allows security patches (3.0.1, 3.0.2) but blocks 4.0.0 which might rename outputs or change resource behavior. Private registries enable internal standardization:
Example 35: Module Data-Only Pattern
Modules don’t have to create resources—data-only modules encapsulate complex data lookups and transformations, acting as reusable queries.
# modules/data-lookup/variables.tf
variable "environment" {
# => Input variable
type = string
# => Sets type
}
variable "region" {
# => Input variable
type = string
# => Sets type
}
# modules/data-lookup/main.tf
# Data-only module performs lookups without creating resources
data "local_file" "config" {
# => Data source
filename = "${path.module} # => Current module directory path/configs/${var.environment}-${var.region}.json"
# => path.module refers to module directory (not root)
}
locals {
# => Local values
# Parse JSON configuration
config = jsondecode(data.local_file.config.content)
# => Sets config
# Compute derived values
subnet_count = length( # => Returns collection size
local.config.availability_zones)
# => Sets subnet_count
# Complex transformations
subnet_cidrs = [ # => List definition
# => Sets subnet_cidrs
for i, az in local.config.availability_zones :
cidrsubnet(local.config.vpc_cidr, 8, i)
]
}
# modules/data-lookup/outputs.tf
output "vpc_cidr" {
# => Output value
value = local.config.vpc_cidr
# => Sets value
}
output "availability_zones" {
# => Output value
value = local.config.availability_zones
# => Sets value
}
output "subnet_cidrs" {
# => Output value
value = local.subnet_cidrs
# => Computed subnet CIDRs (not in source config)
}
output "instance_type" {
# => Output value
value = local.config.compute.instance_type
# => Sets value
}Root configuration:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Data-only module call (no resources created)
module "prod_config" {
# => Module configuration
source = "./modules/data-lookup" # => String value
# => Sets source
environment = "production" # => String value
# => Sets environment
region = "us-west-2" # => String value
# => Sets region
}
# Use module outputs to configure resources
resource "local_file" "deployment_manifest" {
# => Resource definition
filename = "deployment.txt" # => String value
# => Sets filename
content = <<-EOT
# => Sets content
VPC CIDR: ${module.prod_config.vpc_cidr}
# => Module configuration
AZs: ${jsonencode(module.prod_config.availability_zones)}
# => Module configuration
Subnets: ${jsonencode(module.prod_config.subnet_cidrs)}
# => Module configuration
Instance Type: ${module.prod_config.instance_type}
# => Module configuration
EOT
# => All values from data-only module
}
output "computed_subnets" {
# => Output value
value = module.prod_config.subnet_cidrs
# => Reusable subnet calculation from module
}Key Takeaway: Data-only modules use data sources and locals without resource blocks. They encapsulate complex lookups, JSON/YAML parsing, and computed values. Useful for centralizing environment-specific configurations or multi-step data transformations that are reused across multiple root configurations.
Why It Matters: Data-only modules prevent duplicating complex lookup logic across configurations— Centralizing JSON config parsing in modules ensures all teams parse environment configurations identically, preventing the “staging uses subnet 0, production accidentally uses subnet 1” mistakes that cause network routing failures. This pattern makes complex data transformations reusable and testable independently of resource creation.
Group 11: Remote State Management
Example 36: Local Backend with State File
Terraform state tracks resource metadata and enables change detection. Local backend stores state in terraform.tfstate file in working directory.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161 graph TD A["terraform apply"] --> B["Create Resources"] B --> C["terraform.tfstate<br/>Local File"] C --> D["Next terraform plan"] D --> E["Compare Config vs State"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff style E fill:#CA9161,color:#fff
Code:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets minimum Terraform version
# Local backend (default, no configuration needed)
backend "local" {
# => Backend block configures state storage location
path = "terraform.tfstate" # => State file location (default)
# => Relative to working directory
# => File created on first terraform apply
}
# => Local backend is default (can omit this block)
# => No locking, no encryption, single-user only
}
provider "local" {}
# => Local provider for file operations
resource "local_file" "app_config" {
# => Resource tracked in terraform.tfstate
filename = "app-config.txt" # => String value
# => Sets filename
# => Creates file in current directory
content = "Database URL: db.example.com" # => String value
# => Sets content
# => State records filename, content, id, md5
}
# State stores resource attributes
# After apply, terraform.tfstate contains:
# {
# "resources": [{
# "type": "local_file",
# "name": "app_config",
# "instances": [{
# "attributes": {
# "filename": "app-config.txt",
# "content": "Database URL: db.example.com",
# "id": "abc123..",
# "content_md5": "def456.."
# }
# }]
# }]
# }
# => JSON structure maps config to infrastructure reality
# => Terraform reads state to determine required changes
output "state_location" {
# => Output value showing backend type
value = "terraform.tfstate (local backend)"
# => Displayed after terraform apply
# => Confirms state stored locally
}State file operations:
# $ terraform apply
# => Creates terraform.tfstate with resource metadata
# $ terraform show
# => Displays resources from state file
# # local_file.app_config:
# resource "local_file" "app_config" {
# content = "Database URL: db.example.com"
# filename = "app-config.txt"
# id = "abc123.."
# }
# $ terraform state list
# => Lists all resources in state
# local_file.app_config
# $ terraform state show local_file.app_config
# => Shows specific resource attributes from stateKey Takeaway: Local backend stores state in terraform.tfstate file. State contains resource IDs, attributes, metadata, and dependencies. Terraform compares desired config against state to determine required changes. Local backend suitable for individual development, NOT for team collaboration (no locking, no sharing).
Why It Matters: State is Terraform’s memory of infrastructure—without state, Terraform can’t determine if resources exist, must be created, updated, or deleted. The state file maps local_file.app_config in HCL to actual file app-config.txt on disk. Local backend is dangerous for teams: two people running terraform apply simultaneously can corrupt state, causing resources to be duplicated or deleted. This is why production infrastructure requires remote backends with locking (Examples 37-39).
Example 37: S3 Backend with State Locking
S3 backend stores Terraform state in AWS S3 bucket with DynamoDB table for state locking. This enables team collaboration with concurrent operation safety.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["Terraform Apply"] --> B["Acquire Lock<br/>DynamoDB"] B --> C["Read State<br/>S3 Bucket"] C --> D["Execute Changes"] D --> E["Write State<br/>S3 Bucket"] E --> F["Release Lock<br/>DynamoDB"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff style E fill:#029E73,color:#fff style F fill:#DE8F05,color:#fff
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
required_providers {
# => Provider configuration
aws = { # => Map/object definition
source = "hashicorp/aws" # => String value
# => Provider source location
version = "~> 5.0" # => String value
# => Sets version
}
}
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => S3 bucket name (must exist before terraform init)
key = "production/app/terraform.tfstate" # => String value
# => Object key (path within bucket)
# => Different projects use different keys in same bucket
region = "us-west-2" # => String value
# => Bucket region
encrypt = true # => Boolean value
# => Server-side encryption (AES-256)
# => State may contain secrets (database passwords, API keys)
dynamodb_table = "terraform-state-locks" # => String value
# => DynamoDB table for state locking (must exist)
# => Prevents concurrent terraform apply operations
# => Table must have partition key "LockID" (String type)
}
}
provider "aws" {
# => Provider configuration
region = "us-west-2" # => String value
# => Sets region
}
resource "aws_s3_bucket" "example" {
# => Resource definition
bucket = "my-app-bucket" # => String value
# => Resource managed via S3 backend state
}
# State locking workflow:
# 1. terraform plan/apply acquires lock in DynamoDB
# 2. Reads current state from S3
# 3. Executes operations
# 4. Writes updated state to S3
# 5. Releases lock in DynamoDB
# => If another user runs terraform apply, they wait for lock or failBackend initialization:
# Create S3 bucket and DynamoDB table first (one-time setup):
# $ aws s3 mb s3://mycompany-terraform-state
# $ aws s3api put-bucket-versioning --bucket mycompany-terraform-state --versioning-configuration Status=Enabled
# $ aws dynamodb create-table \
# --table-name terraform-state-locks \
# --attribute-definitions AttributeName=LockID,AttributeType=S \
# --key-schema AttributeName=LockID,KeyType=HASH \
# --billing-mode PAY_PER_REQUEST
# Initialize backend:
# $ terraform init
# => Initializing the backend..
# => Successfully configured the backend "s3"!
# State operations with S3 backend:
# $ terraform apply
# => Acquiring state lock (DynamoDB)
# => State lock acquired (ID: abc-123-def-456)
# => Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
# => Releasing state lock..
# If another user tries to apply simultaneously:
# $ terraform apply
# => Error: Error acquiring the state lock
# => Lock Info:
# => ID: abc-123-def-456
# => Path: mycompany-terraform-state/production/app/terraform.tfstate
# => Operation: OperationTypeApply
# => Who: alice@laptop
# => Created: 2025-01-02 10:30:00 UTCKey Takeaway: S3 backend stores state remotely with encryption (encrypt = true) and uses DynamoDB table for state locking to prevent concurrent modifications. Backend requires pre-existing S3 bucket and DynamoDB table with LockID partition key. State locks prevent multiple users from corrupting state through simultaneous applies.
Why It Matters: S3 backend with DynamoDB locking prevents the state corruption disasters that plague local backends—Coinbase experienced state corruption when two SREs ran terraform apply simultaneously on local state, creating duplicate security groups that broke production networking. S3 backend centralizes state enabling team collaboration: all engineers see same infrastructure state, preventing “works on my machine” issues. Versioning on S3 bucket provides state rollback capability if bad apply corrupts state. Encryption protects secrets in state (database passwords, API keys) from unauthorized access.
Example 38: Backend Configuration with Partial Config
Backend configuration can be partially specified in HCL and completed via CLI flags, environment variables, or config files. This enables reusable configurations across environments.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["main.tf<br/>Static Config"] --> B["region, encrypt<br/>dynamodb_table"] C["backend-prod.hcl<br/>Environment Config"] --> D["bucket, key"] B --> E["terraform init<br/>-backend-config"] D --> E E --> F["Merged<br/>Backend Config"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff style E fill:#0173B2,color:#fff style F fill:#029E73,color:#fff
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
required_providers {
# => Provider configuration
aws = { # => Map/object definition
source = "hashicorp/aws" # => String value
# => Provider source location
version = "~> 5.0" # => String value
# => Sets version
}
}
backend "s3" {
# Static config in HCL
region = "us-west-2" # => String value
# => Sets region
encrypt = true # => Boolean value
# => Sets encrypt
dynamodb_table = "terraform-state-locks" # => String value
# => Sets dynamodb_table
# bucket and key left unspecified (provided at init time)
# => Enables reusing same configuration across environments
}
}
provider "aws" {
# => Provider configuration
region = "us-west-2" # => String value
# => Sets region
}
resource "aws_s3_bucket" "app" {
# => Resource definition
bucket = "my-application-bucket" # => String value
# => Sets bucket
}Backend configuration file - backend-prod.hcl:
bucket = "mycompany-terraform-state"
key = "production/app/terraform.tfstate"
# => Partial backend config (only bucket and key)
# => Merged with backend "s3" block in main.tfBackend configuration file - backend-staging.hcl:
bucket = "mycompany-terraform-state"
# => Sets bucket
key = "staging/app/terraform.tfstate"
# => Same bucket, different key (staging vs production)Backend initialization with partial config:
# Initialize production backend:
# $ terraform init -backend-config=backend-prod.hcl
# => Backend config:
# => region: us-west-2 (from main.tf)
# => encrypt: true (from main.tf)
# => dynamodb_table: terraform-state-locks (from main.tf)
# => bucket: mycompany-terraform-state (from backend-prod.hcl)
# => key: production/app/terraform.tfstate (from backend-prod.hcl)
# Initialize staging backend:
# $ terraform init -backend-config=backend-staging.hcl
# => Same config except key: staging/app/terraform.tfstate
# Provide config via CLI flags:
# $ terraform init \
# -backend-config="bucket=mycompany-terraform-state" \
# -backend-config="key=dev/app/terraform.tfstate"
# => Override any backend setting at init time
# Provide config via environment variables:
# $ export TF_CLI_ARGS_init="-backend-config=bucket=mycompany-terraform-state -backend-config=key=dev/app/terraform.tfstate"
# $ terraform init
# => Environment variable sets backend configKey Takeaway: Partial backend configuration separates static settings (region, encryption) in HCL from dynamic settings (bucket, key) provided at terraform init via -backend-config flag, config file, or environment variables. This enables reusable configurations across environments without hardcoding environment-specific values in version control.
Why It Matters: Partial backend config prevents hardcoding production credentials in HCL files committed to git—Twitter uses partial config to keep AWS account IDs and bucket names out of repository, providing them via CI/CD environment variables per deployment environment. This pattern enables “write once, deploy anywhere”: same Terraform configuration deploys to dev (backend-dev.hcl), staging (backend-staging.hcl), and production (backend-prod.hcl) without modifying HCL code. Security benefit: backend credentials never appear in git history where they could leak.
Example 39: Remote State Data Sources
Terraform can read outputs from other state files using terraform_remote_state data source. This enables cross-stack references without tight coupling.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161 graph TD A["Network Stack<br/>terraform.tfstate"] --> B["VPC ID Output"] B --> C["Application Stack<br/>Reads Remote State"] C --> D["Uses VPC ID"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff
Network stack - network/main.tf:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "network/terraform.tfstate" # => String value
# => Sets key
region = "us-west-2" # => String value
# => Sets region
}
}
provider "local" {}
# => Provider configuration
# Network resources (simulated with local files)
resource "local_file" "vpc" {
# => Resource definition
filename = "vpc-config.txt" # => String value
# => Sets filename
content = "VPC ID: vpc-12345" # => String value
# => Sets content
}
# Outputs made available to other stacks
output "vpc_id" {
# => Output value
value = "vpc-12345" # => String value
# => Sets value
description = "VPC identifier" # => String value
# => Output stored in state file
# => Readable by terraform_remote_state
}
output "subnet_ids" {
# => Output value
value = ["subnet-a", "subnet-b", "subnet-c"] # => List definition
# => Sets value
}Application stack - application/main.tf:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "application/terraform.tfstate" # => String value
# => Different state file than network stack
region = "us-west-2" # => String value
# => Sets region
}
}
provider "local" {}
# => Provider configuration
# Read outputs from network stack
data "terraform_remote_state" "network" {
# => Terraform configuration block
backend = "s3" # => String value
# => Backend type must match network stack backend
config = { # => Map/object definition
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "network/terraform.tfstate" # => String value
# => Path to network stack state file
region = "us-west-2" # => String value
# => Sets region
}
# => Reads network stack outputs without modifying network stack
}
# Use remote state outputs
resource "local_file" "app_config" {
# => Resource definition
filename = "app-config.txt" # => String value
# => Sets filename
content = <<-EOT
# => Sets content
VPC ID: ${data.terraform_remote_state.network.outputs.vpc_id}
# => Terraform configuration block
Subnets: ${jsonencode(data.terraform_remote_state.network.outputs.subnet_ids)}
# => Terraform configuration block
EOT
# => data.terraform_remote_state.network.outputs accesses network stack outputs
# => Application stack depends on network stack via data source
}
output "network_vpc_id" {
# => Output value
value = data.terraform_remote_state.network.outputs.vpc_id
# => Re-export network output for downstream consumers
}Key Takeaway: terraform_remote_state data source reads outputs from another Terraform state file. Configure with backend type and config matching source state location. Access outputs via data.terraform_remote_state.NAME.outputs.OUTPUT_NAME. Enables stack separation where network, compute, and database layers have independent state files but share data via outputs.
Why It Matters: Remote state data sources enable organizational separation of concerns— Stack separation reduces blast radius: application deployment errors can’t corrupt network state, and network changes don’t trigger unnecessary application redeployments. This is the foundation of “shared infrastructure, independent deployments” architecture used by multi-team organizations.
Example 40: State Migration Between Backends
Moving state from local to remote backend or between different remote backends requires migration with terraform init -migrate-state. This preserves existing infrastructure tracking.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["Local Backend<br/>terraform.tfstate"] --> B["terraform init<br/>-migrate-state"] B --> C["Copy State"] C --> D["S3 Backend<br/>Remote State"] B --> E["Delete Local<br/>State"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#CC78BC,color:#fff style D fill:#029E73,color:#fff style E fill:#0173B2,color:#fff
Initial configuration - Local backend:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => No backend block (defaults to local)
# => Local backend: terraform.tfstate in current directory
}
provider "local" {}
# => Local provider for file operations
resource "local_file" "data" {
# => File resource to demonstrate state tracking
filename = "data.txt" # => String value
# => Output file path
content = "Important data" # => String value
# => File content
# => State will track this resource
}After initial apply:
# $ terraform apply
# => Creates terraform.tfstate (local backend)
# => State contains local_file.data resourceUpdated configuration - Migrating to S3 backend:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
# Add S3 backend configuration
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "migrated/terraform.tfstate" # => String value
# => Sets key
region = "us-west-2" # => String value
# => Sets region
}
}
provider "local" {}
# => Provider configuration
resource "local_file" "data" {
# => Resource definition
filename = "data.txt" # => String value
# => Sets filename
content = "Important data" # => String value
# => Same resource, new backend
}State migration:
# $ terraform init -migrate-state
# => Initializing the backend..
# => Terraform detected that the backend type changed from "local" to "s3".
# => Do you want to migrate all workspaces to "s3"? (yes/no): yes
# => Successfully configured the backend "s3"!
# => Terraform has migrated the state from "local" to "s3".
# Verify migration:
# $ terraform state list
# => local_file.data
# => (Same resources, now in S3)
# $ aws s3 ls s3://mycompany-terraform-state/migrated/
# => terraform.tfstate (migrated state file in S3)
# Old local state file:
# $ ls terraform.tfstate
# => terraform.tfstate (local backup remains)Migrating between S3 buckets:
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
bucket = "new-terraform-state-bucket" # => String value
# => Changed from mycompany-terraform-state
key = "migrated/terraform.tfstate" # => String value
# => Sets key
region = "us-west-2" # => String value
# => Sets region
}
}
# $ terraform init -migrate-state
# => Backend type remains "s3"
# => Do you want to copy existing state to the new backend? (yes/no): yes
# => State migrated from old S3 bucket to new S3 bucketKey Takeaway: State migration with terraform init -migrate-state copies state from old backend to new backend while preserving resource tracking. Always backup state before migration. Local state file remains after migration (manual cleanup required). Migration works between any backend types (local→S3, S3→Azure, etc).
Why It Matters: State migration enables safe backend upgrades without destroying infrastructure—when Migration is critical when changing organizations: acquired company’s Terraform state in old AWS account must migrate to parent company’s S3 bucket without disrupting infrastructure. Always test migration on non-production stacks first; corrupted state migration requires restoring from backup or re-importing all resources.
Group 12: Workspaces
Example 41: Workspace Basics for Environment Management
Workspaces enable managing multiple environments (dev, staging, prod) using same configuration with separate state files. Each workspace has isolated state.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["main.tf<br/>Shared Config"] --> B["dev Workspace<br/>terraform.tfstate.d/dev"] A --> C["staging Workspace<br/>terraform.tfstate.d/staging"] A --> D["prod Workspace<br/>terraform.tfstate.d/prod"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#CC78BC,color:#fff
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# terraform.workspace contains current workspace name
resource "local_file" "environment_config" {
# => Resource definition
filename = "${terraform.workspace}-config.txt" # => String interpolation
# => terraform.workspace is "default", "dev", "staging", or "prod"
# => Creates: default-config.txt, dev-config.txt, etc.
content = <<-EOT
# => Sets content
Environment: ${terraform.workspace}
# => Terraform configuration block
Workspace: ${terraform.workspace}
# => Terraform configuration block
Instance Count: ${terraform.workspace == "prod" ? 5 : 2}
# => Sets Instance Count: ${terraform.workspace
EOT
# => Conditional: prod workspace gets 5 instances, others get 2
}
# Environment-specific variables using workspace
locals {
# => Local values
instance_count = { # => Map/object definition
dev = 1 # => Numeric value
# => Sets dev
staging = 2 # => Numeric value
# => Sets staging
prod = 5 # => Numeric value
# => Sets prod
}
current_instance_count = lookup( # => Map key lookup with default
local.instance_count, terraform.workspace, 1)
# => lookup( # => Map key lookup with default
map, key, default) returns instance count for workspace
# => Falls back to 1 if workspace not in map
}
output "workspace_info" {
# => Output value
value = { # => Map/object definition
workspace = terraform.workspace
# => Sets workspace
instance_count = local.current_instance_count
# => Sets instance_count
config_file = local_file.environment_config.filename
# => Sets config_file
}
}Workspace commands:
# List workspaces:
# $ terraform workspace list
# => * default
# => (default workspace created automatically)
# Create new workspace:
# $ terraform workspace new dev
# => Created and switched to workspace "dev"!
# $ terraform workspace new staging
# $ terraform workspace new prod
# Switch workspace:
# $ terraform workspace select dev
# => Switched to workspace "dev".
# Apply in dev workspace:
# $ terraform apply
# => terraform.workspace = "dev"
# => Creates dev-config.txt
# => State stored in terraform.tfstate.d/dev/terraform.tfstate
# Switch to prod and apply:
# $ terraform workspace select prod
# $ terraform apply
# => terraform.workspace = "prod"
# => Creates prod-config.txt
# => State stored in terraform.tfstate.d/prod/terraform.tfstate
# Show current workspace:
# $ terraform workspace show
# => prod
# Delete workspace (must be empty):
# $ terraform workspace select default
# $ terraform workspace delete dev
# => Deleted workspace "dev"!State file organization:
project/
├── main.tf
├── terraform.tfstate.d/ # => Workspace states
│ ├── dev/
│ │ └── terraform.tfstate # => dev workspace state
│ ├── staging/
│ │ └── terraform.tfstate # => staging workspace state
│ └── prod/
│ └── terraform.tfstate # => prod workspace state
└── terraform.tfstate # => default workspace state (if not using workspaces)Key Takeaway: Workspaces provide isolated state files per environment using single configuration. Access current workspace via terraform.workspace variable. State files stored in terraform.tfstate.d/<workspace>/terraform.tfstate. Use workspace-based conditionals for environment-specific resource counts and configurations.
Why It Matters: Workspaces prevent environment state file conflicts—Atlassian uses workspaces so dev, staging, and prod environments share identical Terraform code but maintain separate state files, eliminating “dev terraform apply accidentally modified prod” disasters. Workspace-based conditionals enable resource sizing: prod gets 10 servers (terraform.workspace == "prod" ? 10 : 2), dev gets 2, from one codebase. Limitation: workspaces share same variable files and backend config, making them unsuitable for completely different environments (use separate directories for production vs non-production instead).
Example 42: Workspace Strategies and Limitations
Workspaces have limitations for production use. Directory-based separation is often preferred for production/non-production isolation.
Workspace approach (suitable for similar environments):
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "app/terraform.tfstate" # => String value
# => All workspaces share same key (state isolated via workspace prefix)
region = "us-west-2" # => String value
# => Sets region
workspace_key_prefix = "workspaces" # => String value
# => State keys: workspaces/dev/app/terraform.tfstate, workspaces/prod/app/terraform.tfstate
}
}
provider "local" {}
# => Provider configuration
variable "instance_count" {
# => Input variable
type = number
# => Sets type
}
# Workspace-based resource configuration
resource "local_file" "instances" {
# => Resource definition
count = var.instance_count
# => Creates specified number of instances
filename = "${terraform.workspace}-instance-${count.index}.txt" # => String interpolation
# => Sets filename
content = "Instance ${count.index} in ${terraform.workspace}" # => String value
# => Sets content
}Variable files per workspace:
# dev.tfvars
instance_count = 1
# => instance_count set to 1
# staging.tfvars
instance_count = 2
# => instance_count set to 2
# prod.tfvars
instance_count = 5
# => instance_count set to 5Usage:
# $ terraform workspace new dev
# $ terraform apply -var-file=dev.tfvars
# => Creates 1 instance in dev workspace
# $ terraform workspace new prod
# $ terraform apply -var-file=prod.tfvars
# => Creates 5 instances in prod workspaceDirectory-based approach (better for production isolation):
infrastructure/
├── non-production/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── terraform.tfvars # => instance_count = 1
│ │ └── backend-dev.hcl
│ └── staging/
│ ├── main.tf
│ ├── terraform.tfvars # => instance_count = 2
│ └── backend-staging.hcl
└── production/
├── main.tf
├── terraform.tfvars # => instance_count = 5
└── backend-prod.hclDirectory-based benefits:
# production/main.tf
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
# Production-specific backend config
}
}
# Different AWS provider configuration per directory
provider "aws" {
# => Provider configuration
region = "us-east-1" # => String value
# Production account with restricted IAM roles
assume_role {
role_arn = "arn:aws:iam::111111111111:role/TerraformProduction" # => String value
# => Sets role_arn
}
}
# Production-specific resources (no workspace conditionals needed)
resource "aws_instance" "app" {
# => Resource definition
count = var.instance_count
# => Creates specified number of instances
instance_type = "m5.large" # => Production instance type
# => No workspace-based conditionals
}Key Takeaway: Workspaces suitable for similar environments (dev/staging sharing configurations). Directory separation preferred for production isolation where different AWS accounts, IAM roles, backend configs, or compliance requirements exist. Workspaces reduce code duplication but share backend config and variable files. Directories provide complete isolation with independent configurations.
Why It Matters: Workspace limitations caused production outages at multiple companies—one engineer’s terraform workspace select prod followed by terraform destroy deleted production because dev and prod shared IAM credentials and backend config. Directory separation enforces isolation: production directory uses production AWS account, non-production uses separate account, impossible to accidentally cross environments. HashiCorp recommends directory-based separation for production despite workspace convenience, because production requires stricter access controls, different approval workflows, and compliance audit trails that workspaces can’t provide.
Example 43: Workspace Key Prefix for Remote State
S3 backend supports workspace_key_prefix to customize workspace state file paths. This organizes workspace states in remote backend.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
# => Sets bucket
key = "application/terraform.tfstate" # => String value
# => Sets key
region = "us-west-2" # => String value
# => Sets region
workspace_key_prefix = "environments" # => String value
# => Workspace state paths:
# => environments/dev/application/terraform.tfstate
# => environments/staging/application/terraform.tfstate
# => environments/prod/application/terraform.tfstate
# => default workspace: application/terraform.tfstate (no prefix)
}
}
provider "local" {}
# => Provider configuration
resource "local_file" "environment_marker" {
# => Resource definition
filename = "${terraform.workspace}-marker.txt" # => String interpolation
# => Sets filename
content = "Environment: ${terraform.workspace}" # => String value
# => Sets content
}State file organization in S3:
s3://mycompany-terraform-state/
├── application/
│ └── terraform.tfstate # => default workspace
├── environments/
│ ├── dev/
│ │ └── application/
│ │ └── terraform.tfstate # => dev workspace
│ ├── staging/
│ │ └── application/
│ │ └── terraform.tfstate # => staging workspace
│ └── prod/
│ └── application/
│ └── terraform.tfstate # => prod workspace
└── network/
└── terraform.tfstate # => Different stackWithout workspace_key_prefix (default behavior):
backend "s3" {
bucket = "mycompany-terraform-state" # => String value
key = "application/terraform.tfstate" # => String value
region = "us-west-2" # => String value
# => workspace_key_prefix defaults to "env:"
# => Workspace state paths:
# => env:/dev/application/terraform.tfstate
# => env:/staging/application/terraform.tfstate
# => env:/prod/application/terraform.tfstate
}Key Takeaway: workspace_key_prefix customizes S3 state file paths for workspaces. Default prefix is env:. Set custom prefix for better organization (workspace_key_prefix = "environments"). Default workspace state file doesn’t use prefix (stored at key path directly).
Why It Matters: Custom workspace key prefixes improve S3 state organization when managing dozens of Terraform projects— Default env: prefix causes confusion because it’s not intuitive; custom prefix like “environments” or “workspaces” makes state file purpose obvious when browsing S3 console. This organizational clarity reduces accidental state file deletions that would orphan infrastructure.
Group 13: Provisioners
Example 44: Local-Exec Provisioner for External Commands
local-exec provisioner runs commands on machine executing Terraform (not on created resources). Use for local scripts, API calls, or notifications.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["Resource Created"] --> B["local-exec<br/>Provisioner"] B --> C["Execute Command<br/>on Local Machine"] C --> D["Script/API Call"] D --> E["Resource Complete"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#CC78BC,color:#fff style D fill:#029E73,color:#fff style E fill:#0173B2,color:#fff
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
resource "local_file" "deployment_marker" {
# => Resource definition
filename = "deployment.txt" # => String value
# => Sets filename
content = "Deployed at: ${timestamp()}" # => String value
# => Sets content
# local-exec runs on Terraform executor machine
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "echo 'Deployment started' >> deployment.log" # => String value
# => command runs in shell on local machine
# => Executes when resource is CREATED (not on updates)
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "echo 'File created: ${self.filename}' >> deployment.log" # => String value
# => self.filename references current resource attributes
working_dir = "${path.module} # => Current module directory path"
# => working_dir sets command execution directory
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "curl -X POST https://api.example.com/notify -d '{\"event\":\"deployment\"}'"
# => External API notification after resource creation
# => Runs during terraform apply
}
# Runs when resource is DESTROYED
provisioner "local-exec" {
when = destroy
# => Sets when
command = "echo 'File deleted: ${self.filename}' >> deployment.log" # => String value
# => when = destroy executes on terraform destroy
}
}
resource "local_file" "build_artifact" {
# => Resource definition
filename = "build/artifact.txt"
# => Sets filename
content = "Build output"
# => Sets content
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "mkdir -p build && echo 'Build started' > build/log.txt"
# => Create directory and initialize log
interpreter = ["/bin/bash", "-c"]
# => interpreter specifies shell (default: sh on Linux, cmd on Windows)
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "python3 scripts/post-deploy.py --file ${self.filename}"
# => Run Python script with resource attribute
environment = {
DEPLOY_ENV = "production"
# => Sets DEPLOY_ENV
API_KEY = var.api_key
# => Sets API_KEY
}
# => environment sets environment variables for command
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "exit 1" # Simulate failure
# => Sets command
on_failure = continue
# => on_failure = continue allows resource creation to succeed despite provisioner failure
# => Default: on_failure = fail (resource creation fails if provisioner fails)
}
}
variable "api_key" {
# => Input variable
type = string
# => Sets type
default = "test-key-12345"
# => Sets default
sensitive = true
# => Sets sensitive
}
output "provisioner_note" {
# => Output value
value = "local-exec ran commands on Terraform executor machine"
# => Sets value
}Key Takeaway: local-exec provisioner runs commands locally (not on resources). Use command for shell commands, working_dir for execution directory, environment for env vars, when = destroy for cleanup. Provisioners run during resource creation (default) or destruction. Set on_failure = continue to allow resource success despite provisioner failure.
Why It Matters: local-exec enables Terraform to trigger external systems that don’t have providers— Provisioners are last resort (Hashicorp recommends avoiding them): they break Terraform’s declarative model, can’t be planned (you don’t see what command will do), and create hidden dependencies. Prefer provider resources (aws_lambda_invocation) or separate orchestration tools ( They’re acceptable for one-off migrations but anti-pattern for routine infrastructure.
Example 45: Remote-Exec Provisioner for Instance Configuration
remote-exec provisioner runs commands on remote resource via SSH or WinRM. Typically used for initial instance setup not covered by cloud-init or configuration management.
terraform {
required_version = ">= 1.0" # => String value
# => Minimum Terraform version required
}
provider "local" {}
# => Local provider for file operations
# Simulate remote instance with local file
resource "local_file" "instance" {
# => Creates local_file.instance resource
filename = "instance-config.txt" # => String value
# => Local file representing remote instance
content = "Instance initialized" # => String value
# => Initial instance state
# Simulated SSH connection (real example would use AWS EC2)
connection {
# => SSH/WinRM connection configuration
type = "ssh" # => String value
# => Connection type: ssh (Linux) or winrm (Windows)
host = "192.168.1.100" # => String value
# => Target host IP address
user = "ubuntu" # => String value
# => SSH user account
private_key = file("~/.ssh/id_rsa")
# => SSH private key for authentication (reads ~/.ssh/id_rsa)
# => Alternative: password = var.ssh_password
timeout = "2m" # => String value
# => Connection timeout (default: 5m)
}
# => Connection applies to all provisioners in this resource
# inline commands (list of strings)
provisioner "remote-exec" {
# => Provisioner runs after resource creation
inline = [ # => List definition
# => List of commands executed sequentially
"sudo apt-get update",
# => Update package index on remote instance
"sudo apt-get install -y nginx",
# => Install nginx web server (-y auto-confirms)
"sudo systemctl start nginx",
# => Start nginx service immediately
"sudo systemctl enable nginx",
# => Enable nginx to start on boot
"echo 'Setup complete' > /tmp/provisioned.txt",
# => Mark provisioning complete (creates marker file)
]
# => Each command runs sequentially on remote instance
# => Failure stops execution (unless on_failure = continue)
}
# script from file
provisioner "remote-exec" {
# => Second provisioner running after first completes
script = "${path.module} # => Current module directory path/scripts/setup.sh"
# => Upload and execute local script on remote instance
# => path.module resolves to current directory
# => Script must be executable (chmod +x setup.sh)
}
# scripts (multiple files)
provisioner "remote-exec" {
# => Third provisioner for multiple script execution
scripts = [
# => List of scripts executed in order
"${path.module} # => Current module directory path/scripts/install-deps.sh",
# => First: Install dependencies
"${path.module} # => Current module directory path/scripts/configure-app.sh",
# => Second: Configure application
"${path.module} # => Current module directory path/scripts/start-services.sh",
# => Third: Start services
]
# => Each script must be executable
# => Scripts run sequentially, failure stops execution
}
}
# Real-world example with AWS EC2 (concept only)
# resource "aws_instance" "web" {
# ami = "ami-12345678"
# # => Amazon Machine Image ID for EC2 instance
# instance_type = "t3.micro"
# # => Instance size (1 vCPU, 1GB RAM)
# key_name = "my-key-pair"
# # => SSH key pair name registered in AWS
#
# connection {
# type = "ssh"
# # => SSH connection type
# host = self.public_ip
# # => self.public_ip references instance's public IP after creation
# user = "ec2-user"
# # => Default user for Amazon Linux AMIs
# private_key = file("~/.ssh/id_rsa")
# # => Local SSH private key file
# }
#
# provisioner "remote-exec" {
# inline = [
# "sudo yum update -y",
# # => Update all packages on Amazon Linux
# "sudo yum install -y docker",
# # => Install Docker
# "sudo systemctl start docker",
# # => Start Docker daemon
# "sudo docker run -d -p 80:80 nginx:latest",
# # => Run nginx container (-d: detached, -p: port mapping)
# ]
# # => Commands provision Docker and nginx on new EC2 instance
# }
# }Key Takeaway: remote-exec executes commands on remote resources via SSH/WinRM connection. Use inline for command list, script for single file, or scripts for multiple files. Requires connection block with credentials. Provisioner runs during resource creation (use when = destroy for cleanup). Provisioners are last resort—prefer cloud-init, user_data, or configuration management (Ansible, Chef) for production.
Why It Matters: remote-exec is an anti-pattern that creates hidden configuration drift—Etsy abandoned remote-exec after discovering instances provisioned months ago had different software versions than newly provisioned instances (because provisioner ran once at creation, never again). Configuration management tools (Ansible, Chef, Puppet) continuously enforce desired state, while remote-exec is one-shot. Remote-exec requires open SSH ports and credentials in Terraform state (security risk). Modern practice: use cloud-init/user_data for initial setup, configuration management for ongoing enforcement. Only use remote-exec for emergency one-time migrations where proper tooling isn’t feasible.
Example 46: File Provisioner for Uploading Content
file provisioner uploads files or directories from local machine to remote resources. Useful for configuration files, scripts, or initial data.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
resource "local_file" "instance_marker" {
# => Resource definition
filename = "instance.txt" # => String value
# => Sets filename
content = "Instance created" # => String value
# => Sets content
# Simulated connection (real-world: AWS EC2, Azure VM)
connection {
type = "ssh" # => String value
# => Sets type
host = "192.168.1.100" # => String value
# => Sets host
user = "ubuntu" # => String value
# => Sets user
private_key = file("~/.ssh/id_rsa")
# => Sets private_key
}
# Upload single file
provisioner "file" {
source = "${path.module} # => Current module directory path/configs/app.conf"
# => Local file path
destination = "/tmp/app.conf" # => String value
# => Remote destination path
# => Uploads configs/app.conf to /tmp/app.conf on remote instance
}
# Upload file with inline content
provisioner "file" {
# => Provisioner executes during resource lifecycle
content = templatefile( # => Renders template file with variables
"${path.module} # => Current module directory path/templates/config.tpl", {
app_name = "myapp" # => String value
# => Sets app_name
port = 8080 # => Numeric value
# => Sets port
})
# => content instead of source (generated content)
destination = "/etc/myapp/config.json" # => String value
# => Writes generated content to remote file
}
# Upload directory recursively
provisioner "file" {
source = "${path.module} # => Current module directory path/scripts/"
# => Trailing slash: upload directory CONTENTS
destination = "/opt/scripts"
# => Uploads all files from local scripts/ to /opt/scripts/
# => Without trailing slash: creates /opt/scripts/scripts/
}
# Upload directory as directory
provisioner "file" {
source = "${path.module} # => Current module directory path/data"
# => No trailing slash: upload directory itself
destination = "/var/data"
# => Creates /var/data/data/ with contents
}
}
# Typical usage: Upload app code and start service
resource "local_file" "app_server" {
# => Resource definition
filename = "server.txt"
# => Sets filename
content = "App server"
# => Sets content
connection {
type = "ssh"
# => Sets type
host = "192.168.1.100"
# => Sets host
user = "ubuntu"
# => Sets user
private_key = file("~/.ssh/id_rsa")
# => Sets private_key
}
# Step 1: Upload application files
provisioner "file" {
source = "${path.module} # => Current module directory path/dist/"
# => Sets source
destination = "/opt/app"
# => Sets destination
}
# Step 2: Upload systemd service file
provisioner "file" {
# => Provisioner executes during resource lifecycle
content = <<-EOT
# => Sets content
[Unit]
Description=My Application
# => Sets Description
[Service]
ExecStart=/opt/app/server
# => Sets ExecStart
Restart=always
# => Sets Restart
[Install]
WantedBy=multi-user.target
# => Sets WantedBy
EOT
destination = "/etc/systemd/system/myapp.service"
# => Sets destination
}
# Step 3: Enable and start service
provisioner "remote-exec" {
inline = [
# => Sets inline
"sudo systemctl daemon-reload",
"sudo systemctl enable myapp",
"sudo systemctl start myapp",
]
}
}Key Takeaway: file provisioner uploads files (source) or generated content (content) to remote destination. Trailing slash in source (scripts/) uploads directory contents; without trailing slash (scripts) uploads directory itself. Combine with remote-exec for multi-step provisioning (upload files, then execute setup commands). Requires connection block with SSH/WinRM credentials.
Why It Matters: File provisioner enables bootstrapping instances with configuration and code, but it’s a one-way operation that doesn’t handle updates— Modern practice: use S3/GCS for artifacts (aws_s3_bucket + instance profile), cloud-init to download on boot, and configuration management to handle updates. File provisioner acceptable for one-time migrations or emergency hotfixes, but not sustainable for production configuration management.
Example 47: Null Resource for Provisioner-Only Resources
null_resource is a placeholder resource that does nothing except run provisioners. Useful for actions that don’t correspond to infrastructure resources.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
required_providers {
# => Provider configuration
null = { # => Map/object definition
source = "hashicorp/null" # => String value
# => Provider source location
version = "~> 3.0" # => String value
# => Sets version
}
}
}
provider "null" {}
# => Provider configuration
provider "local" {}
# => Provider configuration
resource "local_file" "config" {
# => Resource definition
filename = "app-config.txt" # => String value
# => Sets filename
content = "Config version: 1.0" # => String value
# => Sets content
}
# null_resource with triggers for re-execution
resource "null_resource" "deploy_notification" {
# => Resource definition
# triggers force re-execution when values change
triggers = { # => Map/object definition
config_content = local_file.config.content
# => When config.content changes, null_resource re-creates (re-runs provisioners)
deployment_id = uuid()
# => uuid() changes every apply, forcing provisioner re-run
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "echo 'Deployment triggered' >> deployment.log" # => String value
# => Runs every time triggers change
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = <<-EOT
# => Sets command
curl -X POST https://api.example.com/deploy \
-H "Content-Type: application/json" \
-d '{"config": "${local_file.config.content}", "trigger": "${self.triggers.deployment_id}"}'
EOT
# => Notify external system of deployment
}
}
# null_resource for time-based actions
resource "null_resource" "database_backup" {
# => Resource definition
triggers = {
backup_schedule = timestamp()
# => timestamp() evaluated at plan time
# => Forces re-execution on every apply (use with caution)
}
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "python3 scripts/backup-database.py"
# => Sets command
}
}
# null_resource for depends_on orchestration
resource "local_file" "app_binary" {
# => Resource definition
filename = "app-binary.txt"
# => Sets filename
content = "Binary v2.0"
# => Sets content
}
resource "local_file" "app_config_file" {
# => Resource definition
filename = "app-config-final.txt"
# => Sets filename
content = "Config for binary"
# => Sets content
}
resource "null_resource" "app_deployment" {
# => Resource definition
depends_on = [ # => Explicit dependency list
# => Sets depends_on
local_file.app_binary,
local_file.app_config_file,
]
# => Explicit dependency enforces creation order
triggers = {
binary_content = local_file.app_binary.content
# => Sets binary_content
config_content = local_file.app_config_file.content
# => Sets config_content
}
# => Re-deploy when binary or config changes
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "echo 'Deploying app with binary and config' >> deployment.log"
# => Sets command
}
}
output "deployment_info" {
# => Output value
value = {
deploy_trigger_id = null_resource.deploy_notification.triggers.deployment_id
# => Sets deploy_trigger_id
note = "null_resource ran provisioners without creating infrastructure"
# => Sets note
}
}Key Takeaway: null_resource runs provisioners without managing infrastructure. Use triggers map to control when provisioners re-run (any value change re-creates resource). Common triggers: file content hashes, timestamps, version numbers. Combine with depends_on to orchestrate provisioner execution order. Useful for notifications, deployments, or actions external to Terraform’s resource model.
Why It Matters: null_resource is both powerful and dangerous— However, null_resource with timestamp() trigger runs on every apply, causing unnecessary API calls, alerts, and confusion (“why is deployment running when nothing changed?”). Prefer explicit triggers (content hashes, version numbers) over timestamp. Better alternative: separate orchestration tools (
Group 14: Dynamic Blocks and Advanced Patterns
Example 48: Dynamic Blocks for Repeated Nested Configuration
Dynamic blocks generate repeated nested blocks from collections. This eliminates repetitive configuration for resources with multiple similar nested blocks (security group rules, routing rules, etc).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["Variable<br/>ingress_rules list"] --> B["dynamic block<br/>for_each"] B --> C["Generate Block 1<br/>Port 80"] B --> D["Generate Block 2<br/>Port 443"] B --> E["Generate Block 3<br/>Port 22"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#029E73,color:#fff style D fill:#029E73,color:#fff style E fill:#029E73,color:#fff
terraform {
# => Terraform configuration block
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Local provider for demonstration
# => Provider configuration
# Variable with list of ingress rules
variable "ingress_rules" {
# => Input variable for dynamic ingress rules
# => Input variable
type = list(object({
# => List of objects with port, protocol, description fields
port = number
# => Sets port
protocol = string
# => Sets protocol
description = string
# => Sets description
}))
default = [ # => List definition
# => Sets default
{ port = 80, protocol = "tcp", description = "HTTP" },
# => Sets { port
{ port = 443, protocol = "tcp", description = "HTTPS" },
# => Sets { port
{ port = 22, protocol = "tcp", description = "SSH" },
# => Sets { port
]
}
# Simulated security group with dynamic blocks
resource "local_file" "security_group" {
# => Resource definition
filename = "security-group.txt" # => String value
# => Sets filename
content = <<-EOT
# => Sets content
Security Group Rules:
${join( # => Concatenates list with separator
"\n", [
for rule in var.ingress_rules :
# => Iterate over each rule in the list
"Port ${rule.port}/${rule.protocol}: ${rule.description}"
])}
EOT
# => Dynamic content generation based on var.ingress_rules
}
# More realistic example (concept with AWS-like syntax)
# Real AWS security group would use:
# resource "aws_security_group" "web" {
# name = "web-sg"
#
# dynamic "ingress" {
# => Dynamic block generates multiple ingress blocks
# for_each = var.ingress_rules
# => Iterate over ingress_rules variable
# # => for_each iterates over var.ingress_rules list
# # => Creates one ingress block per list element
#
# content {
# => Nested content block defines ingress structure
# from_port = ingress.value.port
# => Access port from current iteration value
# # => ingress.value accesses current iteration element
# to_port = ingress.value.port
# protocol = ingress.value.protocol
# cidr_blocks = ["0.0.0.0/0"]
# => Allow traffic from any IP address
# description = ingress.value.description
# }
# # => content block defines the nested block structure
# }
# }
# Dynamic blocks with maps (key-value access)
variable "egress_rules_map" {
# => Map-based variable for egress rules
# => Input variable
type = map(object({
# => Map with string keys and object values
port = number
# => Sets port
protocol = string
# => Sets protocol
}))
default = {
http = { port = 80, protocol = "tcp" }
# => Sets http
https = { port = 443, protocol = "tcp" }
# => Sets https
}
}
resource "local_file" "egress_config" {
# => Resource definition
filename = "egress-config.txt"
# => Sets filename
content = <<-EOT
# => Sets content
Egress Rules:
${join( # => Concatenates list with separator
"\n", [
for name, rule in var.egress_rules_map :
# => Iterate map with key (name) and value (rule)
"${name}: Port ${rule.port}/${rule.protocol}"
])}
EOT
# => Dynamic content with map iteration (key = name, value = rule)
}
# Nested dynamic blocks
variable "route_tables" {
# => Nested list variable for route tables with routes
# => Input variable
type = list(object({
# => List of objects with port, protocol, description fields
name = string
# => Sets name
routes = list(object({
destination = string
# => Sets destination
target = string
# => Sets target
}))
}))
default = [
# => Sets default
{
name = "public"
# => Sets name
routes = [
# => Sets routes
{ destination = "0.0.0.0/0", target = "igw-123" },
# => Sets { destination
{ destination = "10.0.0.0/16", target = "local" },
# => Sets { destination
]
},
{
name = "private"
# => Sets name
routes = [
# => Sets routes
{ destination = "0.0.0.0/0", target = "nat-456" },
# => Sets { destination
]
},
]
}
resource "local_file" "route_tables" {
# => Resource definition
filename = "route-tables.txt"
# => Sets filename
content = <<-EOT
# => Sets content
Route Tables:
${join( # => Concatenates list with separator
"\n\n", [
for rt in var.route_tables :
"${rt.name}:\n${join( # => Concatenates list with separator
"\n", [
for route in rt.routes :
" ${route.destination} -> ${route.target}"
])}"
])}
EOT
# => Nested iteration: outer loop for route tables, inner loop for routes
}Key Takeaway: Dynamic blocks use dynamic "BLOCK_NAME" with for_each to generate nested blocks from collections. Access iteration elements via BLOCK_NAME.value (for lists) or BLOCK_NAME.key/BLOCK_NAME.value (for maps). Nest dynamic blocks for multi-level iteration. Eliminates repetitive block definitions while maintaining Terraform’s declarative syntax.
Why It Matters: Dynamic blocks prevent massive configuration duplication—before dynamic blocks, AWS security groups with 20 ingress rules required 20 hand-written ingress { } blocks; one typo in port number affects only that rule, making debugging tedious. Dynamic blocks centralize rule definitions in variables: change port 8080→8443 in one place, all ingress blocks update. This pattern enabled HashiCorp to reduce their example security group modules from 500 lines to 50 lines, improving readability and maintainability. However, overuse harms clarity: simple resources with 2-3 blocks should use explicit blocks, not dynamic; save dynamic for 5+ repeated blocks.
Example 49: For Each with Maps and Advanced Patterns
Advanced for_each patterns enable complex resource generation with map transformations, filtering, and cross-resource references.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Map of environments with nested configuration
variable "environments" {
# => Input variable
type = map(object({
region = string
# => Sets region
instance_count = number
# => Sets instance_count
tags = map(string)
# => Sets tags
}))
default = { # => Map/object definition
dev = { # => Map/object definition
region = "us-west-2" # => String value
# => Sets region
instance_count = 1 # => Numeric value
# => Sets instance_count
tags = { tier = "development" } # => Map/object definition
# => Sets tags
}
staging = { # => Map/object definition
region = "us-east-1" # => String value
# => Sets region
instance_count = 2 # => Numeric value
# => Sets instance_count
tags = { tier = "pre-production" } # => Map/object definition
# => Sets tags
}
prod = { # => Map/object definition
region = "eu-west-1" # => String value
# => Sets region
instance_count = 5 # => Numeric value
# => Sets instance_count
tags = { tier = "production", critical = "true" } # => Map/object definition
# => Sets tags
}
}
}
# for_each with map
resource "local_file" "environment_configs" {
# => Resource definition
for_each = var.environments
# => Creates multiple instances from collection
# => each.value is the environment object
filename = "${each.key}-config.txt"
# => Sets filename
content = <<-EOT
# => Sets content
Environment: ${each.key}
Region: ${each.value.region}
Instance Count: ${each.value.instance_count}
Tags: ${jsonencode(each.value.tags)}
EOT
}
# Map transformation with for expression
locals {
# => Local values
# Create map of production environments only (filtering)
prod_environments = {
for k, v in var.environments :
k => v
# => Sets k
if contains( # => Checks list membership
keys( # => Extracts map keys
v.tags), "critical")
# => Filters to environments with "critical" tag
# => Result: { prod = { region = "eu-west-1".. } }
}
# Transform map values
environment_regions = {
for k, v in var.environments :
k => v.region
# => Extract just region from each environment
# => Result: { dev = "us-west-2", staging = "us-east-1", prod = "eu-west-1" }
}
# Create flat list from map
all_tags = flatten([
# => Sets all_tags
for env_name, env in var.environments : [
for tag_key, tag_value in env.tags :
"${env_name}:${tag_key}=${tag_value}"
# => Sets "${env_name}:${tag_key}
]
])
# => Result: ["dev:tier=development", "staging:tier=pre-production"..]
}
resource "local_file" "prod_only" {
# => Resource definition
for_each = local.prod_environments
# => Creates multiple instances from collection
filename = "${each.key}-prod.txt"
# => Sets filename
content = "Production environment in ${each.value.region}"
# => Sets content
}
# Cross-resource references with for_each
resource "local_file" "instance_markers" {
# => Resource definition
for_each = var.environments
# => Creates multiple instances from collection
filename = "${each.key}-instances.txt"
# => Sets filename
content = <<-EOT
# => Sets content
Environment: ${each.key}
Config File: ${local_file.environment_configs[each.key].filename}
Instances: ${each.value.instance_count}
EOT
# => local_file.environment_configs[each.key] references another for_each resource
# => Terraform resolves dependencies automatically
}
# for_each with set conversion
variable "region_list" {
# => Input variable
type = list(string)
# => Sets type
default = ["us-west-2", "us-east-1", "eu-west-1", "us-west-2"]
# => Note duplicate "us-west-2"
}
resource "local_file" "regional_files" {
# => Resource definition
for_each = toset(var.region_list)
# => Creates multiple instances from collection
# => each.key is "us-west-2", "us-east-1", "eu-west-1" (no duplicate)
filename = "region-${each.key}.txt"
# => Sets filename
content = "Region: ${each.key}"
# => Sets content
}
output "created_environments" {
# => Output value
value = {
all = keys( # => Extracts map keys
local_file.environment_configs)
# => Sets all
prod_only = keys( # => Extracts map keys
local_file.prod_only)
# => Sets prod_only
all_tags = local.all_tags
# => Sets all_tags
}
}Key Takeaway: Use for_each with maps for named resource instances. Transform maps with for expressions: filter with if, extract values, or flatten nested structures. Convert lists to sets with toset() to remove duplicates. Reference for_each resources with resource_type.name[key] syntax. Map-based for_each provides stable resource addresses (adding/removing middle elements doesn’t renumber others).
Why It Matters: Map transformations enable environment-specific infrastructure without duplication—Robinhood uses map filtering to create monitoring dashboards only for production environments (if v.tier == "production"), avoiding dashboard costs for 50+ development environments. Cross-resource for_each references create dependency chains: security group rules reference VPC outputs, instances reference security group IDs, all through map keys ensuring consistent references. The toset() pattern prevents accidental duplicate resource creation when variable lists contain duplicates (common when concatenating multiple region lists from different sources).
Example 50: Count and For Each Conditional Resource Creation
Combine count or for_each with conditionals to create resources only when conditions are met. This enables feature flags and environment-specific resources.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Feature flags
variable "enable_monitoring" {
# => Input variable
type = bool
# => Sets type
default = true # => Boolean value
# => Sets default
}
variable "enable_logging" {
# => Input variable
type = bool
# => Sets type
default = false # => Boolean value
# => Sets default
}
variable "environment" {
# => Input variable
type = string
# => Sets type
default = "production" # => String value
# => Sets default
}
# count = 0 or 1 pattern for conditional creation
resource "local_file" "monitoring_config" {
# => Resource definition
count = var.enable_monitoring ? 1 : 0
# => Creates specified number of instances
# => Ternary: condition ? true_value : false_value
filename = "monitoring.txt" # => String value
# => Sets filename
content = "Monitoring enabled" # => String value
# => Sets content
}
resource "local_file" "logging_config" {
# => Resource definition
count = var.enable_logging ? 1 : 0
# => Creates specified number of instances
filename = "logging.txt"
# => Sets filename
content = "Logging enabled"
# => Sets content
}
# for_each = {} (empty map) skips creation
resource "local_file" "prod_only_resource" {
# => Resource definition
for_each = var.environment == "production" ? { enabled = true } : {}
# => Creates multiple instances from collection
# => for_each with empty map {} creates zero resources
filename = "production-feature.txt"
# => Sets filename
content = "Production-only feature"
# => Sets content
}
# Conditional with complex logic
locals {
# => Local values
# Create backup only in production OR if explicitly enabled
create_backup = var.environment == "production" || var.enable_backup
# => Sets create_backup
# Multiple conditions
high_availability_enabled = (
# => Sets high_availability_enabled
var.environment == "production" &&
# => Sets var.environment
var.instance_count >= 3
# => Sets var.instance_count >
)
}
variable "enable_backup" {
# => Input variable
type = bool
# => Sets type
default = false
# => Sets default
}
variable "instance_count" {
# => Input variable
type = number
# => Sets type
default = 5
# => Sets default
}
resource "local_file" "backup_config" {
# => Resource definition
count = local.create_backup ? 1 : 0
# => Creates specified number of instances
filename = "backup.txt"
# => Sets filename
content = "Backup enabled for ${var.environment}"
# => Sets content
}
resource "local_file" "ha_config" {
# => Resource definition
count = local.high_availability_enabled ? 1 : 0
# => Creates specified number of instances
filename = "high-availability.txt"
# => Sets filename
content = "HA enabled with ${var.instance_count} instances"
# => Sets content
}
# Referencing conditional resources (safe access)
output "monitoring_file" {
# => Output value
value = length( # => Returns collection size
local_file.monitoring_config) > 0 ? local_file.monitoring_config[0].filename : "Not enabled"
# => length( # => Returns collection size
) checks if resource was created
# => [0] accesses first element if count = 1
# => Ternary prevents error if count = 0
}
output "prod_feature_file" {
# => Output value
value = length( # => Returns collection size
keys( # => Extracts map keys
local_file.prod_only_resource)) > 0 ? local_file.prod_only_resource["enabled"].filename : "Not created"
# => keys( # => Extracts map keys
) returns map keys, length checks if non-empty
# => ["enabled"] accesses for_each resource by key
}
# Alternative: one_of pattern (create monitoring OR logging, not both)
variable "telemetry_type" {
# => Input variable
type = string
# => Sets type
default = "monitoring"
# => Sets default
validation {
# => Validation rule enforces constraints
condition = contains( # => Checks list membership
["monitoring", "logging", "none"], var.telemetry_type)
# => Sets condition
error_message = "telemetry_type must be monitoring, logging, or none"
# => Sets error_message
}
}
resource "local_file" "monitoring_alt" {
# => Resource definition
count = var.telemetry_type == "monitoring" ? 1 : 0
# => Creates specified number of instances
filename = "monitoring-alt.txt"
# => Sets filename
content = "Monitoring"
# => Sets content
}
resource "local_file" "logging_alt" {
# => Resource definition
count = var.telemetry_type == "logging" ? 1 : 0
# => Creates specified number of instances
filename = "logging-alt.txt"
# => Sets filename
content = "Logging"
# => Sets content
}Key Takeaway: Use count = condition ? 1 : 0 for boolean feature flags. Use for_each = condition ? { key = value } : {} for map-based conditional creation. Complex conditions belong in locals for readability. Reference conditional count resources with resource[0] after checking length(resource) > 0. Reference conditional for_each with resource[key] after checking length(keys(resource)) > 0.
Why It Matters
Conditional resource creation enables feature flags and environment-specific infrastructure without maintaining separate configurations—environment == "prod" ? 1 : 0 to create CloudWatch dashboards only in production. Feature flags enable gradual rollouts: enable new feature in dev (enable_new_feature = true), test thoroughly, then promote to production variable file. The safe access pattern (length(resource) > 0 ? resource[0] : default) prevents “resource doesn’t exist” errors when referencing conditional resources in outputs or other resources, which would otherwise fail plan/apply when feature is disabled.
Example 51: Terraform Import for Existing Resources
terraform import brings existing infrastructure under Terraform management by adding resources to state without creating them. Use for migrating manually created resources or recovering from state loss.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC graph TD A["Existing Resource<br/>manually created"] --> B["Write Terraform<br/>Configuration"] B --> C["terraform import<br/>ADDR ID"] C --> D["Add to State"] D --> E["terraform plan<br/>verify no changes"] style A fill:#0173B2,color:#fff style B fill:#DE8F05,color:#fff style C fill:#CC78BC,color:#fff style D fill:#029E73,color:#fff style E fill:#0173B2,color:#fff
terraform {
required_version = ">= 1.0" # => String value
}
provider "local" {}
# Manually create file (simulating existing infrastructure):
# $ echo "Existing content" > existing-file.txt
# Write Terraform configuration matching existing resource
resource "local_file" "imported" {
# => Creates local_file.imported resource
filename = "existing-file.txt" # => String value
content = "Existing content" # => String value
# => Configuration MUST match existing resource attributes
# => Mismatch causes terraform plan to show unwanted changes
}
# Import existing resource into state:
# $ terraform import local_file.imported existing-file.txt
# => Syntax: terraform import RESOURCE_ADDRESS RESOURCE_ID
# => local_file.imported is Terraform resource address
# => existing-file.txt is local_file provider's resource ID (filename)
# => Adds resource to state without creating/modifying it
# After import, verify with plan:
# $ terraform plan
# => No changes. Your infrastructure matches the configuration.
# => If plan shows changes, configuration doesn't match existing resource
# If attributes unknown, import first then inspect state:
# $ terraform import local_file.unknown_file mystery.txt
# $ terraform state show local_file.unknown_file
# => Displays imported resource attributes from state
# => Copy attributes to configurationImport workflow for complex resources:
# Step 1: Create minimal resource configuration
# resource "aws_instance" "imported_server" {
# # Leave empty initially
# }
# Step 2: Import existing resource
# $ terraform import aws_instance.imported_server i-1234567890abcdef
# Step 3: Inspect imported state
# $ terraform state show aws_instance.imported_server
# => Shows all attributes: ami, instance_type, subnet_id, etc.
# Step 4: Copy attributes to configuration
# resource "aws_instance" "imported_server" {
# ami = "ami-12345678" # From state
# instance_type = "t3.micro" # From state
# subnet_id = "subnet-abc123" # From state
# # .. copy all required and desired attributes
# }
# Step 5: Verify configuration matches
# $ terraform plan
# => No changes (configuration matches imported state)Import blocks (Terraform 1.5+):
# Declarative import (experimental, Terraform 1.5+)
import {
to = local_file.imported_declarative
# => Sets to
id = "existing-file-2.txt" # => String value
# => to specifies target resource address
# => id specifies provider resource identifier
}
resource "local_file" "imported_declarative" {
# => Resource definition
filename = "existing-file-2.txt" # => String value
# => Sets filename
content = "Content" # => String value
# => Sets content
}
# $ terraform plan
# => Shows import will occur
# $ terraform apply
# => Imports resource into stateKey Takeaway: terraform import adds existing resources to state using terraform import RESOURCE_ADDRESS RESOURCE_ID. Write configuration matching existing resource, import to state, then verify with terraform plan (should show no changes). Use terraform state show to inspect imported attributes. Import blocks (Terraform 1.5+) enable declarative imports in configuration. Import doesn’t create resources—it only updates state.
Why It Matters: Import rescues manually created infrastructure from ClickOps hell—when AWS console creates resources faster than writing Terraform (emergency hotfix), import brings them under management, preventing drift between documented infrastructure (Terraform) and actual infrastructure (AWS). Import is critical for acquisitions: purchased company’s AWS resources weren’t created with Terraform, import enables gradual migration without recreating production databases. Limitation: import is per-resource (not bulk), so migrating 1,000 resources requires 1,000 import commands; tools like Terraformer automate bulk imports for entire AWS accounts.
Example 52: Moved Blocks for Resource Refactoring
moved blocks handle resource address changes (renames, module restructuring) without destroying and recreating resources. This enables safe refactoring of Terraform configurations.
terraform {
# => Terraform configuration block
required_version = ">= 1.1" # moved blocks require Terraform 1.1+
# => Sets minimum version (moved introduced in 1.1)
}
provider "local" {}
# => Provider configuration
# Original configuration (before refactoring):
# resource "local_file" "old_name" {
# filename = "data.txt"
# content = "Important data"
# }
# => State tracked resource as local_file.old_name
# Refactored configuration with moved block:
resource "local_file" "new_name" {
# => Resource renamed (new_name instead of old_name)
filename = "data.txt" # => String value
# => Sets filename (same as before)
content = "Important data" # => String value
# => Sets content (same as before)
# => Resource renamed from old_name to new_name
}
moved {
# => Moved block prevents resource destruction during rename
from = local_file.old_name
# => Original address in state
to = local_file.new_name
# => New address in configuration
# => Tells Terraform: resource address changed, don't destroy/recreate
# => State updated to track resource under new name
}
# => Moved block is migration instruction (remove after apply)
# Without moved block:
# $ terraform plan
# => Plan: 1 to add, 0 to change, 1 to destroy
# => - local_file.old_name (destroyed)
# => + local_file.new_name (created)
# => DESTROYS FILE!
# => Terraform sees old resource deleted, new resource created
# With moved block:
# $ terraform plan
# => Plan: 0 to add, 0 to change, 0 to destroy
# => No changes. Resource address updated in state without destruction.
# => Terraform updates state: old_name → new_name (no resource changes)
# Moving resources into modules
# Before:
# resource "local_file" "app_config" {
# filename = "app.txt"
# content = "App config"
# }
# => Root-level resource
# After (moved to module):
module "app" {
# => Module call (replaces root resource)
source = "./modules/app"
# => Sets module source path
}
# => Resource now inside module
# modules/app/main.tf:
# resource "local_file" "config" {
# filename = "app.txt"
# content = "App config"
# }
# => Same resource, different location
moved {
# => Moved block for module migration
from = local_file.app_config
# => Original root address
to = module.app.local_file.config
# => New module address
# => Moves resource from root to module without recreation
}
# => State updated: app_config → module.app.config
# Moving resources out of modules
moved {
# => Moved block for extracting from module
from = module.old_app.local_file.config
# => Original module address
to = local_file.extracted_config
# => New root address
}
# => Reverse migration: module → root
resource "local_file" "extracted_config" {
# => Resource extracted from module
filename = "extracted.txt"
# => Sets filename
content = "Extracted from module"
# => Sets content
}
# Moving between module instances (for_each)
# Before: Single module instance
# module "storage" {
# source = "./modules/storage"
# }
# => Single module instance
# After: for_each module instances
module "storage_regional" {
# => Module with for_each (multiple instances)
source = "./modules/storage"
# => Sets source
for_each = toset(["us-west", "eu-central"])
# => Creates multiple instances from collection
}
moved {
# => Moved block for for_each migration
from = module.storage.local_file.data
# => Original single instance address
to = module.storage_regional["us-west"].local_file.data
# => New for_each instance address
# => Moves resource from single instance to for_each instance
# => Other regions will be created (not moved)
}
# => Only us-west moved; eu-central created freshKey Takeaway: moved blocks prevent resource destruction during refactoring by updating state to track resource under new address. Syntax: moved { from = OLD_ADDRESS, to = NEW_ADDRESS }. Works for resource renames, moving to/from modules, changing module instances. Requires Terraform 1.1+. Plan shows no changes (resource not recreated). Remove moved blocks after apply—they’re migration instructions, not permanent configuration.
Why It Matters: Moved blocks enable safe refactoring that was previously terrifying—before moved blocks, renaming aws_rds_database.db to aws_rds_database.primary_db would destroy and recreate the production database, causing hours of downtime. Moved blocks make refactoring safe: reorganize modules, split monolithic configurations, rename resources for clarity, all without destroying infrastructure. Datadog used moved blocks to reorganize 200+ Terraform modules from flat structure to hierarchical, completing migration with zero resource recreation. This transforms Terraform from “never touch working code” to “continuously refactor for maintainability”, matching software engineering best practices.
Group 15: Import and State Manipulation
Example 53: State List and Show Commands
terraform state commands inspect and manipulate state file. Use for debugging, auditing, and understanding current infrastructure tracking.
terraform {
required_version = ">= 1.0" # => Terraform 1.0+ required
}
provider "local" {} # => Local provider for examples
resource "local_file" "app_config" { # => Application configuration file
filename = "app-config.txt" # => Output: app-config.txt
content = "Application configuration" # => String value
}
resource "local_file" "db_config" { # => Database configuration file
filename = "db-config.txt" # => Output: db-config.txt
content = "Database configuration" # => String value
}
module "storage" { # => Storage module instance
source = "./modules/storage" # => Local module path
bucket_prefix = "myapp" # => Prefix for bucket naming
environment = "production" # => Production environment
}State commands:
# List all resources in state
# $ terraform state list
# => local_file.app_config
# => local_file.db_config
# => module.storage.local_file.bucket
# => module.storage.local_file.bucket_policy
# Show specific resource details
# $ terraform state show local_file.app_config
# => # local_file.app_config:
# => resource "local_file" "app_config" {
# => content = "Application configuration"
# => directory_permission = "0777"
# => file_permission = "0777"
# => filename = "app-config.txt"
# => id = "abc123.."
# => }
# Show module resource
# $ terraform state show 'module.storage.local_file.bucket'
# => Displays module resource attributes
# => Note: quotes needed for module resources (shell escaping)
# Filter resources with grep pattern
# $ terraform state list | grep config
# => local_file.app_config
# => local_file.db_config
# Count resources in state
# $ terraform state list | wc -l
# => 4 (number of managed resources)
# Show all resources (verbose)
# $ terraform show
# => Displays entire state in human-readable format
# => Includes all resources, modules, outputs
# Show state in JSON format
# $ terraform show -json
# => Returns state as JSON for programmatic parsing
# => Useful for scripts, auditing, inventory systemsKey Takeaway: terraform state list shows all resources in state. terraform state show RESOURCE_ADDRESS displays specific resource attributes. terraform show displays entire state. Add -json flag for machine-readable output. These are read-only commands safe for production inspection. Use for debugging “resource not found” errors or understanding current infrastructure state.
Why It Matters: State inspection commands are critical for debugging Terraform issues—when terraform plan claims a resource doesn’t exist but you see it in AWS console, terraform state list reveals if it’s tracked in state (missing = import needed, present = configuration mismatch). JSON output enables infrastructure inventories: terraform show -json | jq '.values.root_module.resources[] | select(.type == "aws_instance") |.values.tags.Name' lists all EC2 instance names across 50+ Terraform projects for compliance audits. State inspection is the first step in diagnosing any “Terraform out of sync with reality” problem.
Example 54: State mv for Resource Renaming
terraform state mv renames resources or moves them between modules in state without destroying infrastructure. This is the CLI equivalent of moved blocks.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
# Original resource (before rename)
resource "local_file" "old_name" {
# => Resource definition
filename = "data.txt" # => String value
# => Sets filename
content = "Important data" # => String value
# => Sets content
}Renaming with state mv:
# Apply original configuration
# $ terraform apply
# => Creates local_file.old_name
# Rename resource in configuration (update HCL):
# resource "local_file" "new_name" { .. }
# Without state mv, this would destroy/recreate:
# $ terraform plan
# => Plan: 1 to add, 0 to change, 1 to destroy
# Update state to match rename (prevents destruction):
# $ terraform state mv local_file.old_name local_file.new_name
# => Move "local_file.old_name" to "local_file.new_name"
# => Successfully moved 1 object(s).
# Verify no changes needed:
# $ terraform plan
# => No changes. Your infrastructure matches the configuration.Moving resources to modules:
# Before: Root-level resource
# resource "local_file" "app_data" {
# filename = "app.txt"
# content = "App data"
# }
# After: Moved to module
module "app" {
# => Module instantiation with input variables
source = "./modules/app" # => String value
}
# modules/app/main.tf:
# resource "local_file" "data" {
# filename = "app.txt"
# content = "App data"
# }State mv to module:
# $ terraform state mv local_file.app_data module.app.local_file.data
# => Moves resource from root to module in state
# => No infrastructure changes
# Verify:
# $ terraform state list
# => module.app.local_file.dataMoving between module instances:
# Move from single module to for_each module instance:
# $ terraform state mv \
# module.storage.local_file.bucket \
# 'module.storage_regional["us-west"].local_file.bucket'
# => Quotes needed for bracket notation
# Move within for_each instances:
# $ terraform state mv \
# 'module.app["old-key"].local_file.config' \
# 'module.app["new-key"].local_file.config'Key Takeaway: terraform state mv SOURCE DESTINATION updates resource addresses in state without destroying infrastructure. Use for renaming resources, moving to/from modules, or reorganizing module structures. Always run terraform plan after state mv to verify no unwanted changes. This is CLI alternative to moved blocks (both achieve same result).
Why It Matters: State mv enables safe refactoring when moved blocks aren’t an option—if using Terraform <1.1 (no moved blocks support) or need immediate rename without committing moved block to git, state mv provides escape hatch. State mv is also useful for fixing broken imports: import to wrong address, state mv to correct address, no resource recreation. Warning: state mv is local operation (modifies state file), so team members need to pull updated state or they’ll have conflicts; moved blocks in configuration prevent this synchronization issue (better for team environments).
Example 55: State rm for Removing Resources from State
terraform state rm removes resources from Terraform state without destroying actual infrastructure. Use for excluding resources from management or cleaning up after manual deletions.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
resource "local_file" "managed" {
# => Resource definition
filename = "managed.txt" # => String value
# => Sets filename
content = "Terraform-managed" # => String value
# => Sets content
}
resource "local_file" "unmanaged" {
# => Resource definition
filename = "unmanaged.txt" # => String value
# => Sets filename
content = "Will be removed from state" # => String value
# => Sets content
}Remove resource from state:
# Apply initial configuration
# $ terraform apply
# => Creates both files
# Remove resource from state (keeps actual file)
# $ terraform state rm local_file.unmanaged
# => Removed local_file.unmanaged
# => Successfully removed 1 resource instance(s).
# Verify removal:
# $ terraform state list
# => local_file.managed
# => (local_file.unmanaged no longer in state)
# File still exists:
# $ ls unmanaged.txt
# => unmanaged.txt (infrastructure untouched)
# Plan no longer tracks removed resource:
# $ terraform plan
# => No changes (Terraform ignores unmanaged.txt)
# Next apply won't destroy unmanaged.txt:
# $ terraform apply
# => No changesRemove all module resources:
# Remove entire module from state:
# $ terraform state rm module.storage
# => Removes all resources in module.storage
# => Resources remain in cloud, but no longer managed by TerraformUse cases:
# 1. Exclude resource from Terraform management (hand off to another team)
# $ terraform state rm aws_s3_bucket.legacy_bucket
# => Bucket still exists, but Terraform no longer manages it
# => Other team can import into their Terraform
# 2. Clean up after manual deletion
# Resource manually deleted from AWS console (violating best practices)
# $ terraform plan
# => Error: resource doesn't exist but tracked in state
# $ terraform state rm aws_instance.manually_deleted
# => Removes from state, plan succeeds
# 3. Remove accidentally imported resource
# $ terraform import local_file.wrong wrong-file.txt
# => Oops, wrong resource!
# $ terraform state rm local_file.wrong
# => Removed from state, no harm doneDangerous patterns (use caution):
# DO NOT remove resource then apply expecting it to be recreated:
# $ terraform state rm local_file.data
# $ terraform apply
# => Creates NEW resource (doesn't match existing, causes conflicts)
# DO NOT remove resource from state without removing from configuration:
# $ terraform state rm local_file.app
# (leave resource block in .tf files)
# $ terraform plan
# => Terraform wants to CREATE resource again (already exists, error!)Key Takeaway: terraform state rm RESOURCE_ADDRESS removes resource from state without destroying actual infrastructure. Resource continues existing but Terraform no longer manages it. After state rm, either remove resource from configuration (hand off to manual management) or import elsewhere (transfer to different Terraform project). Don’t use state rm expecting Terraform to recreate resource—it causes conflicts with existing infrastructure.
Why It Matters: State rm is the “eject button” for resources that shouldn’t be managed by Terraform anymore—when migrating from Terraform to Kubernetes operators, state rm removes K8s resources from Terraform state without deleting them, allowing operator to take over management. State rm cleans up broken states: manually deleted resource still in state causes errors, state rm fixes it. However, state rm is dangerous in team environments: you remove resource, teammate runs apply, their outdated configuration tries to recreate removed resource, causing conflicts. Always coordinate state rm across team and update configuration simultaneously.
Example 56: Replace for Forced Resource Recreation
terraform apply -replace forces recreation of specific resources without modifying configuration. Use for repairing corrupted resources, applying manual changes, or triggering provisioners.
terraform {
# => Terraform configuration block
required_version = ">= 1.0" # => String value
# => Sets required_version
}
provider "local" {}
# => Provider configuration
resource "local_file" "app_config" {
# => Resource definition
filename = "app-config.txt" # => String value
# => Sets filename
content = "Version 1.0" # => String value
# => Sets content
provisioner "local-exec" {
# => Provisioner executes during resource lifecycle
command = "echo 'Deployed at ${timestamp()}' >> deploy.log" # => String value
# => Provisioner only runs on creation (not updates)
}
}
resource "local_file" "database_seed" {
# => Resource definition
filename = "seed.txt" # => String value
# => Sets filename
content = "Initial data" # => String value
# => Sets content
}Force recreation with -replace:
# Normal apply (no changes needed):
# $ terraform plan
# => No changes
# Manually corrupt the file (simulating problem):
# $ echo "CORRUPTED" > app-config.txt
# Plan doesn't detect manual changes:
# $ terraform plan
# => No changes (state content matches config, file content not checked)
# Force recreation:
# $ terraform apply -replace="local_file.app_config"
# => Terraform will perform the following actions:
# => # local_file.app_config will be replaced
# => -/+ resource "local_file" "app_config" {
# => ~ content = "CORRUPTED" -> "Version 1.0"
# => (file recreated with correct content)
# => (provisioner runs again)
# Verify:
# $ cat app-config.txt
# => Version 1.0 (restored)Use cases:
# 1. Trigger provisioners (which only run on creation):
# $ terraform apply -replace="null_resource.deploy_notification"
# => Forces null_resource recreation, re-running provisioners
# => Useful for re-running deployment scripts
# 2. Repair instance with corrupted state:
# $ terraform apply -replace="aws_instance.web"
# => Terminates and recreates instance
# => Fixes issues not resolvable through configuration changes
# 3. Replace multiple resources:
# $ terraform apply \
# -replace="local_file.app_config" \
# -replace="local_file.database_seed"
# => Recreates both resources in single apply
# 4. Module resource replacement:
# $ terraform apply -replace="module.app.local_file.config"
# => Replaces resource inside moduleTaint (deprecated alternative):
# Old method (deprecated in Terraform 0.15.2):
# $ terraform taint local_file.app_config
# $ terraform apply
# New method (Terraform 0.15.2+):
# $ terraform apply -replace="local_file.app_config"
# => -replace is preferred (taint command removed in Terraform 1.0)Dangerous patterns:
# DO NOT replace stateful resources without backups:
# $ terraform apply -replace="aws_rds_database.production"
# => DESTROYS PRODUCTION DATABASE!
# => Always backup before replacing data resources
# DO NOT use -replace for normal updates:
# Change content in configuration, then:
# $ terraform apply -replace="local_file.app_config"
# => Unnecessary! Normal apply handles content changes without recreation
# => -replace is for forcing recreation, not normal updatesKey Takeaway: terraform apply -replace="RESOURCE_ADDRESS" forces resource destruction and recreation without configuration changes. Use for repairing corrupted resources, triggering provisioners, or applying manual fixes. Resources destroyed before recreated (potential downtime). Avoid for stateful resources (databases, storage) without backups. Replaces deprecated terraform taint command (removed in Terraform 1.0).
Why It Matters: Replace is the “turn it off and on again” for infrastructure—when EC2 instance has corrupted disk or hung process not fixable through configuration, replace recreates instance from clean state. Replace is critical for provisioner-based workflows: provisioners only run at creation, so updating provisioner logic requires -replace to re-run them. However, replace is dangerous for production databases: terraform apply -replace on RDS instance destroys data unless you have backups and time for restoration. Always prefer configuration changes over forced replacement; use replace only when infrastructure is genuinely broken and needs recreation.
Key Takeaway for Intermediate level: Production Terraform uses modules for reusability (Examples 29-35), remote state for collaboration with locking (Examples 36-40), workspaces for environment separation with caveats (Examples 41-43), provisioners as last resort (Examples 44-47), dynamic blocks for repeated config (Examples 48-50), import for existing infrastructure (Example 51-52), and state commands for manipulation and debugging (Examples 53-56). Master these patterns to manage large-scale infrastructure efficiently and safely.
Proceed to Advanced for custom providers, testing frameworks, security patterns, multi-environment architecture, and CI/CD integration (Examples 57-84).