Hedronite · Ops Lesson · 01-Earth-DevOps · Deep Terraform · Sprint Day 21 · Wed 2026-08-12 · Trio #87

Terraform's Resource Lifecycle on AWS — the destroy that came first

The plan was clean, every policy passed, and the outage happened anyway.

Lesson Class: Ops (pure DevOps — sprint override, v3.1 pair grid paused)
Sprint Track: TF — Deep Terraform, day 21, seventh TF visit
Cloud Referent: AWS — ASG behind an ALB target group
Arc Position: TF Ops rung 7 — state → module → provider → environment → pipeline → policy → order
Paired Dev: Terratest Under Change (Go)
Paired Cert: TF Associate 003 — IaC Concepts, Terraform's Purpose, and the Execution Graph
Grounding: Brikman TU&R 3ed Ch.5 pp.271-275 · Ch.2 pp.107-110 · tfpro-labs 22 + 23 + 32 + 01
The destroy that came first
Terraform's default replacement is destroy then create. Between the two, the thing does not exist, and the load balancer answers with a 503.
A name is a resource too
Creating first means both generations exist at once. Unique names come before the lifecycle flag, or the apply fails on a conflict.
The attribute with two owners
A scaling policy writes desired_capacity and so does Terraform. Name that attribute in ignore_changes. Never all.
What does a plan tell you before it tells you the diff? The verb.

Terraform's Resource Lifecycle on AWS — create_before_destroy, ignore_changes, replace_triggered_by, and Zero-Downtime Replacement Behind an ALB Target Group

Section IFrame

Three days ago this arc put a policy engine in front of the apply. The engine reads the plan, refuses what violates the rules, and the change never leaves the runner. Good. Now suppose the plan is clean, every rule passes, the engine says yes, and the apply runs.

What happens next has an order to it, and the order is not stated anywhere in the configuration.

Terraform reads the plan and sees a launch template whose AMI changed. The launch template is immutable in the one attribute that matters, so the resource cannot be updated in place. It must be replaced. Terraform's default replacement is two operations in sequence: destroy the old one, then create the new one. Between those two operations, the thing does not exist.

For a launch template, the gap is harmless. For the auto scaling group referencing it, the gap is an outage measured in whatever the AMI bake takes plus the health-check grace period. For a security group attached to a running load balancer, the gap is a dependency error that leaves the apply half-finished.

Call the failure the destroy that came first. Terraform did exactly what it said it would do in the plan, in the order the plan printed, and the order was wrong for this resource.

The lifecycle block is the only place in the language where an author changes that order. Six rungs of this arc have taught what Terraform manages. This one teaches when.

Section IIFoundations

The graph decides order; lifecycle bends it

Terraform builds a dependency graph before it does anything. The edges come from references. Write subnet_id = aws_subnet.app.id and Terraform learns that the instance depends on the subnet, so the subnet is created first and destroyed last. Brikman works this in the very first cluster example, where the security group reference is what tells Terraform the group must exist before the instance that uses it (Terraform: Up and Running, 3ed, Ch. 2, pp. 107-110).

The graph is derived, not declared. That is the whole design. An author who declares ordering by hand gets it wrong; an author who references a value gets the ordering for free.

The lifecycle block does not add nodes to that graph. It changes what Terraform does at a node it already has. Four arguments, each answering a different question:

Argument The question it answers
create_before_destroy When this must be replaced, which comes first?
prevent_destroy May this be destroyed at all?
ignore_changes Whose opinion of this attribute wins?
replace_triggered_by What else should force this to be rebuilt?

Two of them are about ordering. Two of them are about ownership. Read the block that way and the arguments stop looking like a grab bag.

Update in place, replace, or refuse

Every diff line in a plan is one of four verbs, and the provider schema decides which one applies. ~ is an in-place update, because the cloud API accepts a modify call on that attribute. -/+ is a replacement, because it does not. +/- is a replacement with the create first, which is what create_before_destroy produces. ! is a forced replacement the author asked for.

Nothing an author writes changes an attribute from update-in-place to replacement. That mapping belongs to the provider, which knows that an EC2 instance can change its tags and cannot change its subnet. What the author changes is the sequence, and only when the verb is already replacement.

Read the plan verb before reaching for the lifecycle block. If the verb is ~, there is no gap to close, and adding create_before_destroy buys nothing.

Why create-before-destroy is not the default

The obvious question: if creating first avoids the gap, why is it not the behavior everywhere?

Because creating first means both resources exist at the same time, and cloud resources fight over unique names. An S3 bucket name is globally unique. A security group name is unique per VPC. An IAM role name is unique per account. Try to create a second one carrying the same name and the API returns a conflict, so the apply fails before the old one is anywhere near being destroyed.

This is the tradeoff Lab 22 of the Terraform Pro set puts in front of the candidate: analyze the replacement behavior in the config, apply create_before_destroy where appropriate, verify the intent through the plan. Where appropriate is carrying the weight. The lab is a plan-mode lab precisely because the answer is visible in the plan and does not need real infrastructure to check.

