Skip to content

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.


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.

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_resource shown here is used for illustration. In modern Terraform (1.4+), the built-in terraform_data resource is the recommended replacement, as it doesn’t require an external provider.

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.

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_by only 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 a terraform_data resource as shown above.

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.

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 to input, 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.

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 output and react when it changes.
  • Host provisioners — attach local-exec or remote-exec provisioners to a resource that has no real infrastructure behind it.
  • Trigger replacements — combine triggers_replace with replace_triggered_by on another resource to force recreation when an external value changes, as shown in the replace_triggered_by section 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.

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.

Common scenarios where moved blocks help:

  • Renaming a resource (e.g., aws_instance.webaws_instance.app)
  • Moving a resource into or out of a module
  • Switching a resource from count to for_each
  • Reorganising your configuration without downtime

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.

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.

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.

  • moved blocks are declarative — they only need to be present for one apply. After the state is updated, you can remove them.
  • You can chain moved blocks if a resource has been renamed multiple times, but keeping old moved blocks around is optional. They are harmless but add clutter over time.
  • moved blocks work across modules. The from and to addresses can reference different module paths.
  • If the from address does not exist in state, Terraform silently ignores the moved block. This makes it safe to leave them in shared modules where not every consumer has the old resource.

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.

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.

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 plan before 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 plan and apply; there is no need to inject an extra state rm step.

For any workflow that involves code review or automated pipelines, the removed block is the safer choice.

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.

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 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.

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.

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 lifecycle block.
  • check blocks produce warnings rather than errors. A failing check block 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 (validation blocks 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.

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.