Resources, Data Sources, Outputs, and Dependencies

Resources, data sources, outputs, and dependencies are the grammar Terraform uses to turn configuration into an ordered change plan. In this lesson, the practical outcome is simple: you should be able to look at a Terraform file and predict which objects Terraform will create, which existing objects it will read, which values it will publish after apply, and why one operation must happen before another. That skill matters before modules, workspaces, or remote backends because every larger Terraform design is still built from these four parts.

Purpose and Outcome

A Terraform resource block declares an object Terraform should manage, such as a file, bucket, network, role, or database parameter group. A data source block declares an object Terraform should read but not own. An output block exposes a value from the root module after planning or applying. Dependencies are graph edges that tell Terraform ordering: this expression cannot be known until that object is read or changed. By the end of this chapter, you will be able to design a small configuration that separates ownership from lookup, exposes only useful outputs, and uses implicit dependencies first while reserving explicit dependencies for cases where Terraform cannot infer the ordering.

How Terraform Builds the Graph

Terraform does not run a configuration top to bottom like a script. It parses all configuration files in the module, asks each provider for schemas, evaluates expressions as far as possible, refreshes state and readable remote objects when required, and builds a directed acyclic graph of operations. Each managed object receives an address such as local_file.message or aws_instance.web[0]. That address is the link between configuration and the state record describing the real object. If the address changes, Terraform treats that as a different binding unless you move state deliberately.

Dependencies usually come from references. If data.local_file.rendered_config.filename uses local_file.config.filename, Terraform knows the file resource must be planned before the data source can be read. During planning, some values are known immediately, such as string literals and variable defaults. Other values are unknown until apply, such as a generated identifier returned by a cloud API. Terraform carries those unknown values through expressions and shows them as values known after apply. The graph lets Terraform perform independent operations concurrently while still preserving required order.

Provider schemas are part of the mechanism. A provider marks attributes as required, optional, computed, sensitive, or force replacement. If a changed argument is marked as requiring replacement, Terraform plans a destroy-and-create or create-before-destroy action depending on lifecycle settings and provider capability. Resources are therefore not just text blocks; they are typed instructions interpreted through provider schemas and stored state.

Syntax Anatomy

A resource block has a type and a local name: resource "TYPE" "NAME". The type selects the provider implementation, and the name is Terraform-local. A data source has the same two-label shape but starts with data. You read a resource as TYPE.NAME.ATTRIBUTE and a data source as data.TYPE.NAME.ATTRIBUTE. Outputs use output "NAME" with a value expression and optional metadata such as description, sensitive, and depends_on.

The most important dependency syntax is no syntax at all: write expressions that reference the values you actually need. Use depends_on only when the dependency is behavioral rather than data-shaped. For example, a health check command may need a service to exist even if it does not consume a service attribute in its arguments. Overusing depends_on makes plans more serialized and harder to reason about, while underusing it for side effects can make Terraform run steps too early.

Example 1: One Managed Object and One Output

This first configuration manages a local file. The resource owns the file path and content. The output exposes the final filename without forcing a human to inspect state directly. The dependency between the output and the file is implicit because the output value references local_file.message.filename.

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
  }
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "local_file" "message" {
  filename = "${path.module}/build/${var.environment}-message.txt"
  content  = "environment=${var.environment}
managed_by=terraform
"
}

output "message_path" {
  value = local_file.message.filename
}

After terraform apply, the deterministic behavior is that Terraform creates a build/dev-message.txt file containing two lines: environment=dev and managed_by=terraform. The output named message_path prints the absolute or module-relative path Terraform calculated for that file. If you change environment to stage, Terraform plans to remove the old addressed file object and create a new file at the stage path because the managed object arguments changed.

Example 2: Reading a Managed Result with a Data Source

The second example adds a data source. It is intentionally small so the ownership distinction is clear: local_file.config manages the file, while data.local_file.rendered_config reads content from a path. The data source is not a second owner. It contributes read-only information to expressions elsewhere in the graph.

variable "environment" {
  type    = string
  default = "dev"
}

resource "local_file" "config" {
  filename = "${path.module}/build/app-${var.environment}.conf"
  content  = "port=8080
environment=${var.environment}
"
}

data "local_file" "rendered_config" {
  filename = local_file.config.filename
}

output "config_preview" {
  value = trimspace(data.local_file.rendered_config.content)
}

Terraform infers that the data source must wait for the resource because its filename argument references local_file.config.filename. The output config_preview returns the trimmed file content. Its expected value after apply is port=8080 followed by environment=dev on the next line. In cloud configurations, this same pattern is used to look up an existing image, subnet, certificate, or secret version without claiming ownership of it.

Example 3: Explicit Ordering for a Side Effect

The third example introduces an explicit dependency. The validation command does not produce a Terraform attribute that the output naturally needs, but we still want the output to be considered complete only after the validation has run. The null_resource uses triggers so a content change causes the validation action to be replaced and run again.

variable "environment" {
  type    = string
  default = "dev"
}

resource "local_file" "settings" {
  filename = "${path.module}/build/settings-${var.environment}.json"
  content = jsonencode({
    environment = var.environment
    feature     = "search"
  })
}

resource "null_resource" "validate_settings" {
  triggers = {
    settings_sha = sha256(local_file.settings.content)
  }

  provisioner "local-exec" {
    command = "test -s ${local_file.settings.filename}"
  }

  depends_on = [local_file.settings]
}

output "settings_file" {
  value       = local_file.settings.filename
  depends_on = [null_resource.validate_settings]
}

Here, the file content hash in triggers gives Terraform a stable reason to rerun validation when settings change. The depends_on inside null_resource.validate_settings is explicit documentation that the shell test must happen after the file exists. The output also has depends_on so automation that consumes settings_file does not treat the apply as successful before validation is complete. This is useful for side effects, but it should stay rare; providers with native resources are usually preferable to shell commands.

Design Choices and Trade-offs

Choose a resource when Terraform should create, update, and eventually destroy or forget an object. Choose a data source when another system owns the object and Terraform only needs facts about it. Mixing those responsibilities is a common source of drift: if Terraform manages a network but a separate process also changes it, plans become noisy and may undo manual changes. Conversely, using a data source for something Terraform should own hides lifecycle responsibility and makes teardown incomplete.

Outputs should be treated as module API, not as a debugging dump. A root output can feed humans, automation, or a remote state consumer. Expose stable identifiers, endpoints, and short diagnostic values. Mark secrets as sensitive = true, but remember that sensitivity mainly controls display; state still stores the value and must be protected. Avoid outputting large rendered policies, complete secret documents, or every provider attribute. That makes downstream modules depend on implementation details you may need to change.

Implicit dependencies keep the graph accurate and parallel. Explicit dependencies are valuable for hidden ordering, such as provisioners, eventual-consistency waits, or a provider operation that has a real-world prerequisite not visible in an expression. The trade-off is that explicit edges can hide the true data relationship and slow applies by preventing safe concurrency. If a value reference can express the relationship, prefer the value reference.

Failure Modes and Troubleshooting

Missing instance key. Symptom: Terraform reports that a resource with count or for_each must be accessed with an instance key. Cause: the configuration references aws_subnet.private.id even though the resource has multiple instances. Diagnose by inspecting the resource block for count or for_each and checking state addresses with terraform state list. Correct it by referencing a specific instance, such as aws_subnet.private[0].id, or by projecting a collection with a for expression.

Data source not found during plan. Symptom: planning fails before any create action because a lookup returns no matches. Cause: data sources read existing objects during planning when their arguments are known, so Terraform cannot continue if the object is absent or the filters are too broad. Diagnose by checking the exact filter values, provider region or account, and whether the target object is created by the same configuration. Correct it by managing the object as a resource, passing the identifier as a variable, or referencing the creating resource so Terraform can infer the order.

Cycle in dependency graph. Symptom: Terraform reports a cycle involving two or more addresses. Cause: object A needs an attribute from object B while B also needs an attribute from A, directly or through outputs and locals. Diagnose with the cycle message and simplify expressions until one direction of ownership is clear. Correct it by splitting the design into phases, using a data source only after the resource exists, or replacing one side of the reference with an input variable that breaks the loop.

Output is unknown or unexpectedly sensitive. Symptom: a plan displays known after apply or hides a value as sensitive. Cause: the output depends on a computed provider attribute or on an expression containing a sensitive value. Diagnose by tracing the output expression back to the attribute schema and input variables. Correct it by using a known input when possible, applying before consuming computed values, or exposing a nonsecret derivative such as a name or ARN instead of a secret payload.

Security, Performance, and Reliability

Terraform state can contain every resource attribute and output value, including values hidden in terminal output. Store state in a backend with access control, locking, encryption, and recovery. Data sources can also leak information into state if their attributes are referenced by resources or outputs. Marking an output sensitive is useful, but it is not a substitute for limiting who can read state.

Graph design affects performance and reliability. Fine-grained implicit dependencies let Terraform run independent operations in parallel. A broad module-level depends_on can force unrelated resources to wait and can make an apply look slow or stuck. On the reliability side, narrow outputs reduce downstream breakage because fewer consumers depend on internal details. Clear ownership between resources and data sources reduces accidental deletion and drift repair surprises.

Hands-on Lab: Inspect a Local Graph

Prerequisites: a local Terraform CLI installation, permission to create files in a temporary directory, and network access for initial provider download unless the providers are already cached. Create a new empty directory for the lab and place the following configuration in main.tf.

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
    null = {
      source  = "hashicorp/null"
      version = "~> 3.0"
    }
  }
}

variable "environment" {
  type    = string
  default = "dev"
  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage, or prod."
  }
}

resource "local_file" "manifest" {
  filename = "${path.module}/build/${var.environment}-manifest.txt"
  content  = "name=course-agent
environment=${var.environment}
"
}

data "local_file" "manifest" {
  filename = local_file.manifest.filename
}

resource "null_resource" "manifest_check" {
  triggers = {
    manifest_sha = sha256(data.local_file.manifest.content)
  }

  provisioner "local-exec" {
    command = "grep -q 'environment=${var.environment}' ${local_file.manifest.filename}"
  }

  depends_on = [local_file.manifest]
}

output "manifest_path" {
  value = local_file.manifest.filename
}

output "manifest_text" {
  value      = trimspace(data.local_file.manifest.content)
  depends_on = [null_resource.manifest_check]
}
  1. Run terraform init to install the local and null providers.
  2. Run terraform plan and inspect the action order. You should see the managed file, the data-source read after the filename is known, the validation resource, and two outputs.
  3. Run terraform apply and approve the plan. Verify that build/dev-manifest.txt exists and contains name=course-agent and environment=dev.
  4. Run terraform output manifest_text. The deterministic value is the two-line manifest text with surrounding whitespace removed.
  5. Run terraform plan -var='environment=test'. Verification should fail during input validation before Terraform changes the file.
  6. For cleanup, run terraform destroy and remove the lab directory. If the destroy fails because a file was manually deleted, rerun terraform plan to confirm state and configuration agree, then remove stale state only with a deliberate terraform state rm after verifying the real object is gone.

Assessment Exercises

  1. A module looks up a production VPC with a data source and also declares a resource meant to create the same VPC. Explain the ownership conflict and redesign the module boundary.
  2. You see depends_on = [module.network] on an entire application module. Identify one case where this is justified and one case where a direct value reference would be better.
  3. An output exposes a generated database password marked sensitive. Describe who can still read it and propose a safer output contract.
  4. A plan reports a cycle between a security group rule and an instance. Sketch a dependency direction that removes the cycle without weakening the intended access rule.
  5. Given a resource created with for_each, write the shape of an output that returns a map from each key to its managed object identifier.

Summary

Resources declare ownership, data sources read existing facts, outputs form the visible API, and dependencies determine order inside Terraform’s graph. The most maintainable configurations express dependencies through real value references, reserve depends_on for hidden side effects, keep outputs narrow, and protect state as sensitive infrastructure data. Mastering these mechanics in small examples makes larger Terraform systems easier to plan, review, troubleshoot, and change.