Say it plainly: a name is a resource too. If two copies of a thing cannot hold the same name, then create_before_destroy requires a naming strategy before it requires a lifecycle block. Brikman's own solution is name_prefix rather than name, letting AWS append a random suffix so the two generations never collide (Ch. 5, pp. 271-273).

The cascade nobody expects

create_before_destroy propagates. Set it on a launch template, and Terraform must also create-before-destroy anything that depends on the launch template, because the old dependent still points at the old template and cannot be destroyed while the new one is coming up.

Terraform handles that propagation automatically in the modern versions, and the propagation is why an author who sets the flag on one resource sometimes sees six resources change their replacement order in the plan. That is not a bug. That is the graph doing arithmetic.

The reverse case is the one that bites. A resource with create_before_destroy that depends on a resource without it produces a cycle Terraform refuses to resolve, and the error text names the two resources without explaining the asymmetry. The rule to remember: the flag travels up the dependency chain, never down.

Section IIIMechanism

ignore_changes and the attribute with two owners

ignore_changes answers an ownership question. Some attribute of a Terraform-managed resource is written by something that is not Terraform, and on every plan Terraform proposes to write it back.

The desired-state count on an auto scaling group is the canonical case. Terraform set it to 4 at creation. Target-tracking scaling policy raised it to 11 at 2 PM because traffic arrived. The next plan says ~ desired_capacity: 11 -> 4, and applying it scales the fleet down in the middle of the day.

Nothing is broken. Terraform is doing its job. The configuration says 4 and reality says 11, and reconciling that difference is the entire product. The problem is that the author never meant Terraform to own that number past creation.

resource "aws_autoscaling_group" "app" {
  name                = "app-${aws_launch_template.app.name}"
  min_size            = 2
  max_size            = 20
  desired_capacity    = 4
  vpc_zone_identifier = var.private_subnet_ids
  target_group_arns   = [aws_lb_target_group.app.arn]
  health_check_type   = "ELB"

  lifecycle {
    create_before_destroy = true
    ignore_changes        = [desired_capacity]
  }
}

Lab 23 states the discipline as three tasks: identify the attributes that should tolerate drift, apply ignore_changes with precise scope, leave managed attributes under Terraform control. The middle task is where candidates lose points. ignore_changes = all is available and it is almost always wrong, because it silences the drift the author does want to hear about along with the drift they do not. Name the attributes.

The honest limit: an ignored attribute is now undocumented state. Nothing in the configuration records that the fleet runs at 11, and a reader of the repository sees 4. Write the reason in a comment beside the block. The next person to read it is deciding whether to remove the line.

replace_triggered_by and the dependency the graph cannot see

replace_triggered_by answers the opposite question. Terraform sees no change to this resource, and the author knows it needs rebuilding anyway.

The case: a container instance whose image tag is baked into user data rendered from a template. The template file changes, the rendered string changes, and the instance's user data changes with it, so the graph catches it. Fine. Now the case where it does not: an instance whose configuration is pulled at boot from an S3 object. The object version changes. Nothing in the instance resource changes. Terraform plans nothing, and the fleet keeps running the old configuration until somebody notices.

resource "aws_instance" "config_consumer" {
  ami           = data.aws_ami.base.id
  instance_type = "t3.small"

  lifecycle {
    replace_triggered_by = [aws_s3_object.app_config.version_id]
  }
}

Lab 32 names the shape exactly: force safe replacement when upstream dependency changes are not automatically represented as direct argument drift. The dependency the plan cannot see is the thing to remember, and the cure is to make the invisible dependency an explicit one.

Two constraints worth carrying into an exam room. The referenced value must be a managed resource or a resource attribute, not a variable and not a local. And a change to it forces replacement rather than update, whatever the provider would otherwise have allowed.

prevent_destroy is a guard, not a policy

prevent_destroy = true makes any plan that would destroy the resource fail at plan time with an error. Lab 01 introduces it in the same breath as init, validate, and plan, which is the right place for it: a habit rather than an architecture.

Two things it does not do. It does not stop terraform destroy from being run; it makes that command error out, which is the point, but an operator who wants the resource gone removes the line and re-runs. And it does not survive a terraform state rm, because a resource Terraform no longer tracks has no lifecycle block to consult.

The guard is against accident. Reach for the policy engine from the 08-09 lesson when the requirement is against intent.

Section IVWorked Example

Take a web tier on AWS: a launch template, an auto scaling group, an ALB target group, and a listener rule. The AMI is rebuilt weekly by a separate pipeline, and the deploy is a Terraform apply that changes one variable.

Without any lifecycle block, the plan reads:

# aws_launch_template.app must be replaced
-/+ resource "aws_launch_template" "app" {
      ~ image_id = "ami-0a1b2c" -> "ami-0d4e5f" # forces replacement
    }

# aws_autoscaling_group.app must be replaced
-/+ resource "aws_autoscaling_group" "app" {
      ~ launch_template { version = ... }
    }

