Terraform describes infrastructure as code: you declare what should exist, and Terraform works out the API calls to make it so. This post covers everything needed to write a small but well-structured AWS project. The next post covers state, testing and pipelines.
How Terraform works
flowchart LR
C[Configuration<br/>.tf files] --> P[terraform plan]
S[State<br/>what Terraform manages] --> P
R[Real infrastructure<br/>via provider APIs] --> P
P --> D[Diff: create, update,<br/>replace, destroy]
D --> A[terraform apply]
A --> R
A --> S
Three inputs feed every plan: your configuration, the state file (Terraform’s record of what it created), and the real resources read through the provider. The plan is the difference between them. Nothing changes until you apply it.
| Command | What it does |
|---|---|
terraform init |
Downloads providers and modules, configures the backend |
terraform fmt |
Formats files to the standard style |
terraform validate |
Checks syntax and references |
terraform plan |
Shows what would change |
terraform apply |
Makes the changes |
terraform destroy |
Removes everything in the state |
A sensible project layout
infra/
modules/
network/
main.tf
variables.tf
outputs.tf
envs/
dev/
main.tf
backend.tf
dev.tfvars
prod/
main.tf
backend.tf
prod.tfvars
Reusable building blocks live in modules/. Each environment is a small root module that calls them with its own values and has its own state, so a change to dev can never touch prod.
Providers and versions
# envs/dev/main.tf
terraform {
required_version = ">= 1.10, < 2.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Project = "shop"
Env = var.env
ManagedBy = "terraform"
}
}
}
~> 6.0allows any 6.x release but not 7.0, which may contain breaking changes.default_tagstags every resource the provider creates, which makes cost reports and clean-up far easier.terraform initwrites.terraform.lock.hclwith exact provider versions and checksums. Commit it, so everyone and every pipeline uses the same build.
Variables, locals and outputs
# variables.tf
variable "env" {
type = string
description = "Environment name"
validation {
condition = contains(["dev", "stage", "prod"], var.env)
error_message = "env must be dev, stage or prod."
}
}
variable "region" {
type = string
default = "ap-south-1"
}
variable "vpc_cidr" {
type = string
}
variable "db_password" {
type = string
sensitive = true
}
# locals: computed values, defined once
locals {
name_prefix = "shop-${var.env}"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
}
# outputs.tf
output "vpc_id" {
value = module.network.vpc_id
description = "ID of the environment VPC"
}
Values come from a .tfvars file per environment:
# dev.tfvars
env = "dev"
vpc_cidr = "10.41.0.0/16"
terraform plan -var-file=dev.tfvars
Secrets such as db_password should not be in .tfvars in Git. Pass them as environment variables (TF_VAR_db_password) from your CI secret store, or read them from Secrets Manager with a data source. Marking a variable sensitive hides it in plan output, but it can still be stored in state, which is why state must be protected.
Data sources
A data source reads something that already exists instead of creating it:
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
Repeating resources with for_each
Use for_each with a map. Each instance is keyed by name, so removing one entry removes only that resource. With count, removing an item from the middle of a list shifts every index and causes needless replacements.
# modules/network/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
locals {
private_subnets = {
for i, az in var.azs : az => cidrsubnet(var.cidr, 4, i)
}
public_subnets = {
for i, az in var.azs : az => cidrsubnet(var.cidr, 4, i + 8)
}
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = each.key
tags = { Name = "${var.name}-private-${each.key}", Tier = "private" }
}
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = { Name = "${var.name}-public-${each.key}", Tier = "public" }
}
# modules/network/variables.tf
variable "name" { type = string }
variable "cidr" { type = string }
variable "azs" { type = list(string) }
# modules/network/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }
output "public_subnet_ids" { value = [for s in aws_subnet.public : s.id] }
Calling the module
# envs/dev/main.tf (continued)
module "network" {
source = "../../modules/network"
name = local.name_prefix
cidr = var.vpc_cidr
azs = local.azs
}
resource "aws_security_group" "app" {
name = "${local.name_prefix}-app"
vpc_id = module.network.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Modules from the Terraform Registry work the same way with a version pin:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.0"
# ...
}
Good module design: few inputs with sensible defaults, outputs for everything a caller may need, no provider block inside the module, and one clear purpose.
Protecting important resources
resource "aws_db_instance" "main" {
# ...
deletion_protection = true
lifecycle {
prevent_destroy = true # plan fails if this would be destroyed
ignore_changes = [engine_version] # managed by AWS minor upgrades
}
}
When you rename a resource or move it into a module, tell Terraform instead of letting it destroy and recreate:
moved {
from = aws_s3_bucket.assets
to = module.storage.aws_s3_bucket.assets
}
Reading a plan
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ ami = "ami-0aaa" -> "ami-0bbb" # forces replacement
| Symbol | Meaning |
|---|---|
+ |
create |
~ |
update in place |
-/+ |
destroy then create (replacement) |
- |
destroy |
Read every -/+ and - line before applying. “Forces replacement” on a database or load balancer means downtime or data loss.
A note on licensing
Terraform has been under the Business Source License since August 2023, and HashiCorp is now part of IBM. For using Terraform to manage your own infrastructure this changes nothing. OpenTofu, the Linux Foundation fork, accepts the same configuration for most projects and is a valid choice if you prefer an open-source licence.
Summary
init,plan,apply: read the plan every time.- Pin Terraform and provider versions and commit the lock file.
- One root module and one state per environment; reusable modules in
modules/. for_eachover maps,sensitivefor secrets,prevent_destroyfor data.movedblocks for refactors.
Next: remote state with locking, importing existing resources, testing, and a safe CI pipeline.