Back to Blog

Terraform Meta-Arguments, Lifecycle & Provisioners for the Terraform Associate Exam: depends_on, lifecycle, provisioners & null_resource

A practitioner's guide to Terraform's resource meta-arguments and escape hatches for the Terraform Associate (003/004) exam: explicit depends_on, the lifecycle block (create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by), custom conditions, provisioners (local-exec, remote-exec, file), connection blocks, and null_resource / terraform_data — with commands, real examples, and the trade-offs the exam tests.

By Sailor Team , August 12, 2026

Most of Terraform is delightfully declarative: you describe the resources you want, and Terraform figures out the order to create, update, and destroy them. But real infrastructure has edges the dependency graph can’t infer on its own — a database that must exist before an app boots, a resource you never want an apply to delete, an attribute an autoscaler mutates that you don’t want Terraform to fight over, and the occasional bootstrap step no provider exposes as a resource. Terraform’s meta-arguments and provisioners are how you handle those edges, and they show up directly on the Terraform Associate (003/004) exam.

This guide is written from a practitioner’s perspective. We’ll cover the explicit depends_on meta-argument, the full lifecycle block, custom conditions, the three provisioner types and their connection blocks, and the null_resource / terraform_data escape hatches. Two of these — count and for_each — are meta-arguments too, but they earn their own treatment in the count vs for_each guide; here we focus on the ones that control ordering, replacement, and bootstrapping. If you want the foundation first, the core workflow walkthrough and the state management guide set the stage, and the Terraform Associate exam guide 2026 maps where these objectives sit.

What Meta-Arguments Are

A meta-argument is an argument you can set on any resource (and some on module) blocks that changes how Terraform manages the resource, rather than configuring the underlying object. Provider-specific arguments (like an instance’s AMI or an S3 bucket’s name) describe what to build; meta-arguments describe how Terraform should build, order, and replace it.

There are five resource meta-arguments:

Meta-argumentPurpose
depends_onDeclare an explicit dependency Terraform can’t infer
countCreate N copies indexed by number
for_eachCreate copies keyed by a map or set
providerSelect a non-default (aliased) provider
lifecycleControl create/update/destroy behavior

count, for_each, and provider are about multiplicity and targeting. This guide focuses on the two that trip people up under exam pressure: depends_on and lifecycle — plus the provisioner blocks that live alongside them.

depends_on: Explicit Ordering

Terraform builds a dependency graph by reading references between resources. When resource B interpolates an attribute of resource A — say subnet_id = aws_subnet.app.id — Terraform automatically knows A must exist first. This implicit dependency covers the vast majority of cases, and you should always prefer it: references keep the graph accurate as your code evolves.

depends_on exists for the minority of cases where a real dependency exists but no attribute reference expresses it. The classic example is IAM: an EC2 instance’s application needs an IAM policy attached to its role before it can call an API, but the instance resource doesn’t reference the policy attachment in any argument. Terraform can’t see the ordering, so you state it explicitly:

resource "aws_instance" "app" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name

  # The app can't call S3 until this policy is attached,
  # but nothing above references the attachment. Make it explicit.
  depends_on = [aws_iam_role_policy_attachment.app_s3]
}

Key rules the exam expects you to know:

  • depends_on takes a list of references to whole resources or modules, not to individual attributes. Write aws_iam_role_policy_attachment.app_s3, not aws_iam_role_policy_attachment.app_s3.id.
  • It should be a last resort. Overusing it creates artificial ordering that slows applies and hides real relationships. Reach for a reference first; use depends_on only when there genuinely isn’t one.
  • Modules accept depends_on too. Setting it on a module block makes every resource inside wait for the listed dependencies — useful when a whole module needs a network or IAM foundation that its inputs don’t reference.

The lifecycle Block

The lifecycle block is a nested configuration block inside a resource that customizes how Terraform handles the create/update/destroy sequence. It has five arguments worth knowing cold.

create_before_destroy

By default, when a change forces replacement, Terraform destroys the old resource, then creates the new one. For anything serving traffic, that’s an outage. Setting create_before_destroy = true inverts the order: Terraform creates the replacement first, then destroys the original.

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.small"

  lifecycle {
    create_before_destroy = true
  }
}

This is the backbone of zero-downtime replacements. One catch the exam likes: it only works when the new resource can coexist with the old one. If a unique name or a fixed EIP collides, you’ll need a naming strategy (for example, name_prefix instead of a fixed name) so both can exist momentarily. create_before_destroy also propagates to dependencies — resources that depend on this one inherit the inverted ordering so the graph stays consistent.

prevent_destroy

prevent_destroy = true makes Terraform reject any plan that would destroy the resource, erroring out instead of proceeding. It’s a guardrail for stateful, irreplaceable resources — a production database, a critical S3 bucket, a KMS key.

resource "aws_db_instance" "prod" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

Two things candidates miss:

  • It blocks destroy, including a replacement’s destroy step. If a change forces replacement, the plan fails because it would destroy the resource — so prevent_destroy can surface unexpected “this change requires replacement” situations early.
  • It does not stop a terraform destroy if you first remove the resource from configuration. Terraform can only honor the flag while the resource is still in your code. It’s a safety net, not a hard lock — treat it as one.

ignore_changes

Sometimes something outside Terraform legitimately mutates an attribute — an autoscaler changes desired_capacity, a deploy tool rewrites a tag, an external process updates an AMI. On the next plan, Terraform sees drift and wants to revert it. ignore_changes tells Terraform to stop managing specific attributes after creation:

resource "aws_autoscaling_group" "app" {
  desired_capacity = 2
  max_size         = 10
  min_size         = 2

  lifecycle {
    ignore_changes = [desired_capacity]
  }
}

Now Terraform sets desired_capacity at creation but never reverts out-of-band changes to it. You can list several attributes, or use ignore_changes = all to ignore every attribute after create (rare, and a code smell — prefer naming specific attributes). This is the declarative cousin of drift management; for the broader picture of reconciling real-world changes, see the import and drift detection guide.

replace_triggered_by

Added in Terraform 1.2, replace_triggered_by forces a resource to be replaced when another resource or attribute changes, even if the resource’s own configuration is unchanged. It’s the inverse of ignore_changes: instead of suppressing a change, you propagate one.

resource "aws_appautoscaling_target" "svc" {
  # ...
  lifecycle {
    replace_triggered_by = [aws_ecs_service.app.id]
  }
}

A common use is forcing an instance or task to be recreated whenever a related configuration resource changes. It accepts references to resources, resource instances, or individual attributes.

precondition and postcondition

The lifecycle block can also hold custom condition blocks that validate assumptions during plan and apply. A precondition is checked before the resource is evaluated; a postcondition after. Each has a condition expression and an error_message:

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.small"

  lifecycle {
    precondition {
      condition     = data.aws_ami.al2023.architecture == "x86_64"
      error_message = "The selected AMI must be an x86_64 image."
    }
  }
}

Conditions let you fail fast with a clear message instead of surfacing a confusing provider error later. They complement input variable validation (covered in the variables, outputs, and locals guide) — variable validation guards inputs; pre/postconditions guard resource assumptions.

Provisioners: the Last Resort

Provisioners run scripts or commands as part of resource creation or destruction. They exist for the genuine gaps — bootstrapping a machine, running a one-off configuration step, notifying an external system — where no provider resource does the job.

HashiCorp is unusually blunt in the documentation: provisioners are a last resort. The reasons matter for the exam and for real work:

  • They’re imperative inside a declarative tool, so Terraform can’t plan their effects. A provisioner’s actions aren’t represented in state or shown in plan.
  • They run only at create or destroy time, not on updates. If a provisioner installed software at creation, editing the script won’t re-run it on the next apply.
  • A failed provisioner taints the resource, marking it for replacement on the next apply — which can cascade into destroying and recreating infrastructure.

Prefer, in order: a provider resource, cloud-init / user_data, a purpose-built config-management or image-baking tool, and only then a provisioner. With that warning honored, here are the three types.

local-exec

local-exec runs a command on the machine running Terraform — your laptop or the CI runner — not on the created resource. It’s useful for local side effects: writing an inventory file, invoking a CLI, triggering a downstream job.

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"

  provisioner "local-exec" {
    command = "echo ${self.private_ip} >> private_ips.txt"
  }
}