Terraform destroys the ASG, which terminates every instance, then creates the replacement ASG, which launches new ones. The target group has zero healthy members for as long as boot plus health checks take. Every request in that window gets a 503 from the load balancer.

The fix is three coupled decisions, and each one is required for the others to work.

resource "aws_launch_template" "app" {
  name_prefix   = "app-"
  image_id      = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_group" "app" {
  name                      = "app-${aws_launch_template.app.name}"
  min_size                  = 2
  max_size                  = 20
  desired_capacity          = 4
  min_elb_capacity          = 2
  vpc_zone_identifier       = var.private_subnet_ids
  target_group_arns         = [aws_lb_target_group.app.arn]
  health_check_type         = "ELB"
  health_check_grace_period = 90

  launch_template {
    id      = aws_launch_template.app.id
    version = aws_launch_template.app.latest_version
  }

  lifecycle {
    create_before_destroy = true
    ignore_changes        = [desired_capacity]
  }
}

First decision: name_prefix on the launch template and an interpolated name on the ASG. Both generations need distinct names, and the ASG's name is derived from the template's so that a new template forces a new ASG rather than colliding with the old one.

Second decision: create_before_destroy on both. On the template alone it does nothing useful, because the ASG would still be destroyed first. The flag travels up the chain and both ends must carry it.

Third decision, and this is the one Brikman spends the most ink on: min_elb_capacity. Without it, Terraform considers the new ASG created the moment the API returns, which is before a single instance is healthy in the target group. It then proceeds to destroy the old ASG, and the outage happens anyway, one step later than before. min_elb_capacity = 2 makes Terraform wait until at least two instances report healthy to the ELB before it treats the create as complete (Ch. 5, pp. 273-275).

The plan now reads +/- on both resources, and the sequence is: create the new template, create the new ASG, wait for two healthy targets, destroy the old ASG, destroy the old template. The target group never drops below two healthy members. The 503 window closes.

Brikman is also honest about the limits of this pattern, and the honesty is worth carrying. The rollback story is poor, because a failed deploy leaves the old ASG already destroyed or the new one half-formed and the operator recovering by hand. Terraform is a provisioning tool doing a deployment tool's job here. It works, and a real deployment pipeline running on CodeDeploy or an ECS blue/green does it better with rollback included.

Section VConnection to Prior Lessons

The 07-22 lesson defined drift as the difference between the state file and the world, and prescribed reconciliation. ignore_changes is the deliberate exception to that prescription: an attribute where drift is expected, permanent, and correct. Naming the exception is what keeps the rule honest. A team that finds terraform plan noisy and starts ignoring the noise has lost the rule entirely.

The 08-03 pipeline lesson pinned a plan artifact by commit SHA so that the apply applies what was reviewed. Every replacement ordering discussed today is visible in that artifact, printed as -/+ or +/- before anybody approves. The reviewer who reads the verbs is reading the outage.

The 08-09 policy lesson attached a machine reader to that same artifact. A Sentinel or Rego rule can assert that no resource carrying the tier = "public" tag is ever planned with a plain -/+. The rule is three lines and it catches the outage in CI rather than at 2 AM.

Section VIConnection to Today's Dev Lesson

The Dev lesson proves what this one claims.

Everything above is an assertion about behavior during a change. min_elb_capacity holds the target group above two healthy members while the fleet rolls. That is a testable claim, and no static check can test it, because the claim is about a window of time rather than a final state. Terratest runs a background HTTP poller against the ALB DNS name, applies a second time with a changed AMI variable, and counts failures across the window. Zero failures is the pass.

The second half is the idempotence assertion. After an apply, a fresh plan should be empty. If it is not, some attribute is fighting: a computed value the configuration keeps trying to overwrite, a normalization the provider applies, an externally-written field with no ignore_changes on it. The Dev lesson reads the plan as a JSON struct in Go and asserts a zero-length change set, which turns the noisy plan a team learns to skim into a test that fails the build.

Section VIIClosing

The plan tells you the verb. Read it before you read the diff.

Where the verb is replacement and the resource carries traffic, decide the order deliberately rather than accepting the default. Give both generations distinct names before you set the flag, because the flag without the naming strategy fails on a name conflict and looks like a Terraform bug. Set the flag on every resource up the chain, not only the one that changed. Where the create is not complete until something is healthy, say so with min_elb_capacity or its equivalent, or the wait never happens.

Where an attribute has a second owner, name that attribute in ignore_changes and write down why beside it. Never all.

Where a dependency exists in the world and not in the graph, wire it with replace_triggered_by rather than trusting an operator to remember.

Then go look at your own plan output from the last deploy and count the -/+ lines against resources that serve traffic. That count is the number of outages you have scheduled and not yet noticed.

Examine well.

Cross-ReferencesRelated

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-12 · Fajr trio #87 · sprint day 21 · TF track
Paired: Polyglot-Dev/Go/2026-08-12-terratest-under-change/ · Cert-Prep/HashiCorp/2026-08-12-terraform-associate-003-iac-concepts/