Terraform Basics: Infrastructure as Code Done Right
Terraform is HashiCorp’s Infrastructure as Code (IaC) tool that lets you define and provision infrastructure using declarative configuration files. Instead of clicking through AWS consoles or running shell scripts, you describe your desired infrastructure state, and Terraform makes it happen.
Core Concepts
Resources
Resources are the fundamental building blocks in Terraform - they represent infrastructure components like EC2 instances, S3 buckets, or VPCs.
resource "aws_instance" "web_server" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
tags = {
Name = "WebServer"
}
}
Each resource has a type (aws_instance
) and a local name (web_server
) that you use to reference it elsewhere in your configuration.
Data Sources
Data sources allow you to fetch information about existing infrastructure that Terraform doesn’t manage directly.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
This fetches the latest Ubuntu AMI and uses it in the EC2 instance resource.
Variables
Variables make your Terraform configurations reusable and flexible.
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Environment name"
type = string
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
You can provide variable values through .tfvars
files, command line, or environment variables.
Outputs
Outputs expose information about your infrastructure after it’s created.
output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
Locals
Locals are named values that you can reuse throughout your configuration - think of them as internal variables.
locals {
common_tags = {
Environment = var.environment
Project = "WebApp"
ManagedBy = "Terraform"
}
name_prefix = "${var.environment}-webapp"
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = local.common_tags
}
Providers
Providers are plugins that interact with APIs of cloud platforms, SaaS providers, and other services.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Practical Example: VPC Setup
Here’s a common pattern for setting up a VPC (Virtual Private Cloud - AWS’s isolated network environment):
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${local.name_prefix}-vpc"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-igw"
}
}
# Public Subnet
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${local.name_prefix}-public-${count.index + 1}"
Type = "Public"
}
}
# Route Table
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"
}
}
# Route Table Association
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
For Loops and Dynamic Blocks
Terraform provides several ways to iterate and create multiple similar resources.
Count
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "${local.name_prefix}-web-${count.index + 1}"
}
}
for_each
More flexible than count, especially when you need to reference resources by key:
variable "environments" {
default = {
dev = "t3.micro"
prod = "t3.small"
}
}
resource "aws_instance" "app" {
for_each = var.environments
ami = data.aws_ami.ubuntu.id
instance_type = each.value
tags = {
Name = "${each.key}-app"
Environment = each.key
}
}
How does each.key
work?
When using for_each
with a map, Terraform iterates over each key-value pair:
each.key
= the map key (“dev”, “prod”)each.value
= the map value (“t3.micro”, “t3.small”)
So "${each.key}-app"
becomes “dev-app” and “prod-app” respectively.
Dynamic Blocks
For creating nested blocks dynamically:
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}
Modules
Modules are reusable Terraform configurations. Think of them as functions - they take inputs (variables) and produce outputs. This is arguably the most important concept for scaling Terraform usage across teams and projects.
Why modules matter: Instead of copying and pasting the same VPC configuration across 10 projects, you create a VPC module once and reuse it everywhere. When you need to add a security group rule to all VPCs, you update the module once instead of 10 separate configurations.
Creating a Module
A module is just a directory containing .tf
files. Here’s a simple web server module:
# modules/web-server/main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.this.id]
tags = var.tags
}
resource "aws_security_group" "this" {
name = "${var.name}-sg"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# modules/web-server/variables.tf
variable "name" {
description = "Name prefix for resources"
type = string
}
variable "ami_id" {
description = "AMI ID for the instance"
type = string
}
variable "instance_type" {
description = "Instance type"
type = string
default = "t3.micro"
}
variable "subnet_id" {
description = "Subnet ID"
type = string
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
# modules/web-server/outputs.tf
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IP of the instance"
value = aws_instance.this.public_ip
}
Using the Module
module "web_server" {
source = "./modules/web-server"
name = "my-web-app"
ami_id = data.aws_ami.ubuntu.id
instance_type = "t3.small"
subnet_id = aws_subnet.public[0].id
vpc_id = aws_vpc.main.id
tags = local.common_tags
}
output "web_server_ip" {
value = module.web_server.public_ip
}
Essential Commands
terraform init
Initializes a Terraform working directory. Downloads providers and modules.
terraform init
terraform plan
Shows what Terraform will do without actually doing it. Always run this before apply.
terraform plan
terraform plan -out=tfplan # Save plan to file
terraform apply
Creates, updates, or deletes infrastructure to match your configuration.
terraform apply
terraform apply tfplan # Apply saved plan
terraform apply -auto-approve # Skip confirmation
terraform destroy
Destroys all resources managed by the configuration.
terraform destroy
terraform destroy -target=aws_instance.web # Destroy specific resource
terraform fmt
Formats Terraform files to a canonical style.
terraform fmt
terraform fmt -recursive # Format all files in subdirectories
terraform validate
Validates the configuration syntax.
terraform validate
State Management
Terraform State File
Terraform stores information about your infrastructure in a state file (.tfstate
). This file maps your configuration to the real world and tracks resource metadata.
Important: Never edit the state file manually. Use Terraform commands instead.
Remote State
For team environments, store state remotely:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
State Lock
The .terraform.lock.hcl
file locks dependency versions to ensure consistent builds across team members and environments.
# .terraform.lock.hcl (auto-generated)
provider "registry.terraform.io/hashicorp/aws" {
version = "5.17.0"
constraints = "~> 5.0"
hashes = [
"h1:U+EDfeUqefebA1h7KyBMD1xH2dPdGcYfgEUvDU6gT7w=",
# ... more hashes
]
}
Never delete this file. Commit it to version control.
Terraform vs Other IaC Tools
Terraform vs CloudFormation
Terraform Pros:
- Multi-cloud support
- More intuitive HCL syntax
- Better state management
- Larger ecosystem of providers
- Plan functionality shows exact changes
Terraform Cons:
- State file management complexity
- Learning curve for HCL
- Requires separate tooling for some AWS-specific features
CloudFormation Pros:
- Native AWS integration
- No state file to manage
- Tight integration with AWS services
- Automatic rollback on failure
CloudFormation Cons:
- AWS-only
- JSON/YAML can be verbose
- Slower to support new AWS features
- Limited preview capabilities
Terraform vs Pulumi
Terraform Pros:
- Declarative approach
- Mature ecosystem
- Large community
- Domain-specific language (HCL)
Terraform Cons:
- Limited programming constructs
- State management complexity
Pulumi Pros:
- Use familiar programming languages (Python, TypeScript, Go, etc.)
- Better for complex logic and calculations
- Native testing frameworks
- Object-oriented approach
Pulumi Cons:
- Smaller ecosystem
- Steeper learning curve for traditional ops teams
- Can lead to over-engineered infrastructure code
Terraform vs Ansible
These tools serve different purposes but sometimes overlap:
Terraform Pros:
- Purpose-built for infrastructure provisioning
- Better state management for infrastructure
- Declarative approach ideal for infrastructure
Terraform Cons:
- Not designed for configuration management
- Limited post-deployment capabilities
Ansible Pros:
- Excellent for configuration management
- Agentless architecture
- Great for application deployment
- Imperative approach good for complex workflows
Ansible Cons:
- Weaker infrastructure provisioning
- No native state tracking
- Can be slower for large-scale operations
Best Practices
- Always use version control for your Terraform configurations
- Use remote state for team environments
- Pin provider versions to avoid unexpected changes
- Use modules for reusable components
- Run terraform plan before every apply
- Use consistent naming conventions and tagging
- Separate environments (dev, staging, prod) into different state files
- Never commit secrets to version control - use variables or secret management tools
Conclusion
Terraform strikes an excellent balance between simplicity and power. Its declarative approach, multi-cloud support, and mature ecosystem make it the de facto standard for infrastructure as code. While it has a learning curve, the investment pays off quickly in terms of reproducibility, version control, and team collaboration.
I personally prefer Terraform over proprietary IaC like CloudFormation or ARM templates since it’s multiplatform and very widely known. Also, Terraform wins in maturity (also against Pulumi & co IMO) and the provider ecosystem is amazing.