Cloud  /  Terraform

IaC Terraform 50 guides · updated 2026

Infrastructure as code done right — providers, state, reusable modules, and the workflow patterns that keep multi-cloud deployments sane in 2026.

Declarative Syntax in Terraform

Terraform uses a declarative approach: you describe the end state you want, and Terraform figures out the steps to get there. This is fundamentally different from writing imperative scripts that specify every action in order.


Declarative vs. Imperative: The Core Difference

Imperative (Bash script):

#!/bin/bash
# You must specify every step in the right order
aws ec2 create-security-group --group-name my-sg --description "Web tier"
aws ec2 authorize-security-group-ingress --group-name my-sg --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 run-instances --image-id ami-0abcdef1234567890 --instance-type t3.micro --security-groups my-sg
# If the security group already exists — your script fails
# If the instance already exists — you create a duplicate

Declarative (Terraform HCL):

# You describe what should exist — Terraform handles the "how"
resource "aws_security_group" "web" {
name = "my-sg"
description = "Web tier"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
}
# Run this 100 times — same result. Terraform knows what already exists.

Why Declarative Wins for Infrastructure

PropertyImperative ScriptsTerraform Declarative
IdempotencyManual, error-proneBuilt-in
State awarenessNoneTracks all managed resources
Dependency resolutionManual orderingAutomatic DAG
Drift detectionNot possibleterraform plan shows drift
RollbackRequires reverse scriptRe-apply previous state

Idempotency is the key property: running terraform apply on a configuration that already matches reality makes zero changes. This makes automation safe — you can run it in CI/CD without fear of accidental duplication.


HCL Syntax Deep Dive

HCL (HashiCorp Configuration Language) is designed to be both human-readable and machine-parseable.

Block Types

# Syntax: <block_type> "<type>" "<name>" { ... }
terraform { # Global settings — no type/name
required_version = ">= 1.6"
}
provider "aws" { # Provider block — type only, no name
region = "us-east-1"
}
resource "aws_vpc" "main" { # Resource block — type + name
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
}
variable "environment" { # Variable block
type = string
default = "dev"
}
output "vpc_id" { # Output block
value = aws_vpc.main.id
}
locals { # Locals block — computed values
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}

Expressions and References

# Reference another resource's attribute
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # Reference by resource.type.name.attribute
cidr_block = "10.0.1.0/24"
}
# Interpolation in strings
resource "aws_s3_bucket" "logs" {
bucket = "logs-${var.environment}-${random_id.suffix.hex}"
}
# Conditional expression
resource "aws_instance" "app" {
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
}
# For expressions
locals {
subnet_ids = [for s in aws_subnet.public : s.id]
# For expression with condition
prod_instance_ids = [
for inst in aws_instance.app : inst.id
if inst.tags["Environment"] == "production"
]
}

Terraform’s Dependency Graph

One of the most powerful aspects of declarative syntax: Terraform automatically builds a directed acyclic graph (DAG) of resource dependencies and creates them in the correct order — with parallelism where possible.

# Terraform determines this order automatically:
# 1. aws_vpc.main (no dependencies)
# 2. aws_subnet.public AND aws_security_group.web (both depend on VPC — run in parallel)
# 3. aws_instance.app (depends on both subnet and SG)
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # Dependency on VPC
cidr_block = "10.0.1.0/24"
}
resource "aws_security_group" "web" {
vpc_id = aws_vpc.main.id # Dependency on VPC
# ...
}
resource "aws_instance" "app" {
subnet_id = aws_subnet.public.id # Dependency on subnet
vpc_security_group_ids = [aws_security_group.web.id] # Dependency on SG
# ...
}

You don’t write the ordering logic — you write the relationships, and Terraform builds the execution plan.


Terraform Plan: Seeing the Declarative Diff

The plan command shows what Terraform will do to move from current state to desired state:

Terraform will perform the following actions:
# aws_instance.app will be created
+ resource "aws_instance" "app" {
+ ami = "ami-0abcdef1234567890"
+ instance_type = "t3.micro"
+ id = (known after apply)
+ public_ip = (known after apply)
}
# aws_instance.old will be destroyed
- resource "aws_instance" "old" {
- id = "i-1234567890abcdef0"
- instance_type = "t3.small"
}
Plan: 1 to add, 0 to change, 1 to destroy.

The + / - / ~ symbols show add, destroy, and update-in-place. The declarative model makes this diff straightforward to understand and review in pull requests.


HCL File Organization

Terraform loads all .tf files in the current directory. Conventional organization:

project/
├── main.tf # Core resources
├── variables.tf # All variable declarations
├── outputs.tf # All outputs
├── versions.tf # terraform{} and required_providers blocks
├── locals.tf # Local computed values
└── terraform.tfvars # Variable values (don't commit secrets!)

This is a convention, not a rule — Terraform merges all .tf files before processing.