Terraform Examples — Line by Line¶
1. Provider Configuration¶
Tells Terraform which cloud/API to talk to and how to authenticate. Without this, Terraform doesn't know where to create anything.
terraform {
required_providers {
aws = {
source = "hashicorp/aws" # where to download the provider from (registry.terraform.io)
version = "~> 5.0" # use any 5.x version, don't jump to 6.x automatically
}
}
required_version = ">= 1.0" # minimum Terraform CLI version this config needs
}
provider "aws" {
region = "us-east-1" # all resources go here unless overridden
}
Line by line:
- terraform {} block — meta-configuration for Terraform itself, not for AWS
- required_providers — locks down which providers and versions this config needs
- source = "hashicorp/aws" — the official AWS provider maintained by HashiCorp
- version = "~> 5.0" — the ~> operator means "compatible with" — allows 5.1, 5.2 but not 6.0
- required_version — protects against someone running an old Terraform CLI that might behave differently
- provider "aws" — configures the AWS provider itself, region is the minimum you need
2. Variables¶
Variables make your config reusable across environments. You define them once, pass different values per environment.
variable "instance_type" {
type = string # enforces that only a string is accepted
default = "t2.micro" # used if no value is passed in
description = "EC2 instance type" # documents what this variable is for
}
variable "environment" {
type = string
default = "dev"
}
variable "allowed_ports" {
type = list(number) # a list of numbers, not a single value
default = [80, 443]
}
variable "tags" {
type = map(string) # key-value pairs, both strings
default = {
Owner = "mark"
Project = "demo"
}
}
Line by line:
- variable "name" {} — declares an input variable with that name
- type — optional but good practice, Terraform will enforce it
- default — if you don't pass a value, this is used. No default = required input
- description — shown in terraform plan output and docs, always fill this in
- list(number) — ordered list, access with var.allowed_ports[0]
- map(string) — key-value store, access with var.tags["Owner"]
3. Resource — EC2 Instance¶
A resource is the thing you're creating. This is the core of Terraform. The resource block tells Terraform what to build and how to configure it.
resource "aws_instance" "web" { # "aws_instance" = resource type, "web" = local name
ami = "ami-0c55b159cbfafe1f0" # which OS image to use (region-specific)
instance_type = var.instance_type # references the variable defined above
tags = {
Name = "web-server"
Environment = var.environment # reusing the environment variable
}
}
Line by line:
- resource "aws_instance" "web" — type is aws_instance, local name is web. The full address is aws_instance.web
- ami — the Amazon Machine Image ID. This is the OS. Different per region.
- instance_type = var.instance_type — var. prefix is how you reference a variable
- tags — metadata attached to the AWS resource. Not functional, but important for cost tracking and searching
- var.environment — reusing the variable, so if environment = "prod", the tag becomes "prod"
4. Output Values¶
Outputs expose values after apply — like the IP of a server you just created. Also used to pass values between modules.
output "instance_id" {
value = aws_instance.web.id # reference the id attribute of the resource
description = "The ID of the EC2 instance"
}
output "instance_public_ip" {
value = aws_instance.web.public_ip # attribute exposed by the aws_instance resource
sensitive = false # set to true if this is a secret (hides from CLI output)
}
Line by line:
- output "name" — declares an output with that name, visible after terraform apply
- aws_instance.web.id — reference pattern: <resource_type>.<local_name>.<attribute>
- .id, .public_ip — these are attributes the AWS provider exposes after creation. You can find the full list in the provider docs for each resource type.
- sensitive = true — hides the value from terminal output, still stored in state
5. Data Sources¶
A data source reads existing infrastructure without creating anything. Use when something already exists and you need its ID or properties.
# Look up the latest Amazon Linux 2 AMI instead of hardcoding an ID
data "aws_ami" "amazon_linux" {
most_recent = true # if multiple match, pick the newest
owners = ["amazon"] # only look at AMIs owned by Amazon
filter {
name = "name" # filter by the AMI name field
values = ["amzn2-ami-hvm-*-x86_64-gp2"] # wildcard pattern
}
}
# Now use it in a resource — no hardcoded AMI ID
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id # reference the data source result
instance_type = "t2.micro"
}
Line by line:
- data "aws_ami" "amazon_linux" — data source type is aws_ami, local name is amazon_linux
- most_recent = true — filters down to one result when multiple match
- owners = ["amazon"] — scopes the search to Amazon's public AMIs only
- filter {} — narrows results further. name is the field to filter on, values is what to match
- data.aws_ami.amazon_linux.id — reference pattern: data.<type>.<name>.<attribute>
6. Local Values¶
Locals are like variables but computed inside the config, not passed in from outside. Use to avoid repeating the same expression multiple times.
locals {
common_tags = { # define once, reuse everywhere
Environment = var.environment
Owner = "mark"
ManagedBy = "terraform"
}
instance_name = "${var.environment}-web-server" # string interpolation
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = local.common_tags # reference with local.<name>
}
resource "aws_s3_bucket" "data" {
bucket = local.instance_name # reusing the same computed string
tags = local.common_tags # same tags, no repetition
}
Line by line:
- locals {} — a block of computed values, not inputs
- common_tags — a map built from variables. Apply this to every resource for consistent tagging.
- "${var.environment}-web-server" — string interpolation. ${} injects a value into a string.
- local.common_tags — reference pattern: local.<name>
- The point: if environment changes, every tag and name updates automatically
7. Remote Backend (S3)¶
By default state is stored locally in terraform.tfstate. Remote backend stores state in S3 so teams can share it safely.
terraform {
backend "s3" {
bucket = "my-company-tf-state" # S3 bucket that must already exist
key = "prod/terraform.tfstate" # path inside the bucket
region = "us-east-1" # region of the S3 bucket
dynamodb_table = "terraform-state-lock" # table that handles locking
encrypt = true # encrypt state at rest in S3
}
}
Line by line:
- backend "s3" — use S3 as the state storage backend
- bucket — the S3 bucket must exist before you run terraform init. Terraform won't create it.
- key — the file path inside the bucket. Use a path that identifies the environment and project.
- dynamodb_table — when someone runs apply, Terraform writes a lock to this table. If another person tries to apply at the same time, they get blocked. Prevents state corruption.
- encrypt = true — state can contain secrets (passwords, keys). Always encrypt.
- After adding this, run terraform init again — it will migrate local state to S3.
8. Modules¶
A module is a reusable folder of .tf files. You pass in variables, it creates resources, it returns outputs. Think of it as a function for infrastructure.
# Calling a module from the public registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws" # registry.terraform.io path
version = "5.0.0" # always pin module versions
name = "my-vpc"
cidr = "10.0.0.0/16" # these are the module's input variables
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"]
}
# Calling a local module (a folder in your project)
module "web_server" {
source = "./modules/web-server" # relative path to the module folder
instance_type = "t2.micro" # input variable defined in the module
environment = var.environment
}
# Referencing a module output
output "vpc_id" {
value = module.vpc.vpc_id # module.<name>.<output_name>
}
Line by line:
- module "vpc" — calls a module, local name is vpc
- source — where the module lives. Can be a registry path, a git URL, or a local folder path
- version — pin this always. Module updates can be breaking.
- The key=value pairs after source/version — these are the module's input variables. What you pass in depends on what the module declares as variables.
- module.vpc.vpc_id — reference pattern: module.<name>.<output>. The output must be declared in the module.
9. Lifecycle Rules¶
Control how Terraform handles create, update, and destroy for a resource. Critical for databases and resources where downtime matters.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
lifecycle {
create_before_destroy = true # spin up the replacement BEFORE killing the old one
# default is destroy first, then create — causes downtime
prevent_destroy = false # set to true on databases to block accidental destruction
ignore_changes = [ami] # if the AMI changes outside Terraform, don't update
# useful when an external process updates a resource
}
}
Line by line:
- lifecycle {} — meta-block, sits inside a resource block
- create_before_destroy = true — zero-downtime replacement. Terraform creates new resource, then deletes old. Default is delete first.
- prevent_destroy = true — terraform destroy will fail with an error. Put this on anything you can't afford to lose.
- ignore_changes = [ami] — if this field changes (in AWS or in code), Terraform won't try to update the resource. Useful for fields managed by other systems.
10. Count and For Each¶
Create multiple resources from a single block. count = simple number. for_each = iterate over a map or set.
# count — creates 3 identical instances, accessed by index
resource "aws_instance" "web" {
count = 3 # creates web[0], web[1], web[2]
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "web-${count.index}" # count.index = 0, 1, 2
}
}
# for_each — creates one instance per map entry, accessed by key
resource "aws_instance" "servers" {
for_each = { # map: key = name, value = instance type
web = "t2.micro"
api = "t2.small"
db = "t2.medium"
}
ami = "ami-0c55b159cbfafe1f0"
instance_type = each.value # each.value = "t2.micro", "t2.small", etc.
tags = {
Name = each.key # each.key = "web", "api", "db"
}
}
# Reference a specific for_each resource
output "web_ip" {
value = aws_instance.servers["web"].public_ip # access by key, not index
}
Line by line:
- count = 3 — creates 3 copies. Addressed as aws_instance.web[0], [1], [2]
- count.index — the current iteration number, starts at 0
- for_each = {} — iterate over a map. Better than count because resources are addressed by name, not index. If you remove index 1 from a count=3, Terraform destroys and recreates [1] and [2]. With for_each, removing "api" only touches "api".
- each.key — the map key for the current iteration
- each.value — the map value for the current iteration
- ["web"] — how you reference a specific for_each resource
11. terraform.tfvars¶
A file Terraform auto-loads to populate variable values. Keeps values separate from config. Don't commit secrets to git.
# terraform.tfvars — auto-loaded by Terraform
instance_type = "t2.large"
environment = "production"
allowed_ports = [80, 443, 8080]
tags = {
Owner = "mark"
Project = "myapp"
}
# prod.tfvars — NOT auto-loaded, pass with -var-file="prod.tfvars"
instance_type = "t2.xlarge"
environment = "prod"
Line by line:
- terraform.tfvars — this exact filename is auto-loaded. No flag needed.
- *.auto.tfvars — also auto-loaded. Any file ending in .auto.tfvars.
- prod.tfvars — custom name, must be passed explicitly: terraform apply -var-file="prod.tfvars"
- Values here override variable defaults
- Never put passwords or API keys in .tfvars files committed to git. Use environment variables or a secrets manager instead.