HashiCorp Terraform

Terraform Dependency Management: When Ordering Matters More Than You Think

Every Terraform user eventually has that moment.

You run terraform apply, expecting a clean deployment, and Terraform confidently starts building resources in an order that makes you pause and say, “Wait… why is it doing that first?”

Maybe an app service starts before its identity permissions are ready. Maybe a Kubernetes resource gets applied before the cluster add-ons are fully available. Maybe an Azure role assignment exists “on paper,” but the next resource still fails because the cloud control plane has not caught up yet. Terraform did what the graph told it to do, but the real world had other ideas.

Dependency management in Terraform is one of those topics that looks simple until it is not. Most of the time, Terraform’s dependency graph is excellent. It sees references between resources, builds a graph, and walks that graph in parallel as dependencies become available. But when dependencies are hidden, overly broad, circular, or forced through -target, you can accidentally turn a clean Infrastructure as Code workflow into a deployment puzzle.

The goal is not to micromanage Terraform. The goal is to give Terraform the right information so it can do its job well.

Terraform Does Not Run Top to Bottom

One of the first misconceptions to clear up is that Terraform does not execute resources in the order they appear in your .tffiles. Terraform configuration is declarative. You describe the desired end state, and Terraform builds a dependency graph to determine what can be created, updated, or destroyed safely.

That graph is built from several sources, including resource references, provider relationships, explicit depends_ondeclarations, resources already present in state, and destroy/create ordering when replacements are needed. Terraform then walks the graph, often in parallel, starting work on a node once its dependencies are satisfied. HashiCorp’s internals documentation describes this graph-based planning model directly, including how explicit dependencies become edges in the graph and how Terraform validates the graph for cycles.  

That is why this:

resource "azurerm_resource_group" "main" {
name = "rg-demo"
location = "eastus"
}
resource "azurerm_virtual_network" "main" {
name = "vnet-demo"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = ["10.0.0.0/16"]
}

does not need depends_on.

The virtual network references attributes from the resource group, so Terraform already knows the virtual network depends on the resource group. This is called an implicit dependency, and it should be your default approach.

The order of the blocks does not matter. The references matter.

Implicit Dependencies Should Be Your First Choice

Implicit dependencies are created when one resource references another resource’s attributes. This is the cleanest way to express ordering because it tells Terraform why the relationship exists.

For example:

resource "azurerm_subnet" "app" {
name = "snet-app"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}

Terraform can see that the subnet needs the virtual network name and resource group name. It does not need a sticky note from us saying, “Please create the network first.” The reference already says that.

This matters because implicit dependencies are precise. Terraform understands which specific value is being consumed. That allows Terraform to plan more accurately and avoid unnecessary waiting, excessive unknown values, or overly conservative changes.

A good Terraform configuration should read like connected infrastructure, not like a shell script pretending to be declarative code.

When depends_on Is Actually Useful

The depends_on meta-argument exists for a reason. There are legitimate cases where Terraform cannot infer a dependency from configuration alone.

HashiCorp’s documentation says depends_on is for hidden dependencies where a resource or module depends on another object’s behavior but does not access its data in arguments. It is supported in resource and module blocks, and its value must be a list of references known early enough for Terraform to build the graph.  

A classic example is when something must exist before another resource works, but no attribute reference exists between them.

resource "azurerm_role_assignment" "app_storage_access" {
scope = azurerm_storage_account.main.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_linux_web_app.app.identity[0].principal_id
}
resource "azurerm_app_service_source_control" "app" {
app_id = azurerm_linux_web_app.app.id
repo_url = "https://github.com/example/app"
branch = "main"
# The source control setup may require the app identity permissions
# to be assigned first, even though this resource does not directly
# consume the role assignment output.
depends_on = [
azurerm_role_assignment.app_storage_access
]
}

That is a reasonable use of depends_on. The dependency is behavioral, not data-driven.

But this is where discipline matters. depends_on should be used like a torque wrench, not a hammer. It is precise when used carefully, but it can damage the design when applied everywhere.

When depends_on Makes Things Worse

The common mistake is using depends_on to “make Terraform behave” without understanding what relationship is actually missing.

