Skip to content

Terraform

The previous page argued that declarative IaC with a reconciler is the robust choice. Terraform is the tool that made that idea mainstream for provisioning cloud infrastructure. You write files describing the resources you want; Terraform figures out how to create, update, or destroy real resources to match. This page is the mechanical tour.

Terraform itself knows nothing about AWS, GCP, Cloudflare, or GitHub. All of that lives in providers — plugins that translate Terraform’s generic “create/read/update/delete a resource” model into a specific platform’s API. You declare the providers you need; Terraform downloads them.

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}

Because the engine is provider-agnostic, the same Terraform workflow — plan, apply, the dependency graph, state — works whether you’re provisioning EC2 instances, a Cloudflare DNS record, or a Datadog monitor. One mental model, many platforms.

The atom of Terraform is the resource — one piece of infrastructure (a VM, a subnet, a DNS record). You declare resources in HCL (HashiCorp Configuration Language), the declarative syntax you met in the last page: nouns and desired values, not steps.

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}

Read that as a fact about the world you want: there should be a t3.micro instance running this AMI, tagged web-server. The two strings after resource are the type (aws_instance, defined by the provider) and a local name (web, how you refer to it elsewhere in your code). Together they form the address aws_instance.web.

Real infrastructure has ordering constraints: a server must live inside a network that exists first; a DNS record must point at a server that exists first. Crucially, you don’t write that ordering. You express dependencies by referencing one resource’s attributes from another, and Terraform infers the order by building a dependency graph.

resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id # ← reference creates a dependency
cidr_block = "10.0.1.0/24"
}

Because aws_subnet.app reads aws_vpc.main.id, Terraform knows the VPC must be created first. It also knows that resources with no dependency between them can be created in parallel. This is the declarative payoff in action: you state relationships, the tool derives the procedure — exactly the “reasoning about steps” that the previous page said a reconciler takes off your hands.

You write references Terraform builds the graph
────────────────────── ──────────────────────────────
subnet needs vpc.id aws_vpc.main
instance needs subnet.id │
record needs instance.ip aws_subnet.app
aws_instance.web ──► aws_route53_record.www
(create in this order; independent ones in parallel)

Terraform’s two most important commands embody the safety story of this whole Part.

Terminal window
terraform init # download providers, set up the working dir
terraform plan # compute the diff between code and reality — changes NOTHING
terraform apply # execute that plan — creates/updates/destroys real resources

plan is a dry run. It compares your code against the real world (via state — see State & Drift) and prints exactly what it would do, with + for create, ~ for change, and - for destroy. Nothing happens yet. apply then executes that plan. This is the reviewable diff from Why IaC, now concrete: you read the plan, confirm it does what you expect, then let it touch production.

Hardcoding values makes code un-reusable. Variables are typed inputs; outputs are values a configuration exposes after it runs.

variable "instance_type" {
type = string
default = "t3.micro"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type # ← parameterized
}
output "public_ip" {
value = aws_instance.web.public_ip # ← exposed after apply
}

Variables are how the same code builds staging (instance_type = "t3.micro") and production (instance_type = "m5.large") — the reproducibility-with-one-diff idea from Why IaC. Outputs are how one configuration hands a value (a database endpoint, a server IP) to a human or to another configuration.

A module is a folder of .tf files you can call from elsewhere, with its own variables and outputs. It’s the function of Terraform: define a “standard web service” once, then instantiate it many times.

module "api" {
source = "./modules/web-service"
instance_type = "m5.large"
instance_count = 3
}

Modules turn copy-pasted infrastructure into a single reviewed, versioned definition. Fix a security default in the module and every caller inherits the fix — the same leverage Helm charts give Kubernetes manifests.

How does plan know what already exists in the real world? Through the state file — the subject of the next page.

Five questions to place Terraform rather than just run it:

  • Why does it exist? To make declarative provisioning mainstream: you write HCL describing resources, and a provider-agnostic engine creates, updates, or destroys real infrastructure to match.
  • What problem does it solve? Figuring out the order of API calls for interdependent infrastructure — you state relationships by reference, Terraform builds the dependency graph and derives the order, and plan shows the exact diff before anything changes.
  • What are the trade-offs? Providers let one workflow span AWS/GCP/Cloudflare/Datadog, but you take on a state file to manage, and a ~ change can silently force a destroy-then-create replace — fine for a web server, catastrophic for a database.
  • When should I avoid it? Configuring software inside a running box (that’s Ansible’s job), or trivial one-off resources where the init/state/plan ceremony outweighs the benefit.
  • What breaks if I remove it? Infrastructure reverts to console clicks or hand-ordered API scripts — no reviewable plan, no dependency graph, no deterministic engine, and no preview of the blast radius (recall the 2017 S3 typo).
  1. Terraform’s engine knows nothing about AWS or GCP. What component supplies that knowledge, and why is that separation valuable?
  2. In resource "aws_instance" "web", what are the two strings, and how do you reference this resource elsewhere?
  3. You never write the order in which resources are created. How does Terraform determine it, and what in your code drives that?
  4. What is the difference between terraform plan and terraform apply, and why is plan central to the safety argument of this Part?
  5. Explain the roles of variables, outputs, and modules. Which one lets the same code build both staging and production?
Show answers
  1. Providers supply that knowledge — plugins that translate Terraform’s generic create/read/update/delete model into a specific platform’s API. The separation is valuable because the engine stays provider-agnostic, so one workflow (plan, apply, graph, state) works across AWS, GCP, Cloudflare, Datadog, and more.
  2. The first string is the type (aws_instance, defined by the provider); the second is a local name (web, how you refer to it in your code). Together they form the address aws_instance.web, which you use to reference it elsewhere.
  3. Terraform builds a dependency graph and infers the order. What drives it is references: when one resource reads another’s attribute (e.g. aws_subnet.app uses aws_vpc.main.id), Terraform knows the VPC must come first; resources with no reference between them can be created in parallel.
  4. plan is a dry run — it diffs your code against reality (via state) and prints what it would do (+/~/-) while changing nothing; apply executes that plan against real resources. plan is central to the safety argument because it’s the reviewable diff you read and confirm before anything touches production.
  5. Variables are typed inputs that parameterize the code; outputs expose values after a run (a server IP, a DB endpoint) to a human or another configuration; modules are reusable folders of .tf you instantiate many times. Variables are what let the same code build staging (t3.micro) and prod (m5.large) with just a differing value.