Terraform Course
Resources
Resources are the core of every Terraform configuration. Every server, database, and security rule you write is a resource block. This lesson goes inside them — the arguments you have not seen yet, the behaviours that surprise every beginner, and the meta-arguments that change how Terraform manages a resource entirely.
This lesson covers
Resource block anatomy → The four actions Terraform takes on a resource → Meta-arguments: depends_on, count, for_each, lifecycle → Timeouts → Reading provider documentation
What a Resource Block Actually Is
A resource block is a declaration. It tells Terraform: this thing should exist, with these properties. Terraform reads that declaration, checks whether the thing already exists in the state file, and decides what to do — create it, update it, replace it, or leave it alone.
Every resource block has the same structure. The type identifies what kind of thing to create. The name is how you reference it elsewhere in your configuration. The body contains arguments — some required, most optional.
The type — aws_instance — is always provider_resourcetype. The local name — web — exists only inside Terraform. It has no effect on what gets created in AWS. It is how you reference this resource from other blocks: aws_instance.web.id, aws_instance.web.public_ip.
The Four Things Terraform Can Do to a Resource
Every resource in a plan output gets one of four symbols. Understanding what each one means — and why Terraform chose it — is how you read plans with confidence.
| Symbol | Action | When it happens | Risk |
|---|---|---|---|
| + | Create | In config but not in state | Low |
| ~ | Update in-place | Argument changed that AWS can modify without recreating | Low–Medium |
| -/+ | Replace | Argument changed that forces destroy + create | High — data loss risk |
| - | Destroy | Resource removed from config or destroy command | High |
The -/+ symbol is the one that catches beginners. You change the AMI ID on an EC2 instance and Terraform plans to destroy the existing instance and create a new one. All data on the instance is gone. This is not a bug — the AMI is baked into the instance at launch time and AWS cannot change it on a running instance. The plan always marks the triggering argument with # forces replacement. Find that line before typing yes.
Setting Up the Project
Create a fresh directory. Everything in this lesson runs against real AWS — a VPC, subnet, security group, and EC2 instances. Run these commands first:
mkdir terraform-lesson-9
cd terraform-lesson-9
touch versions.tf variables.tf main.tf outputs.tf
Add this to versions.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
Add this to variables.tf:
variable "region" {
type = string
default = "us-east-1"
}
variable "environment" {
type = string
default = "dev"
}
variable "instances" {
description = "Map of instance role to instance type"
type = map(string)
default = {
primary = "t2.micro"
secondary = "t2.micro"
}
}
Now run terraform init. Once the provider downloads and the lock file is created, continue below.
depends_on — Explicit Dependencies
Terraform builds its dependency graph from attribute references. When resource A references an attribute of resource B, Terraform knows B must exist first. But sometimes a dependency exists that Terraform cannot see through references alone — for example, an internet gateway that must be attached to the VPC before an EC2 instance is useful, even though the instance block does not reference the gateway directly.
We are writing a networking setup — VPC, internet gateway, subnet, security group, and EC2 instance. The instance uses depends_on to explicitly declare it must wait for the internet gateway.
New terms:
- aws_vpc — creates an Amazon Virtual Private Cloud. An isolated network in AWS. Every EC2 instance and database lives inside a VPC. The
cidr_blockdefines the IP address range —10.0.0.0/16gives 65,536 addresses.enable_dns_hostnames = trueallows AWS to assign public DNS names to instances with public IPs. - aws_internet_gateway — attaches to a VPC and enables internet access. Without one, nothing in the VPC can reach the internet and nothing from the internet can reach resources inside the VPC.
- aws_subnet — a subdivision of a VPC tied to one availability zone.
map_public_ip_on_launch = truegives every instance launched here a public IP automatically. - aws_security_group — a virtual firewall.
ingressrules control inbound traffic,egresscontrols outbound. Protocol"-1"on egress means all protocols — the standard for outbound rules. - depends_on — a meta-argument on any resource. Takes a list of resources that must fully complete before this resource begins creation. Use only when Terraform cannot infer the dependency from attribute references.
Add the following to main.tf:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "igw-${var.environment}"
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.region}a"
map_public_ip_on_launch = true
tags = {
Name = "subnet-public-${var.environment}"
ManagedBy = "Terraform"
}
}
resource "aws_security_group" "web" {
name = "web-sg-${var.environment}"
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 = 22
to_port = 22
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"]
}
tags = {
Name = "web-sg-${var.environment}"
ManagedBy = "Terraform"
}
}
resource "aws_instance" "web" {
for_each = var.instances
ami = "ami-0c55b159cbfafe1f0"
instance_type = each.value
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
depends_on = [aws_internet_gateway.main]
tags = {
Name = "web-${each.key}-${var.environment}"
Role = each.key
Environment = var.environment
ManagedBy = "Terraform"
}
}
Add this to outputs.tf:
output "instance_ids" {
description = "Instance IDs keyed by role"
value = { for k, v in aws_instance.web : k => v.id }
}
output "instance_public_ips" {
description = "Public IPs keyed by role"
value = { for k, v in aws_instance.web : k => v.public_ip }
}
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
$ terraform plan
# aws_vpc.main will be created
+ resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
+ enable_dns_hostnames = true
+ id = (known after apply)
}
# aws_internet_gateway.main will be created
+ resource "aws_internet_gateway" "main" {
+ vpc_id = (known after apply)
}
# aws_subnet.public will be created
+ resource "aws_subnet" "public" {
+ availability_zone = "us-east-1a"
+ cidr_block = "10.0.1.0/24"
+ map_public_ip_on_launch = true
+ vpc_id = (known after apply)
}
# aws_security_group.web will be created
+ resource "aws_security_group" "web" {
+ name = "web-sg-dev"
+ vpc_id = (known after apply)
}
# aws_instance.web["primary"] will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t2.micro"
+ subnet_id = (known after apply)
}
# aws_instance.web["secondary"] will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t2.micro"
+ subnet_id = (known after apply)
}
Plan: 6 to add, 0 to change, 0 to destroy.What just happened?
- Terraform resolved creation order entirely from references. The VPC is created first — everything else references its ID. The internet gateway and subnet come after the VPC. The security group after the VPC. The EC2 instances last — they need the subnet, the security group, and the internet gateway.
- depends_on on the instance forces a hard wait for the internet gateway. The instance references
aws_subnet.public.idandaws_security_group.web.iddirectly — those create implicit dependencies. But the internet gateway has no attribute the instance references. Withoutdepends_on, Terraform might create the instance before the gateway is attached and routing is configured. Thedepends_onmakes the dependency explicit. - for_each created two named instances from one block. The plan shows
aws_instance.web["primary"]andaws_instance.web["secondary"]— each identified by the map key, not an index number. If you removesecondaryfrom the map later, only that instance is destroyed. The primary instance is completely untouched. - each.value drove a different instance type per role. The
instancesvariable is a map.each.valueinside the resource block is the map value for the current key — in this case the instance type string. Change the secondary value tot3.smalland only the secondary instance is modified on the next apply.
Now run the apply for real:
terraform apply
$ terraform apply
Plan: 6 to add, 0 to change, 0 to destroy.
Enter a value: yes
aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s [id=vpc-0abc123]
aws_internet_gateway.main: Creating...
aws_subnet.public: Creating...
aws_security_group.web: Creating...
aws_internet_gateway.main: Creation complete after 1s
aws_subnet.public: Creation complete after 1s
aws_security_group.web: Creation complete after 2s [id=sg-0abc123]
aws_instance.web["primary"]: Creating...
aws_instance.web["secondary"]: Creating...
aws_instance.web["primary"]: Creation complete after 32s [id=i-0aaa111]
aws_instance.web["secondary"]: Creation complete after 33s [id=i-0bbb222]
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
instance_ids = {
"primary" = "i-0aaa111"
"secondary" = "i-0bbb222"
}
instance_public_ips = {
"primary" = "54.211.89.100"
"secondary" = "54.211.89.101"
}
vpc_id = "vpc-0abc123"What just happened?
- The VPC was created first, then three resources in parallel. The internet gateway, subnet, and security group all depend on the VPC but not on each other — so Terraform created them simultaneously. The instances waited for all three to complete before starting.
- Both instances were created in parallel. Primary and secondary have no dependency between them — only shared dependencies. Both started creating at the same time and finished one second apart.
- The output is a named map.
{ for k, v in aws_instance.web : k => v.id }built a map where keys are role names and values are instance IDs. This is far more useful than a list with anonymous index positions — you can look up the primary instance ID by name rather than guessing which index it is at.
lifecycle — Controlling Resource Behaviour
The lifecycle block gives you fine-grained control over how Terraform handles a resource across creation, updates, and destruction. Four arguments change behaviours that are otherwise fixed.
We are going to add lifecycle blocks to the VPC and EC2 instance to demonstrate three real scenarios: preventing accidental destruction, controlling replacement order, and ignoring specific attribute changes that are managed externally.
New terms:
- prevent_destroy — when true, Terraform throws an error and aborts if anything in the plan would destroy this resource. Protects databases, VPCs, and anything catastrophic to lose accidentally. Does not protect against
terraform destroyat the command line — only against unintended destroys in a plan. - create_before_destroy — by default, Terraform destroys the old resource then creates the replacement. With this set to true, the replacement is created first then the original is destroyed. Critical for zero-downtime deployments — removing the old instance before the new one exists causes a brief outage.
- ignore_changes — a list of attribute names Terraform ignores when planning. If an attribute is in this list and it changes outside of Terraform, Terraform will not plan to revert it. Use for attributes managed by an external system — an auto-scaling group that modifies desired count, or an agent that updates user data.
Update main.tf — add lifecycle blocks to the VPC and instance resources:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_instance" "web" {
for_each = var.instances
ami = "ami-0c55b159cbfafe1f0"
instance_type = each.value
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
depends_on = [aws_internet_gateway.main]
tags = {
Name = "web-${each.key}-${var.environment}"
Role = each.key
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
create_before_destroy = true
ignore_changes = [ami]
}
timeouts {
create = "20m"
update = "20m"
delete = "10m"
}
}
$ terraform apply -var='instances={"primary":"t3.small","secondary":"t2.micro"}'
# aws_instance.web["primary"] will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0aaa111"
~ instance_type = "t2.micro" -> "t3.small"
}
Plan: 0 to add, 1 to change, 0 to destroy.
Enter a value: yes
aws_instance.web["primary"]: Modifying... [id=i-0aaa111]
aws_instance.web["primary"]: Modifications complete after 45s
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
$ terraform destroy
╷
│ Error: Instance cannot be destroyed
│
│ on main.tf line 14, in resource "aws_vpc" "main":
│ 14: prevent_destroy = true
│
│ Resource aws_vpc.main has lifecycle.prevent_destroy set, but the plan
│ calls for this resource to be destroyed. Remove the
│ lifecycle.prevent_destroy = true argument to allow this.
╵What just happened?
- Changing instance_type to t3.small modified the primary instance in-place. AWS supports resizing a stopped instance without recreating it — so Terraform showed a
~modify, not a-/+replace. The secondary instance was completely untouched because its type was unchanged. The for_each key stability made this possible — with count, index shifts could have caused an unintended replace. - ignore_changes = [ami] means AMI changes are intentionally skipped. If the AMI ID in the configuration is updated, Terraform will not plan to replace the instance. This is correct in environments where instances are patched in-place — the AMI is the launch template only, not the ongoing source of truth.
- prevent_destroy on the VPC stopped the destroy immediately. The error message is clear and tells you exactly how to fix it — remove the lifecycle block. This is intentional design: forcing a deliberate code change before a protected resource can be destroyed. You cannot accidentally remove a
prevent_destroyresource without touching the configuration. - timeouts extended the default create and update windows to 20 minutes. The default for EC2 is 10 minutes. For larger instance types or heavily loaded regions where provisioning takes longer, extending this prevents Terraform from giving up on a perfectly healthy create operation that just needs more time.
Clean Up
Remove the prevent_destroy = true from the VPC lifecycle block first — otherwise destroy will fail. Then run:
terraform destroy
$ terraform destroy Plan: 0 to add, 0 to change, 6 to destroy. Enter a value: yes aws_instance.web["primary"]: Destroying... aws_instance.web["secondary"]: Destroying... aws_instance.web["primary"]: Destruction complete after 30s aws_instance.web["secondary"]: Destruction complete after 31s aws_security_group.web: Destroying... aws_security_group.web: Destruction complete after 2s aws_subnet.public: Destroying... aws_internet_gateway.main: Destroying... aws_subnet.public: Destruction complete after 1s aws_internet_gateway.main: Destruction complete after 3s aws_vpc.main: Destroying... aws_vpc.main: Destruction complete after 1s Destroy complete! Resources: 6 destroyed.
What just happened?
- Resources destroyed in reverse dependency order. Instances first — they depend on everything else. Then the security group. Then the subnet and internet gateway in parallel. The VPC last — it is the container everything else lived inside. Terraform reversed the dependency graph automatically.
- Both instances destroyed in parallel. No dependency between them — only shared resources below them. Parallel destruction is as fast as parallel creation.
- State file is now empty. Running
terraform planagain would show 6 resources to add — you are back at the starting point with zero cost and zero running resources.
Reading resource documentation
Every argument, every exported attribute, every timeout, and every import behaviour for every resource is documented at registry.terraform.io. Search for the resource type — aws_instance, aws_s3_bucket, azurerm_virtual_machine — and you get required arguments, optional arguments, attributes available after creation, and the timeout block if supported. Make this your first stop whenever you use a resource type you have not used before. Do not guess argument names.
Common Mistakes
Using count when items might be removed from the middle
With count = 3, resources are identified as index 0, 1, 2. Remove the middle item from the driving list and Terraform reassigns indexes — what was index 2 becomes index 1. Terraform sees this as a modification to index 1 and a destruction of index 2. Resources you expected to keep get replaced or destroyed. Use for_each with stable string keys whenever a resource set may change.
Setting ignore_changes = all
This tells Terraform to ignore every attribute on the resource — it will never plan to update it for any reason. Drift detection stops working entirely. Terraform is no longer the source of truth for that resource. Only ever ignore specific attributes that are genuinely managed by an external system.
Not reading the plan when you see -/+
A replace destroys the existing resource and creates a new one. For a security group this is painless. For an RDS database, a replace means your database is deleted and an empty one is created in its place. Every time you see -/+, find the line marked # forces replacement, understand why, and confirm you are comfortable with the consequence before typing yes.
Practice Questions
1. Inside a for_each resource, what expression gives you the current map key for the resource being created?
2. Which lifecycle argument creates the replacement resource before destroying the original — enabling zero-downtime replacements?
3. Which meta-argument forces a resource to wait for another resource to complete even when there is no attribute reference between them?
Quiz
1. Why is for_each safer than count when managing a set of resources that may have items removed?
2. You change the AMI ID on an existing aws_instance resource. What does terraform plan show and why?
3. A VPC resource has prevent_destroy = true. What happens when you run terraform destroy?
Up Next · Lesson 10
Beginner Best Practices
Nine lessons in. Before Section II, we lock in the habits that separate clean Terraform from the kind that causes incidents three months later.