Post

Terraform Full Walkthrough

Full Terraform walkthrough — providers, resources, state, variables, modules, workspaces, and real-world AWS patterns from the ground up

Terraform Full Walkthrough

Terraform Full Walkthrough


What is Terraform?

Terraform is an Infrastructure as Code (IaC) tool by HashiCorp. You describe your infrastructure in .tf files and Terraform figures out what to create, update, or destroy to match that description.

Why Terraform over clicking the AWS console:

  • Infrastructure is version-controlled (Git)
  • Reproducible across environments (dev/staging/prod)
  • Team collaboration with code review
  • Destroy everything cleanly with one command
  • Works across AWS, GCP, Azure, Kubernetes, and hundreds of others

The workflow:

1
Write .tf files → terraform init → terraform plan → terraform apply → terraform destroy

Installation

1
2
3
4
5
6
7
8
9
10
11
12
# Linux — via tfenv (version manager, recommended)
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
tfenv install latest
tfenv use latest

# Or direct binary
wget https://releases.hashicorp.com/terraform/1.8.0/terraform_1.8.0_linux_amd64.zip
unzip terraform_1.8.0_linux_amd64.zip && sudo mv terraform /usr/local/bin/

terraform -version

Core Concepts

ConceptWhat it is
ProviderPlugin that talks to an API (AWS, GCP, Kubernetes)
ResourceA single infrastructure object (EC2 instance, S3 bucket)
Data sourceRead existing infrastructure you didn’t create
VariableInput parameter for your config
OutputValues exported after apply (like return values)
StateTerraform’s record of what it has created
ModuleReusable group of resources
WorkspaceIsolated state environments (dev/staging/prod)

Project Structure

1
2
3
4
5
6
7
project/
├── main.tf          # resources
├── variables.tf     # input variable declarations
├── outputs.tf       # output declarations
├── providers.tf     # provider config
├── terraform.tfvars # variable values (don't commit secrets)
└── versions.tf      # required Terraform and provider versions

For larger projects:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
project/
├── modules/
│   ├── networking/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── compute/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       └── terraform.tfvars

Providers

A provider is how Terraform talks to an API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# versions.tf
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"      # allow 5.x but not 6.x
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.0"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
# providers.tf
provider "aws" {
  region  = "eu-west-1"
  profile = "myprofile"    # from ~/.aws/credentials
}

# Multiple regions — use alias
provider "aws" {
  alias  = "us"
  region = "us-east-1"
}
1
2
terraform init          # downloads providers into .terraform/
terraform init -upgrade # upgrade providers to latest matching version

Resources

A resource declares a piece of infrastructure.

1
2
3
resource "<provider>_<type>" "<local_name>" {
  argument = value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# EC2 instance
resource "aws_instance" "web" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id    # reference another resource

  tags = {
    Name        = "web-server"
    Environment = "production"
  }
}

# S3 bucket
resource "aws_s3_bucket" "assets" {
  bucket = "mycompany-assets-bucket"
}

resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id
  versioning_configuration {
    status = "Enabled"
  }
}

Resource references

Reference another resource with <type>.<name>.<attribute>:

1
2
3
4
5
resource "aws_instance" "app" {
  subnet_id         = aws_subnet.private.id
  security_groups   = [aws_security_group.app.id]
  iam_instance_profile = aws_iam_instance_profile.app.name
}

Terraform automatically infers the dependency order — it creates aws_subnet.private before aws_instance.app.


Variables

Declaring variables (variables.tf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
variable "region" {
  type        = string
  description = "AWS region to deploy into"
  default     = "eu-west-1"
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

variable "allowed_cidr_blocks" {
  type    = list(string)
  default = ["10.0.0.0/8"]
}

variable "tags" {
  type = map(string)
  default = {
    Project = "myapp"
    Owner   = "platform-team"
  }
}

variable "db_password" {
  type      = string
  sensitive = true    # won't be shown in plan output or logs
}

Variable types

TypeExample
string"eu-west-1"
number3
booltrue
list(string)["a", "b", "c"]
map(string){ key = "value" }
object({...})Structured type
anyNo type constraint

Setting variable values

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. terraform.tfvars (auto-loaded)
region        = "eu-west-1"
instance_type = "t3.small"
db_password   = "changeme"

# 2. Named .tfvars file
terraform apply -var-file="prod.tfvars"

# 3. CLI flag
terraform apply -var="instance_type=t3.large"

# 4. Environment variables (TF_VAR_ prefix)
export TF_VAR_db_password="supersecret"
terraform apply

Priority (highest to lowest): CLI -var > *.auto.tfvars > terraform.tfvars > environment variables > default

Using variables

1
2
3
4
resource "aws_instance" "web" {
  instance_type = var.instance_type
  tags          = var.tags
}

Outputs

Outputs expose values after apply — useful for passing data between modules or displaying connection info.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# outputs.tf
output "instance_ip" {
  description = "Public IP of the web server"
  value       = aws_instance.web.public_ip
}

output "db_endpoint" {
  description = "RDS endpoint"
  value       = aws_db_instance.main.endpoint
  sensitive   = true    # hide from terminal output
}

output "vpc_id" {
  value = aws_vpc.main.id
}
1
2
3
terraform output                    # show all outputs
terraform output instance_ip        # specific output
terraform output -json              # JSON format (for scripting)

Locals

Locals are computed values within a module — like variables but derived from other values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
locals {
  environment = "production"
  name_prefix = "${var.project}-${local.environment}"

  common_tags = merge(var.tags, {
    Environment = local.environment
    ManagedBy   = "terraform"
  })
}

resource "aws_instance" "web" {
  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web"
  })
}

