Terraform IaC Workflow, Modules, and Practical Patterns
Core Terraform workflow, provider config, variables, remote state with S3 backend, modules, and a working AWS EC2 example for infrastructure engineers.
Terraform is my go-to tool for provisioning any cloud infrastructure that needs to be reproducible, reviewed, and version-controlled. These notes cover the workflow I use daily — from first init through module composition and remote state management. Code examples use AWS but the patterns apply equally to GCP and Azure by swapping the provider block.
Downloads provider plugins and sets up the backend. Run this once per checkout, and again whenever you add providers or change the backend config.
terraform init# Upgrade providers to the latest allowed by version constraintsterraform init -upgrade# Reconfigure the backend (e.g., changing S3 bucket or key)terraform init -reconfigure
2
terraform fmt — format code
Standardize HCL formatting before committing. Add this to your pre-commit hooks.
# Format all .tf files in the current directory recursivelyterraform fmt -recursive# Check formatting without modifying files (use in CI)terraform fmt -check -recursive
3
terraform validate — check configuration syntax
Validates the configuration without accessing any remote services or state. Fast and safe to run locally.
terraform validate
4
terraform plan — preview changes
Shows exactly what Terraform will create, modify, or destroy. Always review this before applying.
terraform plan# Save the plan to a file (use this in CI/CD for consistent apply)terraform plan -out=tfplan# Plan with variable overridesterraform plan -var="instance_type=t3.small" -var="env=staging"# Plan with a tfvars fileterraform plan -var-file=staging.tfvars
5
terraform apply — provision resources
# Interactive apply with confirmation promptterraform apply# Apply a saved plan (no prompt, safe for CI/CD)terraform apply tfplan# Auto-approve without prompt (use carefully)terraform apply -auto-approve
6
terraform destroy — tear down resources
# Destroy all resources managed by this stateterraform destroy# Destroy a specific resource (target flag)terraform destroy -target=aws_instance.web_server# Preview what would be destroyedterraform plan -destroy
Never run terraform apply or terraform destroy directly in production without a saved plan file reviewed in a pull request. Even terraform apply -auto-approve in CI should only run after a plan output has been reviewed and approved.
# versions.tfterraform { required_version = ">= 1.6.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } random = { source = "hashicorp/random" version = "~> 3.6" } }}provider "aws" { region = var.aws_region # Tag every resource managed by this configuration default_tags { tags = { ManagedBy = "terraform" Environment = var.environment Project = var.project_name } }}# Second provider block for a different region (e.g., us-west-2 for DR)provider "aws" { alias = "dr" region = "us-west-2"}
Use ~> (pessimistic constraint) for provider versions: ~> 5.0 allows 5.x but not 6.x. This protects against breaking changes in major versions while still getting patch updates automatically on init -upgrade.
variable "aws_region" { description = "AWS region to deploy resources into" type = string default = "us-east-1"}variable "environment" { description = "Deployment environment (dev, staging, prod)" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be one of: dev, staging, prod." }}variable "project_name" { description = "Project identifier used in resource naming and tags" type = string}variable "instance_type" { description = "EC2 instance type" type = string default = "t3.micro"}variable "allowed_cidr_blocks" { description = "List of CIDR blocks allowed SSH access" type = list(string) default = []}variable "tags" { description = "Additional tags to apply to all resources" type = map(string) default = {}}
locals { # Computed naming convention — consistent across all resources name_prefix = "${var.project_name}-${var.environment}" # Merge default tags with any extra tags passed in common_tags = merge( { Project = var.project_name Environment = var.environment ManagedBy = "terraform" }, var.tags ) # Is this a production deployment? is_prod = var.environment == "prod" # Derive instance count from environment instance_count = local.is_prod ? 3 : 1}
output "instance_id" { description = "EC2 instance ID" value = aws_instance.web_server.id}output "instance_public_ip" { description = "Public IP address of the web server" value = aws_instance.web_server.public_ip}output "security_group_id" { description = "ID of the web server security group" value = aws_security_group.web.id}output "bucket_name" { description = "Name of the S3 bucket" value = aws_s3_bucket.app_assets.id}# Sensitive outputs are redacted in plan/apply outputoutput "db_password" { description = "RDS master password" value = random_password.db.result sensitive = true}
# terraform.tfvars — committed defaults (no secrets here!)# Override with staging.tfvars or prod.tfvars as neededaws_region = "us-east-1"project_name = "myapp"environment = "dev"instance_type = "t3.micro"allowed_cidr_blocks = [ "10.0.0.0/8"]tags = { CostCenter = "engineering" Owner = "platform-team"}
# Show the current state (list all managed resources)terraform state list# Inspect a specific resource in stateterraform state show aws_instance.web_server# Move a resource to a new address (after rename/refactor)terraform state mv \ aws_instance.web_server \ module.web_server.aws_instance.this# Remove a resource from state without destroying it# (useful when importing existing infra management)terraform state rm aws_s3_bucket.legacy# Import an existing AWS resource into stateterraform import aws_s3_bucket.existing my-existing-bucket-name# Refresh state to match real infrastructureterraform apply -refresh-only# Graph resource dependencies (requires graphviz)terraform graph | dot -Tsvg > graph.svg# Show what the workspace thinks is deployedterraform show# Taint a resource to force recreation on next applyterraform taint aws_instance.web_server # Terraform < 1.0terraform apply -replace=aws_instance.web_server # Terraform ≥ 1.0# Output a specific valueterraform output instance_public_ipterraform output -json # all outputs as JSON
Use workspaces (terraform workspace new staging) for managing multiple environments from a single config, but be aware they share the same backend bucket. Many teams prefer separate state files per environment with different key paths in the S3 backend — it’s more explicit and easier to reason about access controls.