At the resource level, this can slow down deployments by creating unnecessary serialization. Terraform is good at parallelism. If you tell it everything depends on everything else, you force it to wait even when it does not need to.

At the module level, the problem can get bigger. A broad module-level dependency like this may look harmless:

module "network" {
source = "./modules/network"
}
module "app" {
source = "./modules/app"
depends_on = [
module.network
]
}

Sometimes this is necessary. Often it is not.

The better approach is to pass the specific values the app module needs:

module "network" {
source = "./modules/network"
}
module "app" {
source = "./modules/app"
subnet_id = module.network.app_subnet_id
}

Now Terraform understands the real dependency: the app needs the subnet ID. It does not need to assume the entire app module depends on every object inside the network module.

Google Cloud’s Terraform best practices documentation makes this point well: explicit dependencies convey less specific information than implicit dependencies, so Terraform may need to create more conservative plans. Their guidance is to use depends_on only as a last resort when the dependency is hidden and cannot be expressed implicitly.  

That is the key lesson. depends_on does not make Terraform smarter. It gives Terraform less detail but more restriction. Sometimes that is exactly what you need. Other times, it is the IaC version of putting traffic cones around the whole parking lot because one space is wet.

Why Terraform Plans Resources in an Unexpected Order

When Terraform plans something in an order you did not expect, the first question should not be, “How do I force the order?”

The better question is, “What does Terraform know?”

Terraform only understands dependencies that are represented in the graph. If two resources do not reference each other and no explicit dependency exists, Terraform may treat them as independent. That means they can be planned or applied in parallel.

Unexpected ordering usually comes from one of these situations:

CauseWhat It Looks LikeBetter Fix
Missing attribute referenceResource B logically needs Resource A, but does not reference itPass the actual attribute B needs
Hidden behavioral dependencyCloud API requires A before B, but no value connects themUse targeted depends_on with a comment
Overly broad module dependencyWhole module depends on another whole modulePass specific outputs instead
Provider eventual consistencyResource exists, but API propagation is not completeUse provider-supported wait options where available, or isolate the dependency carefully
Data source timing issueData source reads before the resource is readyPrefer direct references or restructure the flow
Circular designTwo resources each require the other’s final valueSplit creation into phases or redesign ownership

Terraform does not know your intent. It knows references, state, provider schemas, and graph edges.

That is both the beauty and the frustration of it.

How to Debug Terraform Dependency Ordering

When the order looks wrong, slow down and inspect the graph instead of sprinkling depends_on everywhere.

Start with the plan. A normal terraform plan often tells you which values are known, unknown, replaced, or read during apply. If Terraform says a value is “known after apply,” that can affect ordering and replacement behavior.

For a visual view, use terraform graph:

terraform graph > graph.dot

You can then render it with Graphviz:

dot -Tpng graph.dot -o graph.png

For larger environments, the raw graph can be noisy. That noise is not Terraform being dramatic; it is your infrastructure being honest. Providers, data sources, resources, modules, and destroy/create operations can all show up in ways that make the graph look more complex than expected.

A practical debugging workflow looks like this:

# 1. Format and validate first
terraform fmt
terraform validate
# 2. Review the execution plan
terraform plan
# 3. Generate the graph
terraform graph > graph.dot
# 4. Render it if Graphviz is installed
dot -Tsvg graph.dot -o graph.svg
# 5. For deeper troubleshooting, enable logging temporarily
TF_LOG=DEBUG terraform plan

Be careful with debug logs. They can be very noisy and may include sensitive details depending on providers and configuration. Use them when needed, but do not paste them into tickets, chats, or public forums without reviewing them first.

The most useful debugging question is this: Where is the missing edge in the graph?

If the edge should exist because Resource B uses Resource A, add a proper attribute reference. If the edge is truly behavioral and cannot be represented through data, add a narrow depends_on and document why.

The Circular Dependency Problem

Circular dependencies are where Terraform stops being polite and tells you the truth: the graph cannot be solved.

A circular dependency happens when Resource A needs Resource B before it can exist, while Resource B also needs Resource A before it can exist. Terraform validates the dependency graph and rejects cycles because there is no safe first step.  