Data Sources

Data sources read existing infrastructure that Terraform didn’t create.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Get latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

# Get existing VPC by tag
data "aws_vpc" "main" {
  tags = {
    Name = "main-vpc"
  }
}

# Get availability zones for current region
data "aws_availability_zones" "available" {
  state = "available"
}

# Get current AWS account ID
data "aws_caller_identity" "current" {}
1
2
3
4
5
6
7
8
9
# Use data sources
resource "aws_instance" "web" {
  ami  = data.aws_ami.amazon_linux.id
  vpc_security_group_ids = [data.aws_security_group.web.id]
}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

State

Terraform tracks what it has created in a state file (terraform.tfstate). This is the source of truth.

Local state (default — not for teams)

State is stored in terraform.tfstate in your project directory. Never commit this to Git — it contains secrets.

Remote state (required for teams)

1
2
3
4
5
6
7
8
9
10
# versions.tf — store state in S3 with DynamoDB locking
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "prod/networking/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

Create the S3 bucket and DynamoDB table first (chicken-and-egg — do it manually or with a bootstrap module).

State commands

1
2
3
4
5
6
terraform state list                          # list all resources in state
terraform state show aws_instance.web         # show state for one resource
terraform state mv aws_instance.old aws_instance.new   # rename in state
terraform state rm aws_instance.web           # remove from state (doesn't destroy)
terraform import aws_instance.web i-0abc123   # import existing resource into state
terraform refresh                             # sync state with real infrastructure

Sharing state between configs (remote state data source)

1
2
3
4
5
6
7
8
9
10
11
12
data "terraform_remote_state" "networking" {
  backend = "s3"
  config = {
    bucket = "mycompany-terraform-state"
    key    = "prod/networking/terraform.tfstate"
    region = "eu-west-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_id
}

The Core Workflow

1
2
3
4
5
6
7
8
9
10
terraform init          # download providers, set up backend
terraform fmt           # auto-format all .tf files
terraform validate      # check for syntax and config errors
terraform plan          # show what will change (dry run)
terraform plan -out=tfplan   # save plan to file
terraform apply              # apply changes (prompts for confirmation)
terraform apply tfplan        # apply saved plan (no prompt)
terraform apply -auto-approve # skip prompt (CI/CD)
terraform destroy             # destroy all resources
terraform destroy -target=aws_instance.web   # destroy one resource

Reading plan output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0abc123"
      + instance_type = "t3.micro"
    }

  # aws_security_group.old will be destroyed
  - resource "aws_security_group" "old" { ... }

  # aws_instance.app will be updated in-place
  ~ resource "aws_instance" "app" {
      ~ tags = {
          ~ "Name" = "old-name" -> "new-name"
        }
    }

  # aws_db_instance.main must be replaced (destroy + create)
  -/+ resource "aws_db_instance" "main" {
        ~ identifier = "old-id" -> "new-id"   # forces replacement
      }

Symbols: + create, - destroy, ~ update, -/+ replace


Modules

A module is a directory of .tf files. Every Terraform project is technically the root module. You can call child modules to reuse infrastructure patterns.

Calling a module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# main.tf
module "networking" {
  source = "./modules/networking"    # local path

  vpc_cidr   = "10.0.0.0/16"
  region     = var.region
  project    = var.project
}

module "database" {
  source  = "terraform-aws-modules/rds/aws"   # Terraform Registry
  version = "~> 6.0"

  identifier = "myapp-db"
  engine     = "postgres"
  instance_class = "db.t3.medium"
}

# Use outputs from a module
resource "aws_instance" "app" {
  subnet_id = module.networking.private_subnet_id
}

Writing a module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# modules/networking/variables.tf
variable "vpc_cidr" {
  type = string
}

variable "project" {
  type = string
}

# modules/networking/main.tf
resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags = { Name = "${var.project}-vpc" }
}

resource "aws_subnet" "private" {
  vpc_id     = aws_vpc.main.id
  cidr_block = cidrsubnet(var.vpc_cidr, 4, 1)
}

# modules/networking/outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "private_subnet_id" {
  value = aws_subnet.private.id
}
1
terraform init      # re-run after adding modules to download them

Meta-Arguments

count — create N copies

