Terraform: The Complete Guide for 2026
Terraform changed how teams manage infrastructure. Instead of clicking through cloud consoles or writing fragile shell scripts, you define your entire infrastructure — servers, databases, networks, DNS records — in declarative configuration files that you version, review, and apply like application code. In 2026, Terraform is the most widely adopted infrastructure-as-code tool, supporting every major cloud provider and hundreds of SaaS platforms.
This guide covers Terraform from first principles through production patterns. Whether you are provisioning your first EC2 instance or designing a multi-account AWS organization with shared modules and remote state, every section includes practical HCL examples. Pair this with our Docker Complete Guide and Kubernetes Complete Guide for the full infrastructure stack.
Table of Contents
- What is Terraform and Infrastructure as Code
- Installing Terraform
- HCL Syntax Basics
- Providers
- Resources and Data Sources
- Variables and Outputs
- State Management
- Modules
- Workspaces
- Plan, Apply, Destroy Workflow
- Terraform Cloud and Enterprise
- Best Practices
- Common Patterns
- Terraform vs Pulumi vs CloudFormation
- Troubleshooting
- FAQ
1. What is Terraform and Infrastructure as Code
Infrastructure as Code (IaC) is the practice of managing infrastructure through machine-readable configuration files rather than manual processes. Instead of logging into the AWS console to create a VPC, subnet, security group, and EC2 instance, you write a configuration file that declares exactly what you want. A tool then creates, modifies, or destroys resources to match that declaration.
Terraform is HashiCorp's open-source IaC tool. It uses HCL (HashiCorp Configuration Language) to define infrastructure. You describe the desired end state, and Terraform figures out the steps — creating resources that do not exist, modifying resources that have drifted, and destroying resources no longer declared.
# The Terraform workflow:
# 1. WRITE - Define infrastructure in .tf files
# 2. INIT - Download providers and initialize backend
# 3. PLAN - Preview what will change
# 4. APPLY - Execute the plan
# 5. DESTROY - Tear down resources when needed
#
# .tf files --> terraform plan --> terraform apply --> Cloud Resources
# | |
# State File State Updated
- Version control — infrastructure changes tracked in Git with full history and rollback
- Code review — infrastructure changes go through pull requests like application code
- Reproducibility — identical environments from the same configuration
- Automation — CI/CD pipelines apply changes automatically on merge
- Disaster recovery — rebuild entire environments from code in minutes
2. Installing Terraform
Terraform is a single binary with no dependencies. Install on any platform in under a minute:
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Ubuntu/Debian
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Windows (Chocolatey)
choco install terraform
# Verify
terraform -version # Terraform v1.9.x
Install the HashiCorp Terraform extension for VS Code for syntax highlighting, auto-completion, and formatting.
3. HCL Syntax Basics
HCL (HashiCorp Configuration Language) is designed to be human-readable while remaining machine-parseable. Everything is organized into blocks:
# Block syntax: type "label1" "label2" { body }
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = { Name = "web-server" }
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
output "instance_ip" {
value = aws_instance.web.public_ip
}
Data Types
name = "web-server" # string
count = 3 # number
enabled = true # boolean
zones = ["us-east-1a", "us-east-1b", "us-east-1c"] # list
tags = { Environment = "prod", Team = "platform" } # map
Expressions and Functions
# String interpolation
name = "server-${var.environment}-${count.index}"
# Conditional
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
# Built-in functions
upper_name = upper(var.name)
cidr = cidrsubnet("10.0.0.0/16", 8, 1) # "10.0.1.0/24"
content = file("${path.module}/config.json")
# For expressions
upper_names = [for name in var.names : upper(name)]
filtered = [for s in var.subnets : s if s.public == true]
4. Providers
Providers are plugins that let Terraform interact with cloud platforms and APIs. The Terraform Registry hosts over 4,000 providers.
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = { ManagedBy = "terraform", Environment = var.environment }
}
}
# Multiple provider instances with aliases
provider "aws" {
region = "us-east-1"
alias = "east"
}
provider "aws" {
region = "eu-west-1"
alias = "europe"
}
resource "aws_s3_bucket" "eu_data" {
provider = aws.europe
bucket = "my-app-eu-data"
}
# Authentication: use environment variables (never hardcode)
# export AWS_ACCESS_KEY_ID="..."
# export AWS_SECRET_ACCESS_KEY="..."
# export AWS_REGION="us-east-1"
# Or use AWS profiles
provider "aws" {
region = "us-east-1"
profile = "my-project"
}
5. Resources and Data Sources
Resources are the core building blocks — each block describes an infrastructure object that Terraform creates, updates, and deletes.
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = { Name = "web-server" }
}
Data sources fetch information about existing resources not managed by this Terraform config:
# Look up the latest Amazon Linux AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# Look up available AZs
data "aws_availability_zones" "available" {
state = "available"
}
# Implicit dependency: Terraform detects references automatically
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # depends on aws_vpc.main
cidr_block = "10.0.1.0/24"
}
# Explicit dependency when there is no direct reference
resource "aws_instance" "web" {
# ...
depends_on = [aws_iam_role_policy_attachment.web_policy]
}
6. Variables and Outputs
# variables.tf
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Must be dev, staging, or production."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "allowed_cidrs" {
description = "CIDR blocks allowed to access the app"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "db_password" {
description = "Database password"
type = string
sensitive = true # hidden in plan output
}
# Setting variable values (precedence highest to lowest):
# 1. -var flag: terraform apply -var="environment=production"
# 2. *.auto.tfvars files: loaded automatically
# 3. terraform.tfvars: loaded automatically
# 4. Environment variables: export TF_VAR_environment="production"
# 5. Default values
# terraform.tfvars
environment = "production"
instance_type = "t3.large"
allowed_cidrs = ["10.0.0.0/8"]
# outputs.tf
output "public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "db_endpoint" {
description = "RDS endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
# Local values for computed values used multiple times
locals {
name_prefix = "${var.project}-${var.environment}"
is_production = var.environment == "production"
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
7. State Management
Terraform state is a JSON file that maps configuration to real-world resources. It is the most important concept for production usage. Without state, Terraform cannot determine what exists and would create duplicates on every apply.
# Remote state with S3 (most common for AWS)
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "projects/web-app/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks" # state locking
encrypt = true
}
}
# Azure Blob Storage backend
terraform {
backend "azurerm" {
resource_group_name = "terraform-state-rg"
storage_account_name = "tfstate29471"
container_name = "tfstate"
key = "web-app.terraform.tfstate"
}
}
# GCS backend
terraform {
backend "gcs" {
bucket = "my-company-terraform-state"
prefix = "projects/web-app"
}
}
State Commands
terraform state list # list all resources
terraform state show aws_instance.web # show resource details
terraform state mv aws_instance.web aws_instance.app # rename
terraform state rm aws_instance.legacy # stop managing a resource
terraform import aws_instance.web i-0abc123 # import existing resource
terraform force-unlock LOCK_ID # unlock stuck state
8. Modules
Modules are reusable packages of Terraform configuration. They encapsulate common patterns (VPC setup, database cluster, K8s namespace) into shareable components.
# Module structure:
# modules/vpc/
# main.tf, variables.tf, outputs.tf, versions.tf
# modules/vpc/variables.tf
variable "name" { type = string }
variable "cidr_block" { type = string; default = "10.0.0.0/16" }
variable "public_subnets" { type = list(string); default = ["10.0.1.0/24", "10.0.2.0/24"] }
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.name}-public-${count.index + 1}" }
}
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
Using Modules
# From a local path
module "vpc" {
source = "./modules/vpc"
name = "my-app"
cidr_block = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}
# From the Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-app-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24"]
enable_nat_gateway = true
}
# From a Git repository
module "network" {
source = "git::https://github.com/myorg/tf-modules.git//network?ref=v2.1.0"
}
# Reference module outputs
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
}
9. Terraform Workspaces
Workspaces let you manage multiple instances of the same infrastructure from one configuration. Each workspace has its own state file.
terraform workspace list # show all (* marks current)
terraform workspace new dev # create workspace
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev # switch workspace
# Use workspace name in configuration
locals {
env_config = {
dev = { instance_type = "t3.micro", count = 1 }
staging = { instance_type = "t3.small", count = 2 }
prod = { instance_type = "t3.large", count = 3 }
}
config = local.env_config[terraform.workspace]
}
resource "aws_instance" "web" {
count = local.config.count
instance_type = local.config.instance_type
tags = { Name = "web-${terraform.workspace}", Environment = terraform.workspace }
}
For production, most teams prefer directory-per-environment over workspaces. Each environment (dev/, staging/, prod/) has its own backend config and tfvars but references shared modules. This allows environments to diverge and be deployed independently.
10. Plan, Apply, Destroy Workflow
# Initialize (download providers, set up backend)
terraform init
terraform init -upgrade # upgrade provider versions
terraform init -migrate-state # migrate to new backend
# Validate and format
terraform validate # check syntax
terraform fmt -recursive # format all .tf files
# Plan (preview changes)
terraform plan # show what will change
terraform plan -out=tfplan # save plan for exact apply
terraform plan -target=aws_instance.web # plan specific resource
# Apply (execute changes)
terraform apply # apply with confirmation prompt
terraform apply tfplan # apply saved plan (no prompt)
terraform apply -auto-approve # skip prompt (CI/CD only)
# Destroy
terraform destroy # destroy all resources
terraform destroy -target=aws_instance.web # destroy specific resource
terraform plan -destroy # preview what will be destroyed
# Other commands
terraform show # show current state
terraform output # display output values
terraform graph | dot -Tpng > graph.png # dependency graph
terraform apply -refresh-only # detect drift without changes
terraform apply -replace=aws_instance.web # force resource recreation
11. Terraform Cloud and Enterprise
Terraform Cloud is HashiCorp's managed platform for running Terraform in teams. It provides remote state, locking, VCS integration, policy enforcement, and cost estimation.
terraform {
cloud {
organization = "my-company"
workspaces { name = "web-app-production" }
}
}
# Authenticate: terraform login
# Then: terraform init && terraform plan && terraform apply
- Remote state — encrypted, versioned, with built-in locking
- VCS integration — auto-trigger plans on pull requests from GitHub/GitLab
- Private module registry — share modules within your organization
- Sentinel policies — policy-as-code (e.g., "all S3 buckets must have encryption")
- Cost estimation — see estimated cost changes before applying
- Run triggers — chain workspaces so changes cascade to dependents
12. Best Practices
State Management
- Always use remote state with locking and encryption enabled
- Never commit state to Git — add
*.tfstate*to.gitignore - Separate state per environment — a dev bug should never affect production
Code Organization
# Recommended .gitignore
*.tfstate
*.tfstate.backup
*.tfstate.lock.info
.terraform/
*.tfplan
crash.log
# Recommended file structure
main.tf # primary resources
variables.tf # input variables
outputs.tf # output values
versions.tf # version constraints
providers.tf # provider config
locals.tf # local values
data.tf # data sources
Module Best Practices
- Version modules — use Git tags, never point to
mainbranch - Keep modules focused — one concern per module
- Document everything —
descriptionon every variable and output - Add validation — catch errors before apply with
validationblocks
Security
# Protect critical resources from accidental deletion
resource "aws_db_instance" "main" {
# ...
lifecycle { prevent_destroy = true }
}
# Ignore external changes to specific attributes
resource "aws_instance" "web" {
# ...
lifecycle { ignore_changes = [tags, ami] }
}
# Mark sensitive variables
variable "db_password" {
type = string
sensitive = true # hidden in plan output and logs
}
# Use least-privilege IAM for the Terraform execution role
# Review plans before apply - never auto-approve production
13. Common Patterns (VPC, EC2, S3)
VPC with Public and Private Subnets
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "main-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "public-${count.index + 1}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "private-${count.index + 1}" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route { cidr_block = "0.0.0.0/0"; gateway_id = aws_internet_gateway.main.id }
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
EC2 with Security Group
resource "aws_security_group" "web" {
name_prefix = "web-"
vpc_id = aws_vpc.main.id
ingress { from_port = 80; to_port = 80; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 443; to_port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 22; to_port = 22; protocol = "tcp"; cidr_blocks = [var.admin_cidr] }
egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = var.key_name
user_data = <<-EOF
#!/bin/bash
yum update -y && yum install -y httpd
systemctl start httpd && systemctl enable httpd
echo "Hello from Terraform" > /var/www/html/index.html
EOF
tags = { Name = "web-server" }
}
S3 Bucket with Versioning and Encryption
resource "aws_s3_bucket" "data" {
bucket = "${var.project}-data-${var.environment}"
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } }
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
14. Terraform vs Pulumi vs CloudFormation
Feature Terraform Pulumi CloudFormation
Language HCL (declarative) Python/TS/Go/C# JSON/YAML
Cloud support Multi-cloud Multi-cloud AWS only
State Self/Cloud managed Pulumi Cloud AWS-managed
Ecosystem 4000+ providers Growing AWS-native
Community Very large Growing Large (AWS)
Testing terraform test Standard frameworks cfn-lint
Best for Multi-cloud teams Devs who prefer Pure AWS shops
real languages
- Terraform — multi-cloud, mature ecosystem, declarative config, largest community
- Pulumi — use TypeScript/Python/Go, complex logic, standard testing
- CloudFormation — AWS-only, deep AWS integration, no external state to manage
- CDK — generates CloudFormation or Terraform from TypeScript/Python
15. Troubleshooting Common Issues
State Lock Errors
# Error: Error acquiring the state lock
# Another operation is running or a previous run crashed
terraform force-unlock LOCK_ID # LOCK_ID shown in error message
# Only use when certain no other operation is running
Provider Authentication
# Error: no valid credential sources found
aws sts get-caller-identity # verify AWS creds
gcloud auth list # verify GCP creds
az account show # verify Azure creds
export AWS_PROFILE=my-profile # fix wrong profile
aws sso login --profile my-profile # refresh SSO session
State Drift
# Someone changed a resource outside Terraform
terraform plan # shows unexpected changes
# Option 1: Accept the change (update code to match reality)
terraform apply -refresh-only
# Option 2: Revert (enforce your code)
terraform apply # modifies resource to match code
Resource Already Exists
# Error: BucketAlreadyOwnedByYou
# Resource exists but Terraform does not know about it
terraform import aws_s3_bucket.data my-bucket-name
terraform plan # verify code matches reality
Dependency Cycles
# Error: Cycle: aws_security_group.a, aws_security_group.b
# Fix: use separate aws_security_group_rule resources
resource "aws_security_group" "a" { name_prefix = "sg-a-"; vpc_id = aws_vpc.main.id }
resource "aws_security_group" "b" { name_prefix = "sg-b-"; vpc_id = aws_vpc.main.id }
resource "aws_security_group_rule" "a_to_b" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
security_group_id = aws_security_group.b.id
source_security_group_id = aws_security_group.a.id
}
Frequently Asked Questions
What is the difference between Terraform and CloudFormation?
Terraform is cloud-agnostic and works with AWS, Azure, GCP, and hundreds of other providers using HCL. CloudFormation is AWS-only and uses JSON or YAML. Terraform maintains a state file you manage; CloudFormation manages state internally. Terraform is better for multi-cloud organizations; CloudFormation offers deeper AWS integration and no external state file to manage.
How does Terraform state work and why is it important?
Terraform state is a JSON file (terraform.tfstate) that maps your configuration to real-world resources. When you create an EC2 instance, state records the instance ID so Terraform knows which real resource corresponds to which code block. Without state, Terraform would try to create duplicate resources on every apply. Always store state remotely with locking and encryption enabled. Never commit state files to version control.
Should I use Terraform modules?
Yes, for anything beyond a simple experiment. Use modules when you repeat resource patterns across projects, want to enforce organizational standards, or need teams to provision similar infrastructure independently. Start with verified modules from the Terraform Registry, then build custom modules. Keep them focused on a single concern and version them with Git tags.
How do I handle secrets in Terraform?
Never hardcode secrets in .tf files. Use environment variables (TF_VAR_name), external secret managers (Vault, AWS Secrets Manager, Azure Key Vault), or CI/CD pipeline variables. Mark sensitive variables with sensitive = true to prevent them appearing in plan output. Enable encryption on remote state backends. Consider generating secrets with the random provider and storing them directly in a secrets manager.
What is the difference between Terraform and Pulumi?
Terraform uses HCL, a declarative language designed for infrastructure. Pulumi uses general-purpose languages (Python, TypeScript, Go, C#). Terraform has a larger ecosystem with 4,000+ providers and wider adoption. Pulumi offers full programming language power including loops, classes, and standard testing frameworks. Choose Terraform for its mature ecosystem; choose Pulumi if your team prefers familiar programming languages and needs complex logic.
Conclusion
Terraform is the industry standard for infrastructure as code in 2026. The patterns in this guide scale from your first terraform apply to production-grade infrastructure management with remote state, modules, policy enforcement, and CI/CD automation.
Start with three things: provision one real resource, understand the plan-apply-destroy lifecycle, and set up remote state with locking. Everything else — modules, workspaces, Terraform Cloud — builds on this foundation. The time you invest in mastering Terraform pays dividends every time you provision an environment, recover from a disaster, or onboard a new team member.
Learn More
- Docker: The Complete Developer's Guide — containerize the applications you deploy with Terraform
- Docker Compose: The Complete Guide — orchestrate multi-container apps on a single host
- Kubernetes: The Complete Developer's Guide — orchestrate containers on Terraform-provisioned clusters
- REST API Design Guide — design APIs for your Terraform-managed infrastructure
- Docker Commands Cheat Sheet — Docker CLI quick reference
- Linux Commands Cheat Sheet — manage the servers Terraform provisions