Toolmingo
Guides20 min read

Terraform Tutorial for Beginners (2025): Learn IaC Step by Step

Complete Terraform tutorial for beginners. Learn HCL, providers, resources, variables, modules, state, and deploy real infrastructure on AWS. Free step-by-step guide.

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}
}

count vs for_each: Prefer for_each when items have meaningful identifiers. count is 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:


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.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools