Lab 2 · Dependencies & Modules
#

You declare relationships; Terraform computes the order — Terraform Inside Out
#

David Chan, Claude Opus 4.8 AI-Symbiosis Research · July 2026


Lab 1 had one resource. Real infrastructure is many, and they depend on each other — an object needs its bucket, a subnet needs its VPC, an instance needs its network. Here is the idea that makes Terraform powerful, and it surprises people: you never tell Terraform what order to build things in. You declare what should exist and how the pieces reference each other, and Terraform derives the order itself by building a dependency graph — a DAG (directed acyclic graph). Declarative what, never imperative when.

Part 1 — Dependencies
#

Two resources: a bucket, and an object that lives inside it.

resource "aws_s3_bucket" "data" {
  bucket = "lab2-data-bucket"
}

resource "aws_s3_object" "hello" {
  bucket  = aws_s3_bucket.data.id      # ← this reference is the whole lesson
  key     = "hello.txt"
  content = "hello from terraform"
}

The object references aws_s3_bucket.data.id. That single reference is the entire point of this section.

The order falls out of the reference
#

Which does Terraform create first? It has to be the bucket — Terraform literally cannot know the bucket’s id until the bucket exists, and the object needs that id. So the reference creates a dependency edge: object → depends on → bucket. You never wrote “bucket first” anywhere. apply proves the derived order:

$ tflocal apply
aws_s3_bucket.data: Creating...
aws_s3_bucket.data: Creation complete after 0s [id=lab2-data-bucket]
aws_s3_object.hello: Creating...
aws_s3_object.hello: Creation complete after 0s [id=lab2-data-bucket/hello.txt]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Bucket finished, then the object started. And you can see the graph Terraform built — it’ll print it:

$ tflocal graph
digraph G {
  ...
  "aws_s3_object.hello" -> "aws_s3_bucket.data";
}

That arrow is the dependency, derived purely from your reference. apply is a topological sort of this graph: build nodes with no dependencies first, then their dependents.

Parallel where it can, ordered where it must
#

Two facts the graph decides at once. When resources are independent, Terraform builds them concurrently (by default up to 10 at a time) — you’ll see it later when two modules build in parallel. When they’re dependent, it waits. The DAG governs both.

And destroy runs the graph in reverse:

$ tflocal destroy
aws_s3_object.hello: Destroying...
aws_s3_object.hello: Destruction complete after 0s
aws_s3_bucket.data: Destroying...
aws_s3_bucket.data: Destruction complete after 0s

The object (the dependent) is destroyed first, then the bucket — you can’t delete a bucket that still has an object in it. Build order is a topological sort; teardown is its reverse.

Implicit vs explicit
#

What we just used is an implicit dependency — created by a reference. When two resources must be ordered but don’t reference each other, you state it explicitly with depends_on = [...]. The rule: reference when you can, depends_on only when you must. A reference also passes data; depends_on only enforces order.

Part 2 — Modules
#

Now the pattern above is written inline. Imagine needing it three times, for three environments. Copy-paste is exactly what Infrastructure-as-Code exists to kill. The fix is a module: package the pattern once, with inputs and outputs, and call it wherever you need it.

The layout:

lab-2/
├── main.tf                        # provider + module calls
└── modules/
    └── bucket_with_file/
        ├── main.tf                # the bucket+object pattern, parameterized
        ├── variables.tf           # inputs:  bucket_name, content
        └── outputs.tf             # output:  bucket_id

The module is the blueprint — same resources as before, but parameterized:

# modules/bucket_with_file/main.tf
resource "aws_s3_bucket" "data" {
  bucket = var.bucket_name
}
resource "aws_s3_object" "hello" {
  bucket  = aws_s3_bucket.data.id
  key     = "hello.txt"
  content = var.content
}

And the root calls it twice, with different inputs:

# main.tf
module "alpha" {
  source      = "./modules/bucket_with_file"
  bucket_name = "lab2-alpha"
  content     = "hello from alpha"
}
module "beta" {
  source      = "./modules/bucket_with_file"
  bucket_name = "lab2-beta"
  content     = "hello from beta"
}

init installs modules, not just providers. Add a new module block and you must re-run tflocal init — its job is “download providers and install modules.” (Editing a module’s contents later does not need a re-init; only a new source does.)

Namespacing: why reuse doesn’t collide
#

plan shows four resources from one blueprint — and look at the addresses:

$ tflocal plan
  # module.alpha.aws_s3_bucket.data will be created
  # module.alpha.aws_s3_object.hello will be created
  # module.beta.aws_s3_bucket.data  will be created
  # module.beta.aws_s3_object.hello will be created

Plan: 4 to add, 0 to change, 0 to destroy.

Both instances contain a resource named data — yet no collision, because each call gets its own namespace: module.alpha.* vs module.beta.*. That is why you can reuse a module a hundred times. And the inputs flowed through: alpha’s object is "hello from alpha", beta’s is "hello from beta" — same code, different data. apply builds them, and because alpha and beta don’t depend on each other, the two buckets create in parallel:

module.alpha.aws_s3_bucket.data: Creating...
module.beta.aws_s3_bucket.data: Creating...      ← concurrent (independent branches)
...
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

The DRY payoff, proven
#

Here’s the whole reason modules exist. Edit the blueprint once — add a tag to the bucket:

resource "aws_s3_bucket" "data" {
  bucket = var.bucket_name
  tags = { managed_by = "terraform-inside-out" }   # one line, one place
}

plan (no re-init needed — we changed contents, not source):

  # module.alpha.aws_s3_bucket.data will be updated in-place
  ~ tags = { + "managed_by" = "terraform-inside-out" }
  # module.beta.aws_s3_bucket.data  will be updated in-place
  ~ tags = { + "managed_by" = "terraform-inside-out" }

Plan: 0 to add, 2 to change, 0 to destroy.

One edit, both instances updated — in-place (~), because a tag is mutable metadata (no rebuild). Without the module you’d hand-edit that tag in every copy, miss one, and grow drift. The module is a single source of truth: change once, every instance stays consistent.

Lab 2, in one page
#

DEPENDENCIES                                  MODULES
────────────                                  ───────
references build a DAG                         a module is a blueprint
apply   = topological sort (deps first)        calls  = instances, namespaced (module.alpha.*)
        = parallel where independent           inputs = variables, returns = outputs
destroy = the reverse sort                     edit blueprint once → all instances change (DRY)
implicit (reference) vs explicit (depends_on)  init installs modules (new source ⇒ re-init)

Two mechanisms, cleanly separate: the dependency graph decides order and parallelism at apply time; modules are code reuse at authoring time. You declare intent and relationships; Terraform computes the rest.


Next — Lab 3 · Surgery & Blast Radius: the advanced, don’t-break-prod lab. We import infrastructure that already exists into state, force-replace a resource, move things around with state mv, and run destroy with the targeting and guardrails that keep a teardown from becoming an outage.