1
2
3
4
5
6
7
8
9
10
resource "aws_subnet" "private" {
  count             = 3
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet("10.0.0.0/16", 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = { Name = "private-subnet-${count.index}" }
}

# Reference: aws_subnet.private[0], aws_subnet.private[1], etc.

for_each — create one per map/set item

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
variable "buckets" {
  default = {
    logs    = "myapp-logs-bucket"
    assets  = "myapp-assets-bucket"
    backups = "myapp-backups-bucket"
  }
}

resource "aws_s3_bucket" "buckets" {
  for_each = var.buckets
  bucket   = each.value

  tags = { Name = each.key }
}

# Reference: aws_s3_bucket.buckets["logs"], etc.

for_each is preferred over count for anything meaningful — items are addressed by key, not index, so removing one doesn’t shift everything.

depends_on — explicit dependency

1
2
3
resource "aws_instance" "app" {
  depends_on = [aws_db_instance.main]    # wait for DB before starting app
}

lifecycle — control create/destroy behavior

1
2
3
4
5
6
7
resource "aws_db_instance" "main" {
  lifecycle {
    prevent_destroy       = true    # terraform destroy will error (safety net)
    create_before_destroy = true    # create new before destroying old (zero downtime)
    ignore_changes        = [password]  # don't track changes to this attribute
  }
}

Expressions and Functions

String interpolation

1
name = "${var.project}-${var.environment}-web"

Conditionals

1
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

Common built-in functions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# String
lower("HELLO")                         # "hello"
upper("hello")                         # "HELLO"
format("%-10s %s", "hello", "world")   # formatted string
replace("hello world", "world", "k8s") # "hello k8s"
split(",", "a,b,c")                    # ["a", "b", "c"]
join("-", ["a", "b", "c"])             # "a-b-c"
trimspace("  hello  ")                 # "hello"

# Collections
length(["a", "b", "c"])                # 3
merge({a=1}, {b=2})                    # {a=1, b=2}
flatten([[1,2],[3,4]])                 # [1,2,3,4]
distinct(["a","b","a"])                # ["a","b"]
toset(["a","b","a"])                   # set (deduped)
keys({a=1, b=2})                       # ["a","b"]
values({a=1, b=2})                     # [1,2]
lookup(var.tags, "env", "default")     # get map key with fallback

# Networking
cidrsubnet("10.0.0.0/16", 8, 2)       # "10.0.2.0/24"
cidrhost("10.0.0.0/24", 5)            # "10.0.0.5"

# Encoding
base64encode("hello")
jsonencode({key = "value"})
yamlencode({key = "value"})

# Filesystem (at plan time)
file("./scripts/init.sh")
templatefile("./config.tpl", { port = 8080 })

for expressions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# List comprehension
output "subnet_ids" {
  value = [for s in aws_subnet.private : s.id]
}

# Map comprehension
output "instance_ips" {
  value = { for k, v in aws_instance.servers : k => v.private_ip }
}

# Filter with if
output "prod_instances" {
  value = [for i in aws_instance.all : i.id if i.tags["env"] == "prod"]
}

Workspaces

Workspaces let you manage multiple state files with the same config — one per environment.

1
2
3
4
5
terraform workspace list               # list workspaces (default exists)
terraform workspace new staging        # create staging workspace
terraform workspace select staging     # switch to staging
terraform workspace show               # current workspace
terraform workspace delete staging     # delete workspace
1
2
3
4
5
6
7
8
# Use workspace name in config
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"

  tags = {
    Environment = terraform.workspace
  }
}

Workspaces vs directories: Workspaces share config but have separate state. Separate directories (e.g., environments/prod/) give full isolation and are usually better for production.


Real-World AWS Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# VPC with public and private subnets
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "${var.project}-vpc" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project}-igw" }
}

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
  tags = { Name = "${var.project}-public-${count.index}" }
}

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]
  tags = { Name = "${var.project}-private-${count.index}" }
}

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
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "web" {
  name   = "${var.project}-web-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    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"]
  }
}

.gitignore for Terraform

# State files — contain secrets
*.tfstate
*.tfstate.backup
.terraform.tfstate.lock.info

# Downloaded providers
.terraform/

# Variable files with secrets
*.tfvars
!example.tfvars    # keep example files

# Saved plans
*.tfplan

# Crash logs
crash.log

Quick Reference

CommandWhat it does
terraform initDownload providers, set up backend
terraform fmtAuto-format .tf files
terraform validateCheck syntax and logic
terraform planShow what will change
terraform applyCreate/update infrastructure
terraform destroyDestroy all managed infrastructure
terraform state listList resources in state
terraform state showShow state for a resource
terraform importImport existing resource into state
terraform outputShow output values
terraform workspace newCreate a new workspace
terraform workspace selectSwitch workspace
terraform taintMark resource for recreation on next apply
terraform graphGenerate dependency graph (dot format)
This post is licensed under CC BY 4.0 by the author.