Two block types get you a working configuration: a provider that knows how to talk to an API, and a resource that describes one thing you want to exist. Almost everything else in the language is a way to avoid repeating yourself across those two.
Topic 1: Providers
provider "aws" {
region = "us-east-1"
profile = "infra-admin"
}
terraform init reads your configuration, works out which providers it needs, downloads the binaries into .terraform/, and prepares the backend. It creates no sample files and provisions nothing.
Pin the version. Always.
Without a constraint, init fetches the newest version available — including a major release with breaking changes, on a day you were changing something unrelated.
terraform {
required_version = ">= 1.1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
| Constraint | Means |
|---|---|
= 1.2.0 | Exactly this version |
!= 1.2.0 | Anything except this version |
>=, <=, >, < | Ordinary comparison |
~> 1.2.0 | >= 1.2.0, < 1.3.0 — patch updates only |
~> 1.2 | >= 1.2.0, < 2.0.0 — minor updates allowed |
The ~> operator is the one you will use most. Note carefully that it means different things with two components versus three.
Older material puts version = "..." directly inside the provider block. That form is deprecated; declare providers in required_providers.
Multiple instances of one provider:
To target two regions or accounts, define the provider twice. Every instance after the first needs an alias.
provider "aws" {
region = "eu-west-1" # default instance
}
provider "aws" {
alias = "london"
region = "eu-west-2"
}
resource "aws_vpc" "secondary" {
cidr_block = "10.1.0.0/16"
provider = aws.london
}
You may write provider = aws to select the default explicitly, but nobody does — it is extra code that changes nothing.
Topic 2: Resources
resource "<PROVIDER>_<TYPE>" "<LOCAL_NAME>" {
key = value
nested_block {
...
}
}
The type prefix names the provider — aws_, google_, azurerm_, kubernetes_, postgresql_ — so you can tell at a glance which system a block touches. The local name identifies the resource inside your project only; it never reaches the API.
Arguments take strings (quoted), numbers and booleans (unquoted), lists (square brackets), and nested blocks:
resource "aws_security_group" "web" {
name = "allow-tls"
ingress {
protocol = "tcp"
from_port = 443
to_port = 443
cidr_blocks = ["10.0.0.0/16"]
}
}
Topic 3: References Build the Graph
A created resource exports attributes. Reference them as <type>.<local_name>.<attribute>:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_security_group" "web" {
vpc_id = aws_vpc.main.id # <- dependency created here
name = "web-sg"
}
You did not declare an ordering. By referencing aws_vpc.main.id you told Terraform the group needs the network’s ID, which cannot be known until the network exists. Terraform builds a directed acyclic graph from these references and runs everything it can in parallel.
Since Terraform 0.12 the ${...} wrapper is unnecessary for a bare expression. It is still needed inside a string containing other text:
bucket = local.bucket_name # no wrapper
name = "${var.project}-${var.environment}" # wrapper required
When the graph needs help:
depends_on declares a dependency that no attribute reference expresses — when a resource relies on another’s behaviour rather than its data.
resource "aws_instance" "app" {
ami = "ami-EXAMPLE"
instance_type = "t3.micro"
depends_on = [aws_iam_role_policy.app]
}
Use it sparingly. Each one serialises work that could have run in parallel, and overuse measurably slows large applies.
Topic 4: The Workflow
terraform init # download providers and modules, prepare the backend
terraform validate # syntax and internal consistency — no API calls, no state
terraform fmt # rewrite to canonical style
terraform plan # refresh, diff, print proposed changes
terraform apply # execute, then write state
terraform destroy # compute reverse dependency order and delete
apply runs a plan first and pauses for confirmation. That pause is the safety net.
Try the loop that makes the model click:
applyto create a bucket.- Delete the bucket in the console.
applyagain — Terraform proposes creating it, because it compared its record against reality.applyonce more with nothing changed —0 added, 0 changed, 0 destroyed.
Step four is idempotency; step three is the whole point of a tool that tracks what it manages.
Topic 5: Lifecycle Meta-Arguments
Three arguments change how Terraform handles change itself. You will use all three in production.
resource "aws_instance" "app" {
ami = "ami-EXAMPLE"
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true # stand the new one up first
prevent_destroy = true # refuse to delete this, ever
ignore_changes = [tags] # stop fighting whatever else edits these
}
}
create_before_destroyis the primary lever for zero-downtime replacement. Without it, replacement means a gap where nothing exists.prevent_destroyis the guard rail for databases and anything holding state. It turns an accidental destroy into an error.ignore_changesstops Terraform reverting an attribute another system legitimately owns — an autoscaler adjusting a count, a platform adding its own tags. It also keeps state from bloating with churn.
Try it yourself: Add a second resource referencing the first, run terraform graph and read the output. You will see the edge you created implicitly — no depends_on required.
Common mistake: Hard-coding a value you could reference. Deriving an identifier by string concatenation looks equivalent and is often correct, but it creates no graph edge, so Terraform may build things in the wrong order and will not notice when the target changes or disappears.