Clicking through cloud consoles creates infrastructure you can't reproduce, track, or version control. Terraform fixes this. Here's how to start.
Core Concepts
# main.tf
# Provider: tells Terraform where to deploy
provider "aws" {
region = "ca-central-1"
}
# Resource: a piece of infrastructure
resource "aws_instance" "web" {
ami = "ami-0c9bef50e5e424e5d"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
# Data source: read existing infrastructure
data "aws_vpc" "default" {
default = true
}
Variables and Outputs
# variables.tf
variable "environment" {
type = string
description = "Deployment environment"
default = "production"
}
variable "instance_type" {
type = string
default = "t3.micro"
}
# outputs.tf
output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}
output "instance_id" {
value = aws_instance.web.id
}
# Pass variables at apply time
terraform apply -var="environment=staging" -var="instance_type=t3.small"
# Or use .tfvars files
terraform apply -var-file="staging.tfvars"
A Real-World Example: VPC + EC2 + RDS
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${var.environment}-vpc" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
# RDS PostgreSQL
resource "aws_db_instance" "postgres" {
identifier = "${var.environment}-postgres"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "myapp"
username = "myapp"
password = var.db_password # Store in SSM, not in code
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
skip_final_snapshot = false
final_snapshot_identifier = "${var.environment}-final-snapshot"
tags = { Environment = var.environment }
}
State Management
Terraform stores state in terraform.tfstate. Never commit this to git -- it contains secrets.
Use remote state with locking:
# backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/terraform.tfstate"
region = "ca-central-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # Prevents concurrent applies
}
}
# Create the S3 bucket and DynamoDB table first (chicken-and-egg):
aws s3 mb s3://mycompany-terraform-state --region ca-central-1
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
Modules
Modules are reusable units of Terraform configuration:
# modules/rds/main.tf
variable "environment" {}
variable "instance_class" { default = "db.t3.micro" }
resource "aws_db_instance" "this" {
identifier = "${var.environment}-postgres"
instance_class = var.instance_class
# ...
}
output "endpoint" {
value = aws_db_instance.this.endpoint
}
# In your main configuration:
module "database" {
source = "./modules/rds"
environment = "production"
instance_class = "db.t3.medium"
}
# Access module outputs
resource "aws_ssm_parameter" "db_url" {
name = "/production/db_url"
value = "postgresql://${module.database.endpoint}/myapp"
}
Workflow
# Initialize: download providers and modules
terraform init
# See what will change
terraform plan
# Apply changes
terraform apply
# Destroy everything (careful!)
terraform destroy
# Format code
terraform fmt
# Validate syntax
terraform validate
# Import existing resources into state
terraform import aws_instance.web i-1234567890abcdef0
Secrets Management
Never put secrets in Terraform files. Reference them from AWS SSM or Secrets Manager:
data "aws_ssm_parameter" "db_password" {
name = "/production/db_password"
with_decryption = true
}
resource "aws_db_instance" "postgres" {
password = data.aws_ssm_parameter.db_password.value
# ...
}
Terraform solves infrastructure drift (when someone manually changes something in the console and no one knows). After adopting it, your infrastructure becomes reproducible, reviewable, and auditable.