Skip to content

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 resource block 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.
  • ForceNew is 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_each when instances have a natural identity (a name, an AZ) — removing one only destroys that one. Reach for count only 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 count for 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_each keys by identity so only the removed instance is touched.
  • Reaching for depends_on when 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; extra depends_on edges just add serialization the planner has to respect for no benefit.
  • Using create_before_destroy on 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_each decision rules and edge cases are in the for_each and count meta-argument references; every lifecycle argument is in the lifecycle reference — this page covers the 80/20 you need for the Do exercise.