Providers & Lifecycle¶
A one-line tag edit plans as a quiet ~. Rename that same resource's identifier instead, and the plan
flips to -/+ — destroy, then recreate, with a new ID and possibly the data that lived on the old one.
Same shape of edit, opposite blast radius, and reading the plan is the only way to know which one you're
about to get. The course page shows provider "aws" { ... } and a handful of resource blocks; this page
slows down on how Terraform decides create vs update-in-place vs destroy-and-recreate for a single
resource, the three iteration primitives (count/for_each/dynamic) and when each is the right call,
the lifecycle meta-arguments that override the default behavior, and how Terraform builds the
dependency graph that decides ordering.
TL;DR — in 30 seconds:
- A
resourceblock is meaningless without its provider — the provider is the plugin that knows the resource's schema and translates "create this" into the real API call; Terraform core has no built-in cloud knowledge. ForceNewis a provider decision, not a Terraform one — the same-looking attribute can be updatable on one resource type and force-new on another; a-/+you didn't expect is the moment to stop and check which attribute triggered it.- Default to
for_eachwhen instances have a natural identity (a name, an AZ) — removing one only destroys that one. Reach forcountonly for disposable, identity-less copies, since removing one shifts every index above it.
1. A provider is what defines the resource¶
A resource "aws_s3_bucket" "logs" { ... } block is meaningless without the aws provider: the provider
is the plugin that knows aws_s3_bucket's schema (which arguments exist, which are required, which are
computed), and the plugin that translates "create this" into the actual PutBucket API call. Terraform
core has no built-in knowledge of any cloud — it only knows how to build a graph of blocks, diff state
against config, and hand each diff to the provider responsible for that resource type.
provider "aws" {
region = var.region
}
provider "aws" {
alias = "us_east_1" # a second, aliased instance of the same provider
region = "us-east-1"
}
One provider instance talks to one account/region/endpoint. Most modules need exactly one; you reach
for alias only when a single root module must reach two regions or two accounts at once (a CloudFront
cert that must live in us-east-1 regardless of where the rest of the stack runs, for example).
2. Resource CRUD — what a plan symbol actually means¶
Every terraform plan line is Terraform diffing state (what it last saw) against config (what
you're asking for) and picking one of four actions per resource:
| Symbol | Action | What triggers it |
|---|---|---|
+ |
Create | The resource exists in config but not in state. |
~ |
Update in-place | An argument changed, and the provider's schema marks that argument as updatable without recreation (e.g. an S3 bucket's tags). |
-/+ |
Destroy and recreate (replace) | An argument changed that the provider schema marks ForceNew — the cloud API has no "modify" call for that attribute, so the only path is delete-then-create (e.g. an RDS instance's identifier, an EC2 instance's ami). |
- |
Destroy | The resource is in state but no longer in config. |
ForceNew is a provider decision, not a Terraform one. The same-looking argument can be updatable on
one resource type and force-new on another — you find out which by reading the plan output or the
provider's resource docs, not by guessing. A -/+ you didn't expect on a stateful resource (a database, a
resource with attached data) is the single most common "oh no" moment in a Terraform review; always read
what triggered it before approving.
flowchart LR
Config["Desired state<br/>(.tf files)"]
State[("Last-known state")]
Diff{"terraform plan<br/>diff per resource"}
Create["+ create"]
Update["~ update in-place"]
Replace["-/+ replace<br/>(destroy then create)"]
Destroy["- destroy"]
Config --> Diff
State --> Diff
Diff --> Create
Diff --> Update
Diff --> Replace
Diff --> Destroy
3. count, for_each, dynamic — the three iteration primitives¶
Terraform has no loops in the general-purpose-language sense; these three are the sanctioned ways to produce more than one of something from a single block.
flowchart TD
Q1{"Repeating a resource/module,<br/>or a nested block inside one resource?"}
Q1 -->|"nested block<br/>(e.g. repeated ingress rules)"| Dynamic["dynamic"]
Q1 -->|"resource or module"| Q2{"Does each instance have<br/>a natural identity —<br/>a name, an AZ, an environment?"}
Q2 -->|"yes — key-based addressing matters"| ForEach["for_each"]
Q2 -->|"no — disposable, interchangeable copies"| Count["count"]
| Primitive | Produces | Reference form | Removing one element |
|---|---|---|---|
count = N |
N indexed copies of a resource/module | aws_instance.web[0] |
Shifts every index above the removed one — those resources get destroyed and recreated too. |
for_each = {...} / for_each = toset([...]) |
One copy per map key or set member | aws_subnet.private["private-a"] |
Removes only that one keyed instance; every other instance is untouched. |
dynamic "block_name" {...} |
A variable number of nested blocks inside one resource (e.g. repeated ingress blocks in a security group) |
ingress.value.<attr> inside the content {} |
N/A — it's not a resource, just repeated config inside one. |
Default to for_each whenever instances have a natural identity (a name, an AZ, an environment) — its
key-based addressing means a config change touches only the instance that actually changed. Reach for
count only for genuinely identity-less, disposable copies where index-shifting churn doesn't matter.
Reach for dynamic when a single resource type accepts a repeatable nested block and you'd otherwise
copy-paste it.
# count: fine for disposable, interchangeable instances
resource "aws_instance" "worker" {
count = 3
ami = var.ami
instance_type = "t3.small"
}
# for_each: each instance has an identity worth keeping stable
resource "aws_subnet" "private" {
for_each = var.private_subnets # map keyed by name
cidr_block = each.value.cidr
availability_zone = each.value.az
}
# dynamic: repeat a nested block a variable number of times
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from
to_port = ingress.value.to
protocol = ingress.value.protocol
}
}
4. The lifecycle block — overriding the default behavior¶
lifecycle {} is a nested block inside any resource that changes how Terraform handles that one
resource's create/update/destroy decisions:
| Argument | Effect | Typical use |
|---|---|---|
prevent_destroy = true |
Any plan that would destroy this resource errors out instead. | Irreplaceable resources — a production database, a bucket holding the only copy of something. |
create_before_destroy = true |
On a forced replace, create the new resource first, then destroy the old one (default order is the reverse). | Anything that mustn't have a gap — a load balancer target group, a launch template still referenced elsewhere. |
ignore_changes = [attr, ...] |
Terraform stops diffing the listed attributes — drift there never shows up in plan. | A field another system mutates outside Terraform (autoscaler-managed replica count, a tag added by a scanning tool). |
replace_triggered_by = [...] |
Force a replace when a referenced attribute changes, even though this resource wouldn't otherwise be affected. | "Rebuild this when that rotates" chains with no natural dependency. |
create_before_destroy has one sharp edge worth knowing up front: it only works if the new and old
resource can coexist, which usually means the resource's identifying name/key can't be a fixed, globally
unique string — otherwise the create step collides with the still-existing old resource before the destroy
step runs.
5. Dependencies — how the graph gets built¶
Terraform never asks you to declare ordering. It builds a directed acyclic graph (DAG) from every expression that references another block, then walks it: anything with no unresolved reference can run immediately (and in parallel with siblings); anything that references another resource's attribute waits for that resource first.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id # implicit dependency — Terraform infers the order
cidr_block = "10.0.1.0/24"
}
resource "aws_instance" "web" {
subnet_id = aws_subnet.private.id # transitively depends on aws_vpc.main too
depends_on = [aws_iam_role_policy_attachment.ec2_ssm] # explicit — no attribute reference exists
}
Most dependencies are implicit: reading aws_vpc.main.id from aws_subnet.private is enough for
Terraform to sequence VPC before subnet — no separate declaration needed. Reach for depends_on only
when the ordering requirement is real but no attribute reference expresses it (a policy that must be
attached before an instance boots, even though the instance's config never reads the policy's attributes).
Over-using depends_on where an implicit reference would do just adds edges the planner has to serialize
around, for no benefit.
flowchart TB
VPC["aws_vpc.main"]
Subnet["aws_subnet.private"]
SG["aws_security_group.web"]
Policy["aws_iam_role_policy_attachment.ec2_ssm"]
Instance["aws_instance.web"]
VPC --> Subnet
VPC --> SG
Subnet --> Instance
SG --> Instance
Policy -.->|"depends_on (explicit)"| Instance
Resources with no path between them in the graph are independent — Terraform applies them concurrently
(bounded by -parallelism, default 10), which is why an unrelated -target resource can finish before or
after another with no ordering guarantee between the two.
6. How this connects¶
The course page's provider "aws" {} plus a couple of resource blocks is the surface; this page is what
happens underneath every plan: which attribute changes are safe updates versus forced replacements, which
of the three iteration primitives fits a given shape of resource, what a lifecycle block is protecting
against, and why the plan applies resources in the order it does.
Common gotchas
- Guessing whether an attribute change is a safe update or a forced replace. Fix: it's a
provider-schema decision (
ForceNew), not something you can infer from how the attribute looks — read the plan or the provider's resource docs, don't guess. - Defaulting to
countfor a list of named things (subnets, environments). Fix:count's index shifts every element above a removed one, destroying and recreating resources that didn't actually change;for_eachkeys by identity so only the removed instance is touched. - Reaching for
depends_onwhen an attribute reference would already express the dependency. Fix: most ordering is implicit — reading another resource's attribute is enough for Terraform to sequence them; extradepends_onedges just add serialization the planner has to respect for no benefit. - Using
create_before_destroyon a resource whose name/key is a fixed, globally unique string. Fix: the new resource can't be created before the old one is destroyed if they'd collide on that identifier — the create step fails before the destroy step ever runs.
Check yourself — an RDS instance's identifier argument gets a -/+ in the plan, but a tag edit on the same instance shows ~. Why the difference?
Because ForceNew is set per-attribute in the provider's schema, not per-resource — the provider has
no API call to update identifier in place, so that one change forces destroy-and-recreate, while
tags are updatable in place on the same resource type.
Check yourself — you have 5 subnets defined with for_each over a map, and you delete one entry from the map. What happens to the other 4?
Nothing — for_each addresses each instance by its map key, so removing one entry only destroys that
one keyed instance; the other four are untouched, unlike count's index-based addressing.
You can defend this when you can read a plan line and say whether it's a safe update or a forced
replace and why, choose between count and for_each for a given resource and justify it, name what
prevent_destroy and create_before_destroy each protect against, and explain the difference between an
implicit dependency and depends_on.
Go deeper: the complete
count/for_eachdecision rules and edge cases are in thefor_eachandcountmeta-argument references; everylifecycleargument is in the lifecycle reference — this page covers the 80/20 you need for the Do exercise.