self refers to the resource the provisioner is attached to, so self.private_ip is the new instance’s IP. You can set working_dir, pass environment variables, and choose an interpreter.

remote-exec and the connection block

remote-exec runs commands on the remote resource over SSH or WinRM. It needs a connection block telling Terraform how to reach the machine:

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
  key_name      = aws_key_pair.deploy.key_name

  connection {
    type        = "ssh"
    user        = "ec2-user"
    private_key = file("~/.ssh/deploy_key")
    host        = self.public_ip
  }

  provisioner "remote-exec" {
    inline = [
      "sudo dnf install -y nginx",
      "sudo systemctl enable --now nginx",
    ]
  }
}

remote-exec accepts inline (a list of commands), script (a single local script uploaded and run), or scripts (several). Because it needs network reachability and credentials, it’s fragile — a security group change or a boot-timing issue breaks the apply. This is exactly why cloud-init user_data is usually the better bootstrap path.

file

The file provisioner copies a file or directory from the machine running Terraform to the new resource, using the same connection block:

provisioner "file" {
  source      = "config/app.conf"
  destination = "/etc/app/app.conf"
}

You can also copy inline content instead of a source path.

Creation-time vs destroy-time provisioners

By default a provisioner runs at creation. Set when = destroy to run it during terraform destroy instead — handy for graceful deregistration, draining, or cleanup that the provider doesn’t handle:

provisioner "local-exec" {
  when    = destroy
  command = "./deregister.sh ${self.id}"
}

Destroy-time provisioners have restrictions: they can only reference self, count, and for_each — not other resources or variables — because those may no longer exist at destroy time.

Handling failure

By default, a failed provisioner fails the apply and taints the resource. Override with on_failure:

provisioner "remote-exec" {
  on_failure = continue   # or the default: fail
  inline     = ["/opt/optional-step.sh"]
}

continue lets the apply proceed despite a provisioner error — use it only when the step is genuinely optional.

null_resource and terraform_data

Sometimes you need a provisioner or a lifecycle hook that isn’t tied to a real infrastructure resource — for example, run a script whenever a value changes. The classic tool is the null_resource from the null provider, paired with triggers:

resource "null_resource" "db_migrate" {
  triggers = {
    schema_version = var.schema_version
  }

  provisioner "local-exec" {
    command = "./run_migrations.sh"
  }
}

The triggers map is the key idea: when any value in it changes, Terraform replaces the null_resource, which re-runs its provisioners. Here, bumping schema_version re-runs migrations; leaving it unchanged does nothing.

Terraform 1.4 introduced terraform_data, a built-in managed resource that does the same job without needing the null provider. It has a triggers_replace argument and an input/output pair for passing values through:

resource "terraform_data" "db_migrate" {
  triggers_replace = [var.schema_version]

  provisioner "local-exec" {
    command = "./run_migrations.sh"
  }
}

For new code, prefer terraform_data — it’s built in, needs no extra provider, and reads more clearly. Know both for the exam, since null_resource still appears everywhere in existing configurations.

Choosing the Right Tool: a Decision Table

SituationReach for
Real dependency with no attribute referencedepends_on
Replace without downtimelifecycle { create_before_destroy = true }
Protect a stateful resource from deletionlifecycle { prevent_destroy = true }
An external process mutates an attributelifecycle { ignore_changes = [...] }
Recreate when a related resource changeslifecycle { replace_triggered_by = [...] }
Validate an assumption and fail earlyprecondition / postcondition
Run a local command as a side effectprovisioner "local-exec"
Bootstrap a machine over SSH/WinRMuser_data first, else remote-exec
Re-run a script when a value changesterraform_data (or null_resource) with triggers

The through-line: prefer the declarative option, and use provisioners only when nothing else fits.

Common Terraform Associate Exam Traps

  • Implicit dependencies beat depends_on. If a reference expresses the relationship, use it; depends_on is for the cases where none does.
  • create_before_destroy needs coexistence. Fixed unique names defeat it — use name_prefix or similar so old and new can overlap.
  • prevent_destroy only works while the resource is in config. Remove the block from code and Terraform will happily destroy it.
  • ignore_changes suppresses drift; replace_triggered_by forces replacement. They point in opposite directions — know which is which.
  • Provisioners are a last resort and aren’t shown in plan. A failed one taints the resource.
  • Destroy-time provisioners can only reference self, count, and for_each.
  • terraform_data is the modern replacement for null_resource and needs no external provider.

