Infrastructure as Code
A data platform isn't just code — it's the buckets, warehouses, roles, and clusters that code runs on. Click those into existence in a web console and you've built a snowflake nobody can reproduce. This lab teaches you to describe your infrastructure in files, version it like any other code, and let a tool make reality match. Type the commands; the discipline is in the doing.
Install Terraform (the most widely used IaC tool). On macOS: brew install terraform. On Linux/WSL2: follow the official apt instructions at developer.hashicorp.com/terraform/install, or download the single binary and put it on your PATH. Verify with terraform version — you want v1.5 or newer. No paid cloud account is needed for this chapter. We use Terraform's built-in local and random providers, so everything runs offline on your laptop. Make a fresh folder to work in: mkdir -p ~/iac-practice && cd ~/iac-practice.
Why click-ops doesn't scale
"Click-ops" is the practice of building infrastructure by hand — logging into a cloud console and clicking buttons to create a storage bucket, a database, a user role. It works for the first ten minutes of a project, then quietly becomes a liability. Here's what goes wrong:
| Problem with clicking | What it costs you |
|---|---|
| Not reproducible | You can't recreate the exact setup in a new region or account. The "staging" environment never quite matches production. |
| No history | Who changed that bucket's permissions, and when, and why? Nobody knows. There's no git log for clicks. |
| No review | One person changes a firewall rule alone at 5pm. No teammate saw it coming. No pull request, no second pair of eyes. |
| Drift over time | Reality slowly diverges from what anyone thinks exists. Settings get tweaked by hand and forgotten. |
| Easy to forget what exists | Orphaned resources rack up cost and security risk because nobody remembers creating them. |
Infrastructure as Code (IaC) fixes every one of these by treating infrastructure exactly like application code: you write it in files, commit it to git, review changes in pull requests, and apply them through a repeatable command. The contrast is the same one you met between clicking and scripting in the shell — just applied to servers and buckets instead of files.
Course 5's first principle was "everything is code — if it's not in git, it doesn't exist." IaC extends that all the way down to the machines. Your transformations are code, your tests are code, and now the warehouse and buckets they run on are code too. Nothing about your platform lives only in someone's memory or a console.
Terraform basics
Terraform's core idea is the declarative model. You don't write step-by-step instructions ("create this, then if it exists update it, then…"). Instead you describe the desired end state — "I want a bucket named data-raw in this region with versioning on" — and Terraform figures out the steps to make reality match. If the bucket doesn't exist, it creates it; if it exists but versioning is off, it turns it on; if it already matches, it does nothing. You declare the destination, not the route.
Three vocabulary words carry most of the weight:
| Term | What it is |
|---|---|
| Provider | A plugin that teaches Terraform how to talk to one platform — aws, google, azurerm, snowflake, or the simple built-ins local and random. It translates your HCL into API calls. |
| Resource | A single piece of infrastructure you want to exist — a bucket, a database, a file, an IAM role. Each resource block describes one. |
| HCL | HashiCorp Configuration Language — the readable, block-based syntax you write Terraform in. Files end in .tf. |
A resource block follows a fixed shape: the keyword resource, the resource type (which provider it belongs to and what it makes), a name you choose to refer to it, and a body of settings.
# resource "<TYPE>" "<LOCAL NAME>" { ... }
resource "local_file" "greeting" {
filename = "${path.module}/hello.txt"
content = "Provisioned by Terraform, not by clicking.\n"
}Read it as a sentence: "I want a local_file resource — I'll call it greeting — whose path is hello.txt in this folder and whose content is that line." That's the whole declarative model in four lines. A real AWS bucket looks structurally identical, just with a different type and richer settings.
State — Terraform's memory
Here's the question that makes IaC tricky: if you remove a resource block from your file and re-run Terraform, how does it know to delete the bucket you previously created? Your files only describe what you want now. To bridge the gap, Terraform keeps a state file — by default terraform.tfstate — recording everything it has created and the current values of every setting. On each run it compares three things: your .tf files (desired state), the state file (what it last created), and the real platform (what's actually there). The difference becomes its plan.
The state file is Terraform's source of truth about reality. Edit it by hand and you'll desynchronize it from the real world — Terraform may then try to recreate things that exist, or fail to delete things it forgot. It can also contain secrets (database passwords, keys) in plaintext, so never commit terraform.tfstate to git. Add it to .gitignore. If you must change state, use the dedicated commands (terraform state mv, terraform import) — never a text editor.
On your laptop, state lives in a local file and that's fine. On a team, a local file is a disaster: two people running apply from their own machines would each have a different idea of reality. The fix is remote state — storing the state file in a shared backend (an S3 bucket, Terraform Cloud, a GCS bucket) with locking so only one apply runs at a time. It's the same instinct as putting code in a shared git remote instead of emailing zip files around: one authoritative copy everyone works against.
The write → plan → apply workflow
Almost everything you do with Terraform follows one rhythm. Learn it once and it's the same whether you're making a single file or a hundred-resource platform:
terraform init— reads your config, downloads the providers it needs into a.terraform/folder. Run it once per project, and again whenever you add a provider.terraform plan— the safety step, and the one that separates IaC from clicking. It computes the diff between desired state, recorded state, and reality, then prints exactly what it would create, change, or destroy — without touching anything. This is what reviewers read in a pull request.terraform apply— re-runs the plan, shows it again, and waits for you to typeyesbefore making real changes. Then it updates the state file to match.
Always read the plan before you apply. A line that says 1 to add when you expected zero, or — alarmingly — 2 to destroy on a routine change, is your warning siren. Plans use a simple shorthand: + create, ~ change in place, - destroy, and -/+ destroy and recreate. Glance at the summary line every single time; it's the cheapest insurance you'll ever buy.
Hands-on: provision a resource offline
Let's run the full loop with zero cloud account and zero cost. In your ~/iac-practice folder, create a file called main.tf. You can use your editor, or paste this whole heredoc into the terminal:
cd ~/iac-practice
cat > main.tf <<'EOF'
terraform {
required_providers {
local = { source = "hashicorp/local" }
random = { source = "hashicorp/random" }
}
}
# A random suffix, like cloud resources often need for unique names
resource "random_pet" "name" {
length = 2
}
# A real file on disk, named using that random value
resource "local_file" "manifest" {
filename = "${path.module}/platform-${random_pet.name.id}.txt"
content = "data platform manifest\nowner: you\n"
}
EOFNotice how local_file references random_pet.name.id. Terraform reads that dependency and knows it must create the random name first. You never order the steps yourself — the declarative model works it out. Now run the workflow:
terraform init # download the local + random providers
terraform plan # preview — should show 2 resources to add, nothing destroyedinit prints "Terraform has been successfully initialized!" after fetching the two providers. plan ends with something like:
Terraform will perform the following actions:
# local_file.manifest will be created
+ resource "local_file" "manifest" {
+ content = "data platform manifest\nowner: you\n"
+ filename = "./platform-<known after apply>.txt"
+ id = (known after apply)
}
# random_pet.name will be created
+ resource "random_pet" "name" {
+ id = (known after apply)
+ length = 2
}
Plan: 2 to add, 0 to change, 0 to destroy.The + markers mean "create," and Plan: 2 to add, 0 to change, 0 to destroy confirms nothing existing gets touched. Some values say (known after apply) because the random name doesn't exist yet.
The plan looked safe, so apply it:
terraform apply # type 'yes' when prompted
ls # see the file it created
cat platform-*.txt # read its contentsAfter you type yes, Terraform prints Apply complete! Resources: 2 added, 0 changed, 0 destroyed. ls now shows a real file like platform-clever-mongoose.txt (your random name will differ) and a new terraform.tfstate — that's Terraform recording what it built. cat shows the two lines of content. You just provisioned infrastructure from a declaration, exactly as you would a cloud bucket — only this one cost nothing.
To prove the loop closes, run terraform plan again: it now reports No changes. Your infrastructure matches the configuration. — because state already matches your files. That "do nothing when already correct" behavior is the whole point. When you're done experimenting, terraform destroy (and yes) tears it all back down cleanly, deleting the file it made.
Modules & environments
Two patterns turn these basics into something a real platform team uses. First, modules: a module is a reusable bundle of resources you can call with different inputs — the function-and-parameters idea applied to infrastructure. Instead of copy-pasting fifty lines to create each bucket, you write a storage-bucket module once and call it with a name each time. Define it once, use it everywhere; fix a bug in one place.
Second, environments. You rarely run just one copy of your platform. A typical setup has dev (cheap, safe to break), staging (a rehearsal that mirrors prod), and prod (the real thing). Because the infrastructure is code, each environment is the same modules fed different variables — a smaller warehouse in dev, a bigger one in prod — and each keeps its own state file. The console-clicking version of this never stays in sync; the code version is identical by construction.
For a data platform specifically, here's the kind of thing IaC manages — all as version-controlled code rather than console clicks:
| Infrastructure | What it is, on a data platform |
|---|---|
| Object storage (buckets) | The raw and staging zones of your lake — S3 / GCS buckets with lifecycle and versioning rules. |
| Warehouses / databases | Snowflake, BigQuery, or Redshift compute and datasets your dbt models run against. |
| IAM roles & permissions | Who and what can read which bucket or table — least-privilege access, defined and reviewable. |
| Compute clusters | The Kubernetes cluster, Spark pool, or VMs your orchestrator and jobs run on. |
Every one of these has a Terraform provider. The leap you made with a local file is the same leap, structurally, for all of them.
✓ Check yourself
- Can you name three concrete problems with building infrastructure by clicking in a console?
- What's the difference between the declarative model and step-by-step instructions?
- What is the state file for, and why must you never hand-edit it or commit it?
- Which command previews changes without making them — and why is it the safety step?
Exercise — Add a second resource and preview it before applying
Add a second local_file resource to your main.tf (for example, a README for your fake platform), then run terraform plan to preview the change before applying it. Confirm the plan shows exactly one resource to add and nothing destroyed.
resource "local_file" "readme" {
filename = "${path.module}/README.txt"
content = "This platform's infrastructure is defined as code.\n"
}terraform plan
# Expect the summary line:
# Plan: 1 to add, 0 to change, 0 to destroy.
# The existing file + random_pet are untouched — only the new README is "+ to add".
terraform apply # type 'yes', then:
cat README.txtThe key takeaway: because the other resources already match state, the plan touches only what genuinely changed — 1 to add, not a full rebuild. That precise, reviewable diff before anything happens is exactly what clicking can never give you.
Next
Your infrastructure is now code — versioned, reviewed, repeatable. The last piece is knowing it stays healthy once it's running. → Observability & Monitoring