Skip to content

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_typevar. 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 = trueterraform 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.


Reference Patterns — the 4 you must know

var.name                          # reference a variable
local.name                        # reference a local value
resource_type.resource_name.attr  # reference a resource attribute
data.data_type.data_name.attr     # reference a data source attribute
module.module_name.output_name    # reference a module output