Foundations & IaC Concepts
What Infrastructure as Code actually solves, declarative vs imperative thinking, where Terraform fits next to CloudFormation/Pulumi/CDK, and how Terraform's provider plugin architecture works under the hood.
- Basic comfort with a cloud console (AWS, Azure, or GCP)
- Comfortable running commands in a terminal
- Explain what problem Infrastructure as Code solves and why it matters
- Distinguish declarative from imperative infrastructure tooling
- Place Terraform correctly against CloudFormation, ARM/Bicep, Pulumi, and CDK
- Name Terraform's four core building blocks: providers, resources, data sources, state
- Install and manage Terraform versions with tfenv/asdf
- Explain how Terraform's provider plugin architecture actually fetches and runs providers
Foundations & IaC Concepts
1. Why does this exist?
Picture a Friday afternoon: you need a new environment for a demo on Monday. You open the AWS console, click through eight screens — VPC, subnets, route tables, an EC2 instance, a security group, an IAM role — and by 6pm it’s running. Monday’s demo goes well.
Three months later, someone asks you to recreate the exact same setup in a second region for disaster recovery. You open the console again and… you don’t remember the subnet CIDR you picked, whether the security group allowed port 22 from a specific IP or from anywhere, or which of the four IAM policies you attached actually mattered. Nothing you clicked left a record anyone can read, diff, or replay.
This is the problem Infrastructure as Code (IaC) exists to solve: turn the click-path into a file. Once infrastructure is a file, it can be reviewed like any other code change, run the same way twice, checked into Git, and reasoned about by someone who wasn’t in the room when it was first built. Terraform is one tool in this space — arguably the most widely adopted one — built around a simple idea: you describe the infrastructure you want, and a program reconciles reality to match it.
2. Concept
Declarative vs Imperative
| Declarative | Imperative | |
|---|---|---|
| You specify | The desired end state | The exact sequence of steps |
| Tool’s job | Figure out how to get from current state to desired state | Execute your steps, in order, no interpretation |
| Example | Terraform, CloudFormation, Kubernetes manifests | Bash scripts calling the AWS CLI, Ansible playbooks (mostly) |
| Re-running it | Safe — idempotent, converges to the same state | Depends entirely on whether you wrote it to be safe to re-run |
| Failure mid-way | Tool knows what’s left to reconcile | You often have to figure out what already ran |
Advantage of declarative: you describe the destination, not the route — the tool absorbs the complexity of “what changed since last time.” Disadvantage: less flexibility for genuinely conditional, step-by-step logic (though Terraform’s expression language has grown enough — count, for_each, conditionals — to cover most real cases, covered in Chapter 5).
Terraform vs Other IaC Tools
| Tool | Language | Scope | State | Notes |
|---|---|---|---|---|
| Terraform | HCL (declarative DSL) | Multi-cloud, ~3,000+ providers | Explicit file (local or remote) | Widest provider ecosystem; cloud-agnostic core |
| CloudFormation | YAML/JSON | AWS only | Managed by AWS, invisible to you | No separate state file to lose, but locked to AWS |
| ARM / Bicep | JSON (ARM) or Bicep DSL | Azure only | Managed by Azure | Bicep is a cleaner DSL that compiles to ARM JSON |
| Pulumi | TypeScript, Python, Go, C#, Java | Multi-cloud | Pulumi state (or self-managed backend) | Real programming language — loops, functions, classes, no separate templating layer |
| AWS CDK | TypeScript, Python, Java, C# | AWS (CDK for Terraform extends this) | Synthesizes to CloudFormation | Same “real language” pitch as Pulumi, but synthesizes down to CloudFormation, inheriting its constraints |
When Terraform wins: you’re multi-cloud or multi-service (Kubernetes, Datadog, GitHub, Cloudflare all have Terraform providers), and you want one consistent workflow across all of them. When it doesn’t: a single-cloud AWS shop that wants zero state-file management overhead might reasonably prefer CloudFormation; a team that wants infrastructure defined with the same language and test frameworks as their application code might prefer Pulumi or CDK.
Four Core Concepts
| Concept | What It Is |
|---|---|
| Provider | A plugin that knows how to talk to a specific API (AWS, Azure, Kubernetes, GitHub…) and translates HCL resource blocks into that API’s calls |
| Resource | A single infrastructure object you want Terraform to create and manage — an EC2 instance, an S3 bucket, a DNS record |
| Data source | A read-only lookup of something that already exists — Terraform reads it, but doesn’t manage its lifecycle |
| State | Terraform’s record of which resource block maps to which real-world object, and what its last-known attributes were |
These four ideas are the entire mental model — everything else in this course (modules, workspaces, backends, provisioners) is built on top of them.
3. Architecture Diagram
flowchart TB
subgraph Config["Your Configuration"]
RB[Resource Blocks]
DS[Data Sources]
end
Core[Terraform Core]
subgraph Plugins["Provider Plugins (separate binaries)"]
P1[aws provider]
P2[azurerm provider]
P3[kubernetes provider]
end
State[(State File)]
API1[AWS API]
API2[Azure API]
API3[Kubernetes API]
RB --> Core
DS --> Core
Core <-->|RPC over stdio| P1
Core <-->|RPC over stdio| P2
Core <-->|RPC over stdio| P3
Core <--> State
P1 --> API1
P2 --> API2
P3 --> API3
Terraform Core itself knows nothing about AWS, Azure, or Kubernetes — it only knows how to read HCL, build a dependency graph, and talk to plugins over a well-defined RPC protocol. Every cloud-specific behavior lives in the provider binary, not in Core.
4. Visual Explanation
Provider plugin resolution, step by step:
1. You write: required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
2. terraform init reads this block
3. Terraform checks .terraform.lock.hcl for a pinned checksum (if it exists)
4. If missing, Terraform queries the Terraform Registry (registry.terraform.io)
5. Registry resolves "hashicorp/aws" → download URL for your OS/architecture
6. Provider binary is downloaded into .terraform/providers/
7. Lock file is written/updated with the exact version + checksum
8. On every subsequent init, the lock file pins the exact version — no silent upgrades
Hint: why does the lock file matter so much?
Without .terraform.lock.hcl checked into version control, two engineers running terraform init on different days could silently resolve to two different patch versions of the same provider — and provider patch releases occasionally change behavior. The lock file makes provider versions as reproducible as a package-lock.json or Gemfile.lock.
Comparison: Where State Lives (preview of Chapter 6)
| Approach | Who Manages It |
|---|---|
| CloudFormation | AWS, invisible to you |
| Terraform (local) | A .tfstate file on your machine |
| Terraform (remote) | S3+DynamoDB, Azure Storage, GCS, Terraform Cloud — see Chapter 6 |
5. Example
The smallest real Terraform configuration — one provider, one resource:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "demo" {
bucket = "aetoso-demo-bucket-2026"
}
Line by line:
terraform { required_providers { ... } }— tells Terraform which provider plugins this configuration needs, and which version constraint to respect (~> 5.0means “any 5.x, but not 6.0”).provider "aws" { region = "us-east-1" }— configures the AWS provider plugin: which region its API calls should target.resource "aws_s3_bucket" "demo" { ... }— declares one resource.aws_s3_bucketis the resource type (defined by the provider);demois the local name you use to refer to it elsewhere in this configuration (e.g.,aws_s3_bucket.demo.arn).
6. Production Example
A real project is never one file. A typical small-to-mid production layout:
infra/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ └── backend.tf
├── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
├── versions.tf
└── README.md
environments/<env>/— one root module per environment, each with its own state and backend configuration (Chapter 10 covers why this beats a single shared environment).modules/— reusable building blocks (Chapter 9) thatenvironments/*/main.tfcalls with different inputs per environment.versions.tf— a single source of truth for provider version constraints, often symlinked or duplicated per environment.
7. Code Walkthrough — Installing and Managing Versions
Two popular version managers, both solving “pin an exact Terraform version per project”:
# tfenv (Terraform-specific version manager)
tfenv install 1.9.5 # install a specific version
tfenv use 1.9.5 # switch the active version
echo "1.9.5" > .terraform-version # pin it for this project — teammates' tfenv reads this automatically
# asdf (generic multi-language version manager, Terraform is one plugin)
asdf plugin add terraform
asdf install terraform 1.9.5
asdf local terraform 1.9.5 # writes .tool-versions in this directory
Either way, the pinned version file gets checked into Git. A new teammate clones the repo, runs tfenv install (reads .terraform-version automatically) or asdf install (reads .tool-versions), and now runs the exact same Terraform binary as everyone else — no “works on my machine” version drift.
8. Behind the Scenes
Terraform Core is deliberately small and cloud-agnostic. When you run terraform plan:
- Core parses your
.tffiles into an internal representation. - Core builds a dependency graph of every resource and data source (more on this in Chapter 8).
- For each node in the graph, Core needs to talk to a provider — so it launches the provider binary as a separate child process and communicates over an RPC protocol (gRPC, historically over stdio) — not a network call, a local process-to-process protocol.
- The provider translates each RPC request (“read the current state of this S3 bucket,” “here’s the planned attributes for this new EC2 instance”) into actual cloud API calls.
- Responses flow back through the same RPC channel, and Core assembles the final plan or applies the result to state.
This separation is why a single Terraform binary can support thousands of providers without becoming a multi-gigabyte monolith — each provider is a separately versioned, separately downloaded plugin, only fetched when your configuration actually references it.
9. Common Mistakes
- Wrong: Treating Terraform like a one-off script — running
applyfrom memory without checkingplanoutput first. Why it’s wrong: the plan is the only place you catch “this will destroy and recreate your production database” before it happens. Correct way: always read the plan’s+/-summary before typingyes. Best practice: in CI, require a human-reviewed plan output as a PR comment before any apply runs. - Wrong: Not pinning a Terraform or provider version at all (“latest is fine”). Why it’s wrong: “latest” today is a different binary than “latest” three months from now — your configuration’s behavior can silently change. Correct way: pin both
required_version(Terraform itself) andrequired_providers(each provider), and commit the lock file. - Wrong: Assuming Terraform is a full configuration-management tool like Ansible or Chef. Why it’s wrong: Terraform provisions infrastructure; it’s a poor fit for “install these 12 packages and restart this service” style in-instance configuration (covered more in Chapter 11’s provisioners discussion). Correct way: let Terraform hand off to cloud-init, Packer-built images, or a real config-management tool for in-instance state.
10. Production Tips
- 🚀 Best Practice: Pin
required_versionin every root module (required_version = ">= 1.9.0, < 2.0.0") so CI fails loudly instead of behaving unpredictably on a mismatched binary. - 💡 Tip: Commit
.terraform.lock.hclto version control — it’s not a build artifact, it’s the provider equivalent of a package-lock file. - ⚠️ Warning: Don’t let engineers install Terraform ad hoc via
brew install terraformwith no version pin — that’s how six people end up on six different minor versions. - 🚀 Best Practice: Decide your IaC tool once, deliberately, based on which clouds/services you actually use — don’t default to Terraform out of habit if you’re a single-cloud shop that would rather avoid state-file management entirely.
11. Debugging
| Symptom | Likely Cause | Fix |
|---|---|---|
Error: Failed to query available provider packages |
No internet access, or a private registry misconfigured | Check network/proxy settings; verify registry URL in provider source |
Error: Inconsistent dependency lock file |
.terraform.lock.hcl doesn’t match required_providers after someone changed a version constraint |
Run terraform init -upgrade to regenerate the lock file, then review and commit the diff |
Different plan output on two machines for the same commit |
Different Terraform or provider versions installed locally | Check terraform version; adopt tfenv/asdf with a checked-in version file |
command not found: terraform after tfenv use |
Shell PATH doesn’t include tfenv’s shim directory | Ensure ~/.tfenv/bin is in PATH, re-source your shell profile |
12. Interview Questions
Q — In your own words, what problem does Infrastructure as Code solve that a well-documented runbook doesn’t? A runbook still requires a human to read it and execute steps correctly and consistently every time — it can go stale, be skipped under pressure, or be interpreted differently by two engineers. IaC turns the same intent into an artifact a program executes identically every time, which can be diffed, code-reviewed, and version-controlled the same way application code is.
Q — Why does Terraform run providers as separate processes instead of linking them directly into the Core binary? It decouples release cycles — a provider can ship a new version without waiting for a Terraform Core release, and Core doesn’t balloon in size supporting thousands of providers most users never touch. The RPC boundary also means a crashing or misbehaving provider can’t directly corrupt Core’s process state.
Q — When would you deliberately choose CloudFormation or Pulumi over Terraform? CloudFormation: an AWS-only shop that wants to avoid separate state-file management entirely, since AWS manages CloudFormation’s state internally. Pulumi/CDK: a team that wants infrastructure defined in the same general-purpose language (with real loops, functions, and unit tests) as their application code, and is comfortable with the added complexity of a full language runtime instead of a declarative DSL.
13. Hands-on Lab
Goal: install Terraform via tfenv, pin a version, and run your first plan against a local-only resource (no cloud account needed).
- Install tfenv (
brew install tfenvon macOS, or clone from GitHub on Linux). - Run
tfenv install 1.9.5 && tfenv use 1.9.5. - Create a new directory with a
main.tfcontaining only:terraform { required_version = ">= 1.9.0" } resource "local_file" "hello" { filename = "${path.module}/hello.txt" content = "Hello from Terraform!" } - Run
terraform init, thenterraform plan.
Expected output: init downloads the hashicorp/local provider; plan shows 1 to add, 0 to change, 0 to destroy for local_file.hello.
Hint
If init fails with a provider resolution error, double check you didn't add a required_providers block referencing a source that doesn't exist — Terraform will infer hashicorp/local automatically from the resource type prefix in recent versions, but being explicit never hurts.
Solution
Run terraform apply after the plan looks correct — you should see hello.txt created in your working directory containing "Hello from Terraform!". Run terraform destroy afterward to clean it up and observe the reverse plan.
14. Mini Project
Provision your first cloud resource end to end. Using the AWS free tier (or a sandbox account):
- Write a
main.tfwith theawsprovider and oneaws_s3_bucketresource (globally unique name). - Add an
outputblock that prints the bucket’s ARN after apply. - Run
init → plan → apply, confirm the bucket exists in the AWS console. - Run
terraform destroyand confirm it’s gone.
This mini project deliberately has no state backend, no modules, and no variables yet — those arrive in Chapters 4, 6, and 9. The goal here is just: write HCL, watch Terraform Core hand off to the AWS provider, and see a real resource appear and disappear.
16. Summary
- IaC turns infrastructure changes into a reviewable, repeatable artifact instead of an undocumented manual action.
- Declarative tools (Terraform, CloudFormation) describe desired end state; imperative tools describe exact steps.
- Terraform’s edge is breadth — one workflow across thousands of providers, versus single-cloud tools (CloudFormation, ARM/Bicep) or “real language” tools (Pulumi, CDK).
- Four core concepts — providers, resources, data sources, state — are the entire mental model everything else builds on.
- Providers are separate plugin binaries Terraform Core talks to over RPC; this is why the provider ecosystem can scale to thousands without bloating Core.
- Pin your Terraform version (tfenv/asdf +
required_version) and commit.terraform.lock.hcl— version drift between engineers or environments is a self-inflicted, entirely avoidable failure mode.
17. Cheat Sheet
tfenv install <version> # install a specific Terraform version
tfenv use <version> # activate it
echo "<version>" > .terraform-version # pin per-project
terraform init # download providers, initialize backend
terraform plan # preview changes
terraform apply # apply changes
terraform destroy # tear down everything this config manages
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
18. Related Topics
Next: Chapter 2 covers the Terraform CLI and the full init → plan → apply → destroy workflow in depth, including state, import, taint, and workspace subcommands. Chapter 6 returns to state in much greater depth (remote backends, locking, drift). Chapter 9 covers modules, which is where the modules/ folder from this chapter’s production layout gets built out for real.