A simplified example might look like this:

resource "example_service" "app" {
name = "app"
policy_id = example_policy.app.id
}
resource "example_policy" "app" {
name = "app-policy"
service_id = example_service.app.id
}

Logically, you may think, “The service needs the policy, and the policy needs the service.” Terraform hears, “Please put on your shoes before your socks, but also your socks before your shoes.”

The fix is rarely depends_on. In fact, depends_on usually makes a cycle more explicit, not more solvable.

Instead, you need to break the design into a sequence Terraform can represent.

How to Break Circular Dependencies

The best fix is to separate creation from attachment.

Many cloud resources have a pattern like this:

  1. Create the primary object.
  2. Create the secondary object that references the primary object.
  3. Attach or associate the secondary object back to the primary object.

In Terraform, that often means using a separate association resource when the provider supports one.

resource "example_service" "app" {
name = "app"
}
resource "example_policy" "app" {
name = "app-policy"
service_id = example_service.app.id
}
resource "example_service_policy_attachment" "app" {
service_id = example_service.app.id
policy_id = example_policy.app.id
}

That graph is solvable. The service is created first, the policy follows, and then the attachment connects them.

Another technique is to make one side optional during creation. Some APIs allow you to create a resource without a full configuration, then update it later after the dependent object exists. Terraform can often model that when the provider exposes separate resources or optional arguments.

If neither option works cleanly, it may be a sign that the infrastructure boundary is wrong. Sometimes the answer is to split responsibilities across modules, separate state files, or deployment phases. That is not failure. That is architecture telling you where the seams are.

Data Sources Can Create Ordering Surprises

Data sources are another common source of dependency confusion.

A data source reads something that already exists. If you use a data source to look up a resource that Terraform is also creating in the same configuration, you may create timing problems unless Terraform has a clear dependency path.

For example, this can be fragile:

resource "azurerm_resource_group" "main" {
name = "rg-demo"
location = "eastus"
}
data "azurerm_resource_group" "main" {
name = "rg-demo"
}

The better approach is to use the managed resource directly:

resource "azurerm_resource_group" "main" {
name = "rg-demo"
location = "eastus"
}
output "resource_group_id" {
value = azurerm_resource_group.main.id
}

Data sources are great when reading infrastructure managed elsewhere. They are less great when used as a detour to read something Terraform already knows about in the same configuration.

A good rule of thumb: if Terraform creates it in the same configuration, reference the resource directly. If something outside this configuration owns it, use a data source.

terraform apply -target: Useful Tool, Dangerous Habit

The -target option is one of the most misunderstood Terraform features.

It tells Terraform to focus on a specific resource or module and the dependencies needed for that target. That can be useful in exceptional situations: recovering from a failed deployment, bootstrapping a foundational resource, working around a provider issue, or carefully applying one part of a large configuration during an incident.

But -target is not a normal deployment strategy.

When you rely on terraform apply -target, you are asking Terraform to apply a partial view of the world. That means other changes in the configuration may be skipped, deferred, or left inconsistent until a full plan and apply are run.

A reasonable emergency use might look like this:

terraform apply -target=azurerm_resource_group.main

That can help create a foundational resource first. But after using -target, you should run a normal plan:

terraform plan

And then reconcile the full configuration:

terraform apply

Think of -target like the spare tire in your trunk. It is great when you need it. If you are still driving on it three weeks later, the spare tire is no longer the solution — it is now part of the problem.

The Risk of Building Workflows Around -target

The biggest risk with -target is that teams start using it to compensate for design problems.

If your README says:

terraform apply -target=module.network
terraform apply -target=module.identity
terraform apply -target=module.app

that is not a Terraform workflow. That is a manual orchestration script wearing a Terraform hat.

Sometimes staged deployment is legitimate. For example, you may intentionally separate platform, identity, networking, and application layers into different states with clear ownership boundaries. That can be a strong architecture.

But using -target repeatedly inside one state to force ordering is usually a smell. It means the dependency graph does not represent reality, or the configuration is trying to manage too many lifecycle concerns at once.

