Depends On & Life Cycles
Terraform allows you to define dependencies between resources using the depends_on attribute. This meta argument specifies
that one resource depends on another, and Terraform will ensure that the dependent resource is created or updated before
changes are made to the resources it depends on.
In addition to depends_on, Terraform also provides a set of lifecycle blocks that allow you to control the creation, updating, and deletion of resources.
It is worth noting that the use of depends_on should be done as a last resort as it can cause Terraform to create more
conservative plans that replace more resources than necessary.
What is Terraform depends_on
Section titled “What is Terraform depends_on”In most scenarios, Terraform automatically determines dependencies between resources based on expressions within a resource block.
This is where the depends_on meta-arugment comes in, as it allows you to create an explicit dependency between two resources.
These dependencies are not limited to just resources - they can be created between modules.
Put simply depends_on affects the order in which Terraform processes all the resources and data sources associated with an state change.
Using depends_on
Section titled “Using depends_on”If a dependency is not visible to Terraform, you can explicitly set it using the depends_on meta-argument.
Examples of this could be deploying containers only after the container registry is created, or ensuring settings
Another common scenario is ensuring that a database is initialized (with custom scripts or configurations) before another resource, such as an application server, starts.
Here, the EC2 instance app_server waits until the database initialization script has completed, ensuring that the
application server only starts once the database is ready.
resource "aws_db_instance" "db" { engine = "mysql" instance_class = "db.t2.micro" allocated_storage = 20 db_name = "mydb" username = "admin" password = var.db_password # Use a variable or secrets manager — never hardcode passwords}
resource "null_resource" "db_initialization" { provisioner "local-exec" { command = "echo Initializing database..." }
depends_on = [aws_db_instance.db]}
resource "aws_instance" "app_server" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro"
depends_on = [null_resource.db_initialization]}Note: The
null_resourceshown here is used for illustration. In modern Terraform (1.4+), the built-interraform_dataresource is the recommended replacement, as it doesn’t require an external provider.
Using Lifecycle Blocks
Section titled “Using Lifecycle Blocks”In addition to depends_on, Terraform provides a set of lifecycle blocks that allow you to control the creation, updating, and deletion of resources. Here are the available lifecycle blocks:
create_before_destroy— Specifies that the new resource should be created before the old resource is destroyed. This can be useful when changing resource configurations that require both the old and new resources to be present at the same time.prevent_destroy— Specifies that a resource should not be destroyed, even if it is no longer managed by Terraform. This can be useful for resources that should never be deleted, such as a production database.ignore_changes— Specifies that Terraform should ignore changes to certain resource attributes. This can be useful for attributes that are managed outside of Terraform, such as instance metadata.replace_triggered_by— Forces Terraform to replace a resource when a referenced resource or attribute changes, even if the resource’s own configuration has not changed. Added in Terraform 1.2.
Here’s an example of using create_before_destroy:
resource "aws_lb_target_group" "web" { name = "web-target-group" port = 80 protocol = "HTTP" vpc_id = aws_vpc.main.id
lifecycle { create_before_destroy = true }}In this example, we create an AWS target group for an application load balancer. We use the lifecycle block to specify that the target group should be created before the old target group is destroyed. This ensures that there is no downtime during the update.
Using replace_triggered_by
Section titled “Using replace_triggered_by”The replace_triggered_by lifecycle argument (Terraform 1.2+) forces Terraform to replace a resource when a referenced resource or attribute changes, even if the resource’s own configuration has not changed. This is useful when a resource depends on another in a way that Terraform cannot detect automatically.
Each entry in replace_triggered_by can reference a managed resource, a managed resource attribute, or a variable. When any referenced value changes, Terraform plans a replacement of the resource containing the lifecycle block.
A common use case is restarting an EC2 instance when its user data script changes. Terraform does not replace an instance for user data changes by default, so replace_triggered_by lets you express that relationship explicitly:
resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" user_data = file("scripts/setup.sh")
lifecycle { replace_triggered_by = [terraform_data.setup_script_hash.output] }}
resource "terraform_data" "setup_script_hash" { input = filesha256("scripts/setup.sh")}Here, terraform_data.setup_script_hash tracks the hash of the setup script. When the script file changes, the hash changes, and Terraform replaces the aws_instance.web resource in response.
Note:
replace_triggered_byonly supports managed resources and their attributes. It does not accept data sources or local values. If you need to trigger replacement based on an arbitrary value, wrap it in aterraform_dataresource as shown above.
The terraform_data Resource
Section titled “The terraform_data Resource”The terraform_data resource is a built-in managed resource introduced in Terraform 1.4. It serves the same role as the older null_resource from the hashicorp/null provider, but without requiring an external provider — it is available in every Terraform configuration out of the box.
input and output
Section titled “input and output”terraform_data exposes two key attributes:
input— accepts an arbitrary value that is stored in state. When this value changes, Terraform treats the resource as needing replacement.output— returns the same value that was passed toinput, making it available to other resources and expressions.
This pair lets you thread a value through the resource lifecycle so that downstream references can react to changes:
resource "terraform_data" "build_version" { input = var.app_version}
output "deployed_version" { value = terraform_data.build_version.output}Triggering Replacements with triggers_replace
Section titled “Triggering Replacements with triggers_replace”The triggers_replace lifecycle-style argument works like the triggers map on null_resource. When any value passed to triggers_replace changes, Terraform replaces the terraform_data resource, which in turn re-runs any provisioners attached to it:
resource "terraform_data" "run_migrations" { triggers_replace = [var.db_schema_version]
provisioner "local-exec" { command = "bash scripts/migrate.sh" }}Each time var.db_schema_version changes, Terraform destroys and recreates the resource, executing the migration script again.
When to Use terraform_data
Section titled “When to Use terraform_data”Reach for terraform_data when you need to:
- Store a lifecycle-managed value — track an arbitrary value in state so that other resources can reference it through
outputand react when it changes. - Host provisioners — attach
local-execorremote-execprovisioners to a resource that has no real infrastructure behind it. - Trigger replacements — combine
triggers_replacewithreplace_triggered_byon another resource to force recreation when an external value changes, as shown in thereplace_triggered_bysection above.
Because terraform_data is built in, you do not need to declare a provider or manage provider version constraints the way you would with null_resource. For any new configuration, prefer terraform_data over null_resource.
Refactoring with moved Blocks
Section titled “Refactoring with moved Blocks”When you rename a resource, change its module path, or switch from count to for_each, Terraform sees the old address as a resource to destroy and the new address as a resource to create. The moved block (Terraform 1.1+) tells Terraform that a resource has changed address, so it updates the state instead of destroying and recreating the resource.
When to Use moved Blocks
Section titled “When to Use moved Blocks”Common scenarios where moved blocks help:
- Renaming a resource (e.g.,
aws_instance.web→aws_instance.app) - Moving a resource into or out of a module
- Switching a resource from
counttofor_each - Reorganising your configuration without downtime
Basic Example: Renaming a Resource
Section titled “Basic Example: Renaming a Resource”Suppose you rename an S3 bucket resource from aws_s3_bucket.data to aws_s3_bucket.assets. Without a moved block, Terraform would plan to destroy the old bucket and create a new one. Adding a moved block avoids that:
moved { from = aws_s3_bucket.data to = aws_s3_bucket.assets}
resource "aws_s3_bucket" "assets" { bucket = "my-assets-bucket"}When you run terraform plan, Terraform reports that it will move the resource in state rather than replace it. After a successful apply, you can remove the moved block — it only needs to be present for one apply cycle.
Moving a Resource into a Module
Section titled “Moving a Resource into a Module”If you refactor a resource into a child module, the moved block maps the old root-level address to the new module address:
moved { from = aws_instance.web to = module.compute.aws_instance.web}This tells Terraform that the instance previously managed at aws_instance.web now lives inside the compute module. The underlying infrastructure stays untouched.
Switching from count to for_each
Section titled “Switching from count to for_each”When migrating a resource from count to for_each, each indexed instance needs a moved block mapping it to the new key:
moved { from = aws_subnet.private[0] to = aws_subnet.private["eu-west-2a"]}
moved { from = aws_subnet.private[1] to = aws_subnet.private["eu-west-2b"]}This is one of the most common uses of moved blocks, since switching from index-based to key-based addressing is a frequent refactoring step as configurations mature.
Things to Keep in Mind
Section titled “Things to Keep in Mind”movedblocks are declarative — they only need to be present for one apply. After the state is updated, you can remove them.- You can chain
movedblocks if a resource has been renamed multiple times, but keeping oldmovedblocks around is optional. They are harmless but add clutter over time. movedblocks work across modules. Thefromandtoaddresses can reference different module paths.- If the
fromaddress does not exist in state, Terraform silently ignores themovedblock. This makes it safe to leave them in shared modules where not every consumer has the old resource.
Removing Resources with removed Blocks
Section titled “Removing Resources with removed Blocks”Sometimes you need to stop managing a resource in Terraform without actually destroying the underlying infrastructure. The removed block (Terraform 1.7+) lets you do exactly that — it tells Terraform to drop a resource from state while leaving the real infrastructure in place.
This follows the same config-driven pattern as moved and import blocks: the change is declared in HCL, visible in terraform plan, and reviewable in a pull request before anyone runs apply.
Basic Syntax
Section titled “Basic Syntax”To remove a resource from state without destroying it, add a removed block with a lifecycle block that sets destroy to false:
removed { from = aws_instance.old
lifecycle { destroy = false }}When you run terraform plan, Terraform reports that it will forget aws_instance.old — the EC2 instance continues to run, but Terraform no longer tracks it.
Difference from terraform state rm
Section titled “Difference from terraform state rm”You can achieve a similar result with the CLI command terraform state rm, but the removed block has several advantages:
- Declarative — the intent is captured in code, not in a one-off CLI session.
- Plannable — you see the effect in
terraform planbefore anything changes. - Reviewable — team members can review the change in a pull request, just like any other configuration update.
- Safe in automation — CI/CD pipelines run
planandapply; there is no need to inject an extrastate rmstep.
For any workflow that involves code review or automated pipelines, the removed block is the safer choice.
Removing a Module from State
Section titled “Removing a Module from State”You can also remove an entire module and all of its managed resources in one go:
removed { from = module.legacy_vpc
lifecycle { destroy = false }}Terraform drops every resource inside module.legacy_vpc from state while leaving the real infrastructure untouched. This is especially useful when you are migrating ownership of infrastructure to a different Terraform configuration or team.
One-Time Operation
Section titled “One-Time Operation”Like moved blocks, removed blocks are one-time operations. After a successful terraform apply, the resource is no longer in state and the removed block has no further effect. You should remove it from your configuration to keep the codebase clean.
For full details, see the removed block documentation on the official Terraform site.
Preconditions and Postconditions
Section titled “Preconditions and Postconditions”Preconditions and postconditions are custom validation rules that live inside a resource’s lifecycle block. They let you catch problems early — before Terraform provisions a misconfigured resource, or immediately after creation when the result does not match expectations.
- A precondition runs before Terraform creates or updates the resource. If the check fails, Terraform stops with an error and never touches the infrastructure.
- A postcondition runs after Terraform creates or updates the resource. If the check fails, Terraform marks the apply as an error so you know the result is not what you expected.
Both use the same syntax: a condition expression that must evaluate to true, and an error_message that Terraform displays when the condition fails.
Precondition Example
Section titled “Precondition Example”Suppose you want to guarantee that an AMI is owned by a trusted account before launching an instance. A precondition on the aws_instance resource can enforce this:
data "aws_ami" "app" { most_recent = true
filter { name = "name" values = ["my-app-*"] }
filter { name = "virtualization-type" values = ["hvm"] }
owners = ["self", "123456789012"]}
resource "aws_instance" "app" { ami = data.aws_ami.app.id instance_type = "t3.micro"
lifecycle { precondition { condition = data.aws_ami.app.owner_id == "123456789012" error_message = "The selected AMI is not owned by the trusted account (123456789012)." } }}If the AMI returned by the data source belongs to a different account, Terraform raises an error before creating the instance.
Postcondition Example
Section titled “Postcondition Example”After launching an instance, you might want to verify that it received a public IP address. A postcondition can check this:
resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro" associate_public_ip_address = true subnet_id = aws_subnet.public.id
lifecycle { postcondition { condition = self.public_ip != "" error_message = "The instance did not receive a public IP address." } }}Here, self refers to the resource after creation. If the instance ends up without a public IP — perhaps because the subnet configuration overrides the request — Terraform flags the apply as failed.
How Preconditions and Postconditions Differ from check Blocks and Input Validation
Section titled “How Preconditions and Postconditions Differ from check Blocks and Input Validation”Terraform offers several validation mechanisms, and each serves a different purpose:
- Preconditions and postconditions produce hard errors. When a condition fails, Terraform stops the apply and reports a failure. They are attached to a specific resource inside its
lifecycleblock. checkblocks produce warnings rather than errors. A failingcheckblock does not prevent Terraform from completing the apply — it alerts you that something may need attention. Check blocks are standalone and not tied to a single resource.- Input validation (
validationblocks on variables) runs at the variable level, before Terraform evaluates any resources. It ensures that the values passed into a configuration meet basic constraints (format, range, allowed values) but cannot reference resource attributes.
In short: use input validation to catch bad inputs early, preconditions and postconditions to enforce resource-level invariants, and check blocks for advisory checks that should not block a deployment.
For full details on custom conditions, see the custom conditions documentation on the official Terraform site.
Conclusion
Section titled “Conclusion”The depends_on meta-argument lets you define explicit ordering between resources when Terraform cannot infer the dependency automatically. Lifecycle blocks — create_before_destroy, prevent_destroy, ignore_changes, and replace_triggered_by — give you fine-grained control over how resources are created, updated, and replaced. The terraform_data resource provides a built-in way to store values in state, host provisioners, and trigger replacements without requiring an external provider. The moved block lets you refactor resource addresses without destroying and recreating infrastructure, while the removed block lets you drop resources from state in a declarative, reviewable way. Preconditions and postconditions add resource-level validation to the lifecycle, catching misconfigurations before they reach production.
Together, these tools cover the full spectrum of resource relationship and lifecycle management in Terraform — from ordering and replacement, through refactoring and removal, to runtime validation.