Learning PathsTerraform
TerraformChapter 1·BEGINNER·14 min read

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.

Prerequisites
  • Basic comfort with a cloud console (AWS, Azure, or GCP)
  • Comfortable running commands in a terminal
You'll learn to
  • 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.0 means “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_bucket is the resource type (defined by the provider); demo is 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) that environments/*/main.tf calls 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:

  1. Core parses your .tf files into an internal representation.
  2. Core builds a dependency graph of every resource and data source (more on this in Chapter 8).
  3. 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.
  4. 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.
  5. 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 apply from memory without checking plan output 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 typing yes. 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) and required_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_version in 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.hcl to 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 terraform with 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).

  1. Install tfenv (brew install tfenv on macOS, or clone from GitHub on Linux).
  2. Run tfenv install 1.9.5 && tfenv use 1.9.5.
  3. Create a new directory with a main.tf containing only:
    terraform {
      required_version = ">= 1.9.0"
    }
    
    resource "local_file" "hello" {
      filename = "${path.module}/hello.txt"
      content  = "Hello from Terraform!"
    }
  4. Run terraform init, then terraform 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):

  1. Write a main.tf with the aws provider and one aws_s3_bucket resource (globally unique name).
  2. Add an output block that prints the bucket’s ARN after apply.
  3. Run init → plan → apply, confirm the bucket exists in the AWS console.
  4. Run terraform destroy and 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" }
  }
}

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.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
Terraform Workflow & CLI →