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_idThe 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"
}
initinstalls modules, not just providers. Add a newmoduleblock and you must re-runtflocal 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.