Terraform by HashiCorp is the most widely used infrastructure-as-code (IaC) tool in the industry. It lets you define cloud infrastructure in human-readable configuration files and manage it with a repeatable, auditable workflow. This tutorial takes you from zero to deploying real infrastructure, step by step, with no prior experience required.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install Terraform and configure your first provider |
| HCL Syntax | Write configuration files, use blocks and expressions |
| Resources | Create, update, and destroy cloud resources |
| Variables & Outputs | Make configurations reusable and parameterised |
| State | Understand how Terraform tracks infrastructure |
| Modules | Organise and reuse infrastructure code |
| Workspaces | Manage multiple environments (dev/staging/prod) |
| Real projects | Deploy a web server, S3 static site, and VPC on AWS |
Terraform version used: Terraform 1.8+ (OpenTofu 1.7+ also compatible)
Part 1 — Why Terraform?
The problem with manual infrastructure
Before IaC tools, teams provisioned servers by clicking through cloud consoles or running ad-hoc scripts. This created:
- Snowflake servers — every environment configured differently
- No audit trail — no record of who changed what
- Slow onboarding — rebuilding environments from scratch took hours
- Drift — production diverges from staging over time
How Terraform solves this
Terraform lets you describe your desired infrastructure state in code. It figures out what needs to change and applies only those changes.
Write config → terraform plan → terraform apply → infrastructure exists
| Feature | Benefit |
|---|---|
| Declarative syntax | Describe what you want, not how to create it |
| Execution plan | Preview changes before applying them |
| State management | Track what Terraform created vs what exists |
| Provider ecosystem | 4,000+ providers: AWS, Azure, GCP, GitHub, Datadog, Cloudflare... |
| Modules | Reusable, shareable infrastructure components |
| Multi-cloud | Manage resources across multiple providers in one config |
Terraform vs alternatives
| Tool | Approach | Strength | Best for |
|---|---|---|---|
| Terraform | Declarative HCL | Multi-cloud, huge ecosystem | General IaC, most teams |
| CloudFormation | Declarative YAML/JSON | Deep AWS integration | AWS-only teams |
| Pulumi | Code (TS/Python/Go) | Real programming languages | Developer-heavy teams |
| Ansible | Imperative YAML | Config management + IaC | Existing Ansible users |
| CDK | Code (TS/Python/Java) | AWS, generates CloudFormation | AWS + developer teams |
| OpenTofu | Declarative HCL | Terraform-compatible, open source | Terraform alternative |
Part 2 — Setup
Install Terraform
macOS (Homebrew):
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform version
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
Direct download: developer.hashicorp.com/terraform/downloads — grab the zip for your OS, extract, add to PATH.
Verify:
terraform version
# Terraform v1.8.x
Install the AWS CLI (for AWS examples)
# macOS
brew install awscli
# Linux
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip && sudo ./aws/install
# Configure credentials
aws configure
# AWS Access Key ID: AKIA...
# AWS Secret Access Key: ...
# Default region: us-east-1
# Default output format: json
Free tier note: All AWS examples in this tutorial use free-tier eligible resources. Watch out for NAT Gateway (not free) — it's noted where applicable.
VS Code extensions
Install the HashiCorp Terraform extension for syntax highlighting, autocompletion, and validation.
Part 3 — Core Concepts
Before writing code, understand the four core concepts.
1. Providers
Providers are plugins that connect Terraform to APIs. Each provider exposes resource types you can manage.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
2. Resources
Resources are the infrastructure objects Terraform manages — EC2 instances, S3 buckets, DNS records, etc.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
}
Syntax: resource "<provider>_<type>" "<local_name>" { ... }
3. State
Terraform stores the current state of your infrastructure in a state file (terraform.tfstate). This is how it knows what exists and what needs to change.
| State concept | What it means |
|---|---|
terraform.tfstate |
JSON file mapping config to real resources |
| Remote state | State stored in S3, GCS, or Terraform Cloud (team-safe) |
| State lock | Prevents concurrent applies (S3 + DynamoDB) |
| Refresh | Terraform reads real infrastructure to update state |
| Import | Bring existing resources under Terraform management |
4. The Terraform workflow
terraform init # Download providers and modules
terraform plan # Preview what will change
terraform apply # Apply the changes
terraform destroy # Remove all managed resources
Part 4 — Your First Configuration
Create a new directory and write your first Terraform file.
mkdir terraform-demo && cd terraform-demo
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.5.0"
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-terraform-demo-bucket-12345" # must be globally unique
tags = {
Environment = "dev"
ManagedBy = "terraform"
}
}
Run the workflow:
# Step 1: Download the AWS provider plugin
terraform init
# Step 2: See what Terraform will create
terraform plan
# Step 3: Create the bucket (type 'yes' to confirm)
terraform apply
# Step 4: Clean up
terraform destroy
terraform plan output explained:
+ resource "aws_s3_bucket" "my_bucket" {
+ bucket = "my-terraform-demo-bucket-12345"
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
+ = create, ~ = update in-place, -/+ = destroy and recreate, - = destroy
Part 5 — HCL Syntax
HCL (HashiCorp Configuration Language) is the language Terraform configurations are written in.
Blocks
Everything in Terraform is a block — a container with a type, optional labels, and a body:
<block_type> "<label_1>" "<label_2>" {
# arguments
key = value
}
Argument types
resource "aws_instance" "example" {
# String
instance_type = "t2.micro"
# Number
root_block_device {
volume_size = 20
}
# Boolean
associate_public_ip_address = true
# List
security_groups = ["sg-12345", "sg-67890"]
# Map
tags = {
Name = "example"
Env = "dev"
}
# Nested block
ebs_block_device {
device_name = "/dev/xvdb"
volume_size = 10
}
}
Expressions
# String interpolation
name = "server-${var.environment}"
# Conditional (ternary)
instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
# For expression (list)
all_tags = [for tag in var.tags : upper(tag)]
# For expression (map)
upper_tags = {for k, v in var.tags : k => upper(v)}
# Splat
bucket_arns = aws_s3_bucket.buckets[*].arn
Comments
# Single line comment
// Also single line
/*
Multi-line
comment
*/
File organization
Terraform reads all .tf files in a directory. Convention:
| File | Contents |
|---|---|
main.tf |
Primary resources |
variables.tf |
Input variable declarations |
outputs.tf |
Output value declarations |
providers.tf |
Provider and Terraform blocks |
locals.tf |
Local value computations |
versions.tf |
Required versions pinning |
Part 6 — Variables
Variables make your configurations reusable.
Input variables (variables.tf)
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
variable "instance_count" {
description = "Number of EC2 instances"
type = number
default = 1
}
variable "enable_monitoring" {
description = "Enable detailed monitoring"
type = bool
default = false
}
variable "tags" {
description = "Resource tags"
type = map(string)
default = {
ManagedBy = "terraform"
}
}
variable "allowed_cidrs" {
description = "Allowed IP ranges"
type = list(string)
default = ["10.0.0.0/8"]
}
Variable types
| Type | Example |
|---|---|
string |
"us-east-1" |
number |
3 |
bool |
true |
list(string) |
["a", "b"] |
map(string) |
{key = "value"} |
set(string) |
Like list, no duplicates, unordered |
object({...}) |
Structured object with typed attributes |
tuple([...]) |
Fixed-length list with mixed types |
any |
No type constraint |
Providing variable values
Method 1: Default values (in variables.tf)
Method 2: terraform.tfvars file (auto-loaded):
environment = "staging"
instance_count = 3
enable_monitoring = true
Method 3: .tfvars file (named, explicit):
terraform apply -var-file="prod.tfvars"
Method 4: CLI flag:
terraform apply -var="environment=prod" -var="instance_count=2"
Method 5: Environment variables:
export TF_VAR_environment=prod
terraform apply
Using variables in config (main.tf)
resource "aws_instance" "web" {
count = var.instance_count
instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
tags = merge(var.tags, { Name = "web-${var.environment}" })
}
Part 7 — Outputs
Outputs expose values from your configuration — useful for passing data between modules or displaying results.
outputs.tf:
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "bucket_arn" {
description = "ARN of the S3 bucket"
value = aws_s3_bucket.my_bucket.arn
}
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.web.id
sensitive = false
}
output "db_password" {
description = "Database password"
value = aws_db_instance.main.password
sensitive = true # won't show in plan/apply output
}
After terraform apply:
terraform output # show all outputs
terraform output instance_public_ip # show specific output
terraform output -json # JSON format
Part 8 — Locals
Locals are computed values used within a module — avoid repeating expressions.
locals.tf:
locals {
# Computed name prefix
name_prefix = "${var.project}-${var.environment}"
# Common tags merged with computed values
common_tags = merge(var.tags, {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
CreatedAt = timestamp()
})
# Conditional logic
is_production = var.environment == "prod"
# CIDR blocks
all_cidr = "0.0.0.0/0"
}
# Usage
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = local.common_tags
}
resource "aws_instance" "web" {
instance_type = local.is_production ? "t3.large" : "t3.micro"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}
Part 9 — Data Sources
Data sources let you fetch information about existing infrastructure.
# Get the latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Get current AWS account ID
data "aws_caller_identity" "current" {}
# Get available AZs in the region
data "aws_availability_zones" "available" {
state = "available"
}
# Use in resources
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id # dynamic AMI
instance_type = "t3.micro"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
AccountId = data.aws_caller_identity.current.account_id
}
}
Part 10 — Resource Meta-Arguments
count — create multiple resources
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "web-${count.index + 1}"
}
}
# Reference by index
output "web_ips" {
value = aws_instance.web[*].public_ip
}
for_each — create resources from a map or set
variable "buckets" {
default = {
logs = "my-logs-bucket-abc123"
backups = "my-backups-bucket-abc123"
assets = "my-assets-bucket-abc123"
}
}
resource "aws_s3_bucket" "buckets" {
for_each = var.buckets
bucket = each.value
tags = {
Name = each.key
}
}
# Reference by key
output "bucket_arns" {
value = {for k, v in aws_s3_bucket.buckets : k => v.arn}
}
countvsfor_each: Preferfor_eachwhen items have meaningful identifiers.countis index-based — removing item 0 renumbers everything and causes unexpected destroys.
depends_on — explicit dependency
resource "aws_s3_bucket_policy" "policy" {
bucket = aws_s3_bucket.my_bucket.id
policy = data.aws_iam_policy_document.policy.json
depends_on = [aws_s3_bucket_public_access_block.my_bucket]
}
Terraform usually infers dependencies from references. Use depends_on only for implicit dependencies.
lifecycle — control resource lifecycle
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
lifecycle {
# Don't destroy if AMI changes — update in place
ignore_changes = [ami]
# Create new before destroying old (zero downtime)
create_before_destroy = true
# Prevent accidental destruction
prevent_destroy = true
}
}
Part 11 — Modules
Modules are containers for multiple resources that are used together. They make configurations reusable, testable, and shareable.
Creating a module
Structure:
modules/
└── ec2-instance/
├── main.tf
├── variables.tf
└── outputs.tf
modules/ec2-instance/variables.tf:
variable "name" {
description = "Instance name"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "ami_id" {
description = "AMI ID"
type = string
}
variable "subnet_id" {
description = "Subnet to launch in"
type = string
}
variable "tags" {
description = "Tags to apply"
type = map(string)
default = {}
}
modules/ec2-instance/main.tf:
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
tags = merge(var.tags, { Name = var.name })
}
modules/ec2-instance/outputs.tf:
output "instance_id" {
value = aws_instance.this.id
}
output "public_ip" {
value = aws_instance.this.public_ip
}
Using the module
main.tf (root):
module "web_server" {
source = "./modules/ec2-instance"
name = "web-server"
instance_type = "t3.small"
ami_id = data.aws_ami.amazon_linux.id
subnet_id = aws_subnet.public.id
tags = local.common_tags
}
module "app_server" {
source = "./modules/ec2-instance"
name = "app-server"
instance_type = "t3.medium"
ami_id = data.aws_ami.amazon_linux.id
subnet_id = aws_subnet.private.id
tags = local.common_tags
}
output "web_ip" {
value = module.web_server.public_ip
}
Public modules from the Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
tags = local.common_tags
}
After adding a module, always run terraform init to download it.
Part 12 — State Management
Local state (default)
State is stored in terraform.tfstate in your working directory. Fine for solo use, risky for teams.
Remote state (recommended for teams)
Store state in S3 (with DynamoDB locking):
providers.tf:
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Create the S3 bucket and DynamoDB table first (manually or with a separate Terraform config):
# Create state bucket
aws s3 mb s3://my-terraform-state-bucket --region us-east-1
aws s3api put-bucket-versioning --bucket my-terraform-state-bucket \
--versioning-configuration Status=Enabled
# Create lock table
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
State commands
terraform state list # list all resources in state
terraform state show aws_instance.web # show resource details
terraform state mv aws_instance.web aws_instance.app # rename resource
terraform state rm aws_s3_bucket.old # remove from state (not destroy)
terraform import aws_s3_bucket.existing mybucket # import existing resource
terraform refresh # sync state with real infra
Part 13 — Workspaces
Workspaces let you manage multiple environments with one configuration.
terraform workspace list # list workspaces (default always exists)
terraform workspace new dev # create and switch to 'dev'
terraform workspace new prod
terraform workspace select dev
terraform workspace show # current workspace
Use terraform.workspace in config:
locals {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
db_size = terraform.workspace == "prod" ? "db.t3.medium" : "db.t3.micro"
}
resource "aws_instance" "web" {
instance_type = local.instance_type
tags = {
Environment = terraform.workspace
}
}
Workspace vs directory: For significantly different environments (different accounts, very different configs), separate directories with their own state backends are safer than workspaces.
Part 14 — Project 1: EC2 Web Server
Deploy a simple web server on AWS.
Directory structure:
project-1-ec2/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
variables.tf:
variable "region" {
default = "us-east-1"
}
variable "environment" {
default = "dev"
}
variable "instance_type" {
default = "t2.micro" # free tier
}
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
# Get latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# Security group: allow HTTP and SSH
resource "aws_security_group" "web" {
name = "web-sg-${var.environment}"
description = "Allow HTTP and SSH"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # restrict to your IP in production
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "web-sg"
Environment = var.environment
}
}
# EC2 instance with a simple web server
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Terraform!</h1><p>Environment: ${var.environment}</p>" > /var/www/html/index.html
EOF
tags = {
Name = "web-server-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
outputs.tf:
output "instance_id" {
value = aws_instance.web.id
}
output "public_ip" {
value = aws_instance.web.public_ip
}
output "website_url" {
value = "http://${aws_instance.web.public_ip}"
}
Deploy:
terraform init
terraform plan
terraform apply
# Test
curl $(terraform output -raw website_url)
# <h1>Hello from Terraform!</h1>
# Clean up
terraform destroy
Part 15 — Project 2: S3 Static Website
Host a static website on S3.
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
variable "bucket_name" {
description = "Globally unique bucket name"
type = string
}
# S3 bucket
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
tags = {
ManagedBy = "terraform"
}
}
# Enable static website hosting
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
# Disable block public access (needed for public website)
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
# Bucket policy: allow public reads
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}]
})
depends_on = [aws_s3_bucket_public_access_block.website]
}
# Upload index.html
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
content = "<h1>Hello from Terraform + S3!</h1>"
content_type = "text/html"
}
output "website_url" {
value = aws_s3_bucket_website_configuration.website.website_endpoint
}
Deploy:
terraform init
terraform apply -var="bucket_name=my-terraform-site-abc123"
# Visit: http://<bucket>.s3-website-us-east-1.amazonaws.com
Part 16 — Project 3: VPC with Public and Private Subnets
A production-ready VPC — the foundation of any AWS deployment.
main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
variable "region" { default = "us-east-1" }
variable "environment" { default = "dev" }
variable "vpc_cidr" { default = "10.0.0.0/16" }
locals {
name_prefix = "my-vpc-${var.environment}"
azs = ["${var.region}a", "${var.region}b"]
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = local.name_prefix }
}
# Public subnets
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "${local.name_prefix}-public-${count.index + 1}" }
}
# Private subnets
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = local.azs[count.index]
tags = { Name = "${local.name_prefix}-private-${count.index + 1}" }
}
# Internet Gateway (public internet access)
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${local.name_prefix}-igw" }
}
# Route table for public subnets
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
}
tags = { Name = "${local.name_prefix}-public-rt" }
}
# Associate public subnets with public route table
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
output "vpc_id" { value = aws_vpc.main.id }
output "public_subnets" { value = aws_subnet.public[*].id }
output "private_subnets" { value = aws_subnet.private[*].id }
Note: NAT Gateway is not included (it costs ~$32/month). For private subnet internet access in production, add
aws_eip+aws_nat_gateway+ a private route table.
Part 17 — Common Patterns
Environment-specific tfvars
environments/
├── dev.tfvars
├── staging.tfvars
└── prod.tfvars
terraform apply -var-file="environments/prod.tfvars"
Conditional resource creation
variable "create_db" {
type = bool
default = false
}
resource "aws_db_instance" "main" {
count = var.create_db ? 1 : 0
# ...
}
# Reference with [0] since count may be 0
output "db_endpoint" {
value = var.create_db ? aws_db_instance.main[0].endpoint : null
}
Null resource for provisioners
resource "null_resource" "app_deploy" {
triggers = {
instance_id = aws_instance.web.id
}
provisioner "remote-exec" {
inline = [
"sudo systemctl restart myapp"
]
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = aws_instance.web.public_ip
}
}
}
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Committing terraform.tfstate |
Exposes secrets, causes team conflicts | Add to .gitignore, use remote state |
Committing *.tfvars with secrets |
Credentials in git history | Use env vars or secrets manager |
Using count for maps |
Removing middle element causes shift and destroys | Use for_each with a map |
No required_version |
Config breaks when Terraform upgrades | Pin required_version = ">= 1.5.0, < 2.0.0" |
| Not pinning provider versions | Provider upgrade breaks config silently | Use version = "~> 5.0" |
Running terraform apply without plan |
Surprise changes in production | Always plan first in prod |
| Direct state edits | Corrupts state file | Use terraform state subcommands |
Hardcoded credentials in .tf files |
Security risk | Use environment vars or IAM roles |
Terraform vs related terms
| Term | What it is |
|---|---|
| Terraform | The IaC tool — defines, plans, applies infrastructure |
| HCL | HashiCorp Configuration Language — the syntax Terraform uses |
| OpenTofu | Open-source Terraform fork (BSL license change) — mostly compatible |
| Terragrunt | Wrapper around Terraform — DRY configs, remote state management |
| Terratest | Go library for testing Terraform modules |
| tfsec | Static analysis for Terraform security misconfigurations |
| infracost | Cost estimation for Terraform configurations |
| Terraform Cloud | HashiCorp's managed Terraform (remote runs, state, SSO) |
| Pulumi | IaC using real programming languages (TypeScript, Python, Go) |
| CDK for Terraform | Write Terraform in TypeScript/Python using CDK constructs |
Learning path
| Stage | Focus | Time |
|---|---|---|
| 1 | Setup, init/plan/apply/destroy, first S3 bucket |
Week 1 |
| 2 | Variables, outputs, locals, data sources | Week 2 |
| 3 | VPC, EC2, security groups — real AWS resources | Week 3 |
| 4 | Modules — create and consume | Week 4 |
| 5 | Remote state, workspaces, team workflow | Week 5 |
| 6 | Terraform with CI/CD (GitHub Actions) | Week 6–7 |
| 7 | Advanced: for_each, dynamic blocks, custom providers |
Week 8–10 |
Certifications:
- HashiCorp Terraform Associate (003) — the primary Terraform cert (~$70 USD, 57 questions)
Practice:
- HashiCorp Learn — official interactive tutorials
- AWS Free Tier — practice without cost
- Terraform Registry — explore modules and providers
Useful .gitignore for Terraform
# State files — use remote state
*.tfstate
*.tfstate.*
.terraform.tfstate.lock.info
# Provider plugins (downloaded by terraform init)
.terraform/
# Variable files that may contain secrets
*.tfvars
*.tfvars.json
!example.tfvars # except examples without real values
# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# CLI configuration
.terraformrc
terraform.rc
# Crash logs
crash.log
crash.*.log
Quick reference
# Workflow
terraform init # init working directory
terraform validate # validate config syntax
terraform fmt # format config files
terraform plan # show execution plan
terraform plan -out=tfplan # save plan to file
terraform apply # apply changes
terraform apply tfplan # apply saved plan
terraform apply -auto-approve # skip confirmation (CI only)
terraform destroy # destroy all resources
terraform destroy -target=aws_instance.web # destroy specific resource
# State
terraform state list
terraform state show <resource>
terraform state mv <old> <new>
terraform state rm <resource>
terraform import <resource> <id>
terraform refresh
# Workspace
terraform workspace list
terraform workspace new <name>
terraform workspace select <name>
terraform workspace delete <name>
# Debugging
TF_LOG=DEBUG terraform apply # verbose logs
terraform console # interactive expression evaluation
terraform graph | dot -Tpng > graph.png # dependency graph
FAQ
Do I need to know cloud (AWS/Azure/GCP) before learning Terraform? Basic cloud concepts help — what a VPC, subnet, and EC2 instance are. But you don't need to be a cloud expert. Start with simple S3 buckets, then add EC2, then VPCs as you learn both simultaneously.
What's the difference between Terraform and Ansible?
Terraform excels at provisioning infrastructure (create a server, VPC, database). Ansible excels at configuring what's already running (install packages, configure files). They're complementary — use Terraform to provision, Ansible to configure. Some teams use only Terraform with user_data/cloud-init for basic configuration.
Should I use Terraform Cloud or manage state in S3? For teams: S3 + DynamoDB (free) is sufficient for most. Terraform Cloud adds a UI, Sentinel policies, and SSO — worth it for larger orgs. For solo projects: local state is fine.
What's OpenTofu and should I use it instead? OpenTofu is a Terraform fork created after HashiCorp changed from MPL to BSL license in 2023. It's mostly syntax-compatible. If license matters to you or your org, OpenTofu is a solid alternative. For beginners, either works.
How do I handle secrets in Terraform?
Never put secrets in .tf files or .tfvars that get committed. Options: environment variables (TF_VAR_db_password), AWS Secrets Manager / HashiCorp Vault with data sources, or CI/CD secret injection. Mark sensitive outputs with sensitive = true.
How is for_each different from count?
count uses a number index — removing element 0 shifts everything and causes destroys. for_each uses a map or set of strings as keys — removing one key only destroys that resource. Use for_each whenever your resources have meaningful identifiers.