The better solution may be:

  • Add missing implicit dependencies through references.
  • Replace broad depends_on declarations with specific inputs and outputs.
  • Split circular relationships into separate attachment resources.
  • Separate foundational infrastructure into its own state.
  • Use provider-native resources that model associations explicitly.
  • Document the few explicit dependencies that truly remain.

The point is not purity. The point is repeatability.

Common Mistakes That Create Dependency Pain

Terraform dependency problems are rarely caused by Terraform being random. They are usually caused by configuration that hides intent.

One common mistake is using strings where references should be used:

# Common but fragile
resource_group_name = "rg-demo"

Instead, reference the resource:

# Better
resource_group_name = azurerm_resource_group.main.name

Another mistake is adding depends_on at the module level because it feels easier than exposing the right output. That may work today, but it can create conservative plans and surprising downstream changes later.

A third mistake is assuming that “created” means “ready.” Cloud platforms are distributed systems. A role assignment, DNS record, identity, or policy may be accepted by the API before every dependent service can use it. Terraform providers often account for this, but not always in every edge case. When eventual consistency appears, the answer is usually careful modeling, provider-supported wait behavior, or a narrowly scoped explicit dependency — not random sleep timers scattered through your configuration.

And yes, sometimes people still reach for null_resource with local-exec and a sleep command. We have all seen things. Some of us have written things. Let’s just agree to improve them when we find them.

Practical Guidance for Better Terraform Ordering

The healthiest Terraform dependency strategy is boring in the best possible way.

Use references first. Let Terraform infer dependencies from actual values. This creates a graph that reflects how your infrastructure is connected.

Use depends_on only when the dependency is real but hidden. When you do use it, keep it as narrow as possible and add a comment explaining why it exists.

Avoid broad module-level dependencies unless the entire module truly must wait for the other entire module. In most cases, passing specific outputs creates better plans and clearer intent.

Treat circular dependencies as a design problem, not an ordering problem. Break creation and association apart. Look for separate attachment resources. Reconsider module boundaries when needed.

Use terraform graph when behavior looks surprising. The graph is not always pretty, but it helps you see what Terraform sees.

Use -target sparingly. It is valuable for recovery, bootstrapping, and exceptional operations. It should not become your standard deployment workflow.

The Common Way vs. the Better Way

Here is the pattern I see often in real-world Terraform code:

module "database" {
source = "./modules/database"
}
module "app" {
source = "./modules/app"
depends_on = [
module.database
]
}

It works, until it does not. The app module may now be conservatively tied to everything in the database module, even if it only needs one connection string or hostname.

A better approach is more explicit about the actual contract:

module "database" {
source = "./modules/database"
}
module "app" {
source = "./modules/app"
database_host = module.database.host
database_name = module.database.name
}

Now the dependency is both technical and understandable. The app depends on the database host and name. That is useful to Terraform, and it is useful to the next human reading the code.

Good Terraform is not just about making the apply succeed. It is about making the configuration explain itself.

Final Thoughts

Terraform dependency management is not about forcing a sequence. It is about modeling reality well enough that Terraform can safely determine the sequence for you.

Implicit dependencies are the everyday tool. Explicit dependencies are the exception for hidden relationships. Circular dependencies are a design signal. And terraform apply -target is a recovery tool, not a lifestyle.

When Terraform appears to do things “out of order,” it is usually following the graph it was given. Our job is to make that graph honest.

That is where Infrastructure as Code becomes more than automation. It becomes communication — between developers, operators, cloud platforms, and the future teammate who will inherit the repository six months from now.

Key Takeaways

  • Terraform does not run resources top to bottom; it builds and walks a dependency graph.
  • Prefer implicit dependencies through direct attribute references.
  • Use depends_on only for real hidden dependencies that Terraform cannot infer.
  • Avoid broad module-level depends_on unless the whole module truly depends on the other module.
  • Debug ordering issues by inspecting the plan and graph before forcing order.
  • Break circular dependencies by separating creation from association.
  • Use terraform apply -target for exceptional cases, then reconcile with a full plan and apply.

How has your team handled Terraform dependency issues — careful graph design, staged states, the occasional depends_on, or a few battle scars involving -target?

Related Articles

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.