Keep the Terraform commands cheat sheet nearby while you practice these — many map directly to CLI flags like -replace.

Frequently Asked Questions

When should I use depends_on instead of a normal reference?

Almost never — prefer a reference. Terraform builds its dependency graph from interpolations like subnet_id = aws_subnet.app.id, so referencing an attribute already creates the correct ordering. Use depends_on only when a real dependency exists but nothing in the configuration references it, such as an IAM policy attachment that must exist before an instance’s app can call an API. It takes a list of whole-resource or whole-module references, not attribute references, and overusing it slows applies and hides real relationships.

What is the difference between create_before_destroy and prevent_destroy?

They solve different problems. create_before_destroy = true changes the order of a replacement so Terraform builds the new resource before destroying the old one, enabling zero-downtime swaps. prevent_destroy = true refuses any plan that would destroy the resource, erroring out to protect stateful infrastructure like a production database. One reorders a replacement; the other blocks a destroy entirely. Note that prevent_destroy can cause a “requires replacement” change to fail, since replacement includes a destroy step.

How does ignore_changes work and when should I use it?

ignore_changes lives in the lifecycle block and lists attributes Terraform should stop reconciling after the resource is created. It’s for attributes that something outside Terraform legitimately mutates — an autoscaler adjusting desired_capacity, a deploy pipeline rewriting a tag. Terraform sets the value at creation but won’t revert out-of-band changes afterward. Name specific attributes rather than using ignore_changes = all, which is a blunt instrument and usually a sign the resource is being managed by two systems at once.

Why does HashiCorp call provisioners a last resort?

Because provisioners are imperative steps inside a declarative tool. Terraform can’t represent their effects in state or preview them in plan, they run only at create or destroy time (not on updates), and a failed provisioner taints the resource and marks it for replacement. That combination makes them fragile and hard to reason about. The recommended order is: use a provider resource, then cloud-init or user_data, then a dedicated config-management or image-baking tool, and only then a provisioner for the gap nothing else fills.

What replaced null_resource in newer Terraform versions?

terraform_data, added in Terraform 1.4, is a built-in managed resource that provides the same “run something when a value changes” behavior without requiring the external null provider. It uses a triggers_replace argument and offers input/output for passing values through. null_resource with a triggers map still works and appears throughout existing code, so learn both — but prefer terraform_data for new configurations because it needs no extra provider and reads more clearly.

Can a provisioner run when a resource is destroyed?

Yes. Set when = destroy on the provisioner and it runs during terraform destroy (or when the resource is otherwise removed) instead of at creation. This is useful for graceful cleanup — deregistering from a load balancer, draining connections, or removing DNS records the provider doesn’t manage. Destroy-time provisioners are restricted, though: they can reference only self, count, and for_each, because other resources and variables may no longer exist when the destroy runs.

Conclusion and Next Steps

Terraform’s declarative core handles ordering and replacement automatically most of the time — but production infrastructure has edges that need explicit control. depends_on states dependencies the graph can’t infer, the lifecycle block governs how resources are created, protected, and replaced, custom conditions fail fast with clear messages, and provisioners plus terraform_data cover the imperative bootstrap steps nothing else does. Master when to reach for each — and, just as importantly, when not to — and you’ll handle both the exam’s scenario questions and the messy real-world cases they model.

The fastest way to turn these concepts into exam-day confidence is realistic practice. Sailor.sh’s HashiCorp Terraform Associate (004) Mock Exam Bundle gives you exam-style questions that mirror the real format and difficulty — including lifecycle, provisioner, and dependency scenarios like the ones above — with detailed explanations that surface the exact distinctions the exam tests. Working through realistic questions is the surest way to find your gaps before they cost you points.

Pair the practice with the Terraform Associate 30-day study plan, then reinforce the fundamentals with the core workflow walkthrough, the state management guide, and the count vs for_each guide. When you’re ready to self-check, the free Terraform Associate practice questions let you test yourself before a full mock.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now