← How Your App Gets to the Internet
Lesson 4 of 6

Infrastructure as Code: What Terraform Is Really Doing

SoftwareIntermediate

The Way Everyone Starts

You log into the AWS console. You click through to EC2, press the orange button, pick an instance size, pick a network, create a security group along the way because it asks you to, tick a box about storage, and press launch. Ninety seconds later you have a server. It worked, it was obvious, and nothing about it felt like a mistake.

This is click-ops, and the important thing to understand is that it is not wrong. For one server, once, it is genuinely the fastest way to get there, and anyone who tells you to write forty lines of configuration before you have even decided whether you need the server is wasting your afternoon. The problem with click-ops is not that it fails. It is that it succeeds, repeatedly, right up until the point where it fails all at once.

Where It Actually Breaks

It is worth being specific about this, because "it doesn't scale" is the kind of phrase people repeat without ever having watched it happen. Here is what actually goes wrong, in roughly the order it goes wrong.

You cannot reproduce it. Six months later you need the same setup in a second region, or a staging environment that behaves like production. You click through again from memory. You get it almost right. The two environments are now subtly different in ways nobody has written down, and the differences will surface as a bug that only happens in production, which is the most expensive kind of bug there is.

You cannot review it. Every other change your team makes goes through a pull request, gets read by another person, and leaves a record. Infrastructure changes made by clicking go through nobody. The security group rule that opened the database to the whole internet was added by someone competent, in a hurry, on a Friday, and there was no point at which another person could have seen it before it took effect.

You cannot roll it back. When a deploy breaks something, you revert the commit. When a click breaks something, you try to remember what the setting was before you changed it. Sometimes the console tells you. Often it does not.

And nobody knows what is out there. This is the one that compounds. After two years of clicking, an account contains resources nobody recognises: a load balancer with no targets, three security groups with similar names and one meaningful difference, a database somebody spun up to test something in 2024. Nobody deletes any of it, because nobody can prove it is unused. You are now paying, monthly, for archaeology.

Every one of these is a knowledge problem rather than a technology problem. The infrastructure works fine. What has gone missing is the record of why it looks the way it does, and that record only ever existed in somebody's memory.

The Idea, and Why It Is Not Just Scripting

Infrastructure as Code means writing down what your infrastructure should look like, in files, and having a tool make reality match those files. The files go in git, get reviewed in pull requests, and can be reverted, exactly like application code. That much is easy to say and easy to agree with.

The part that takes a moment to land is why this is not simply a shell script that calls the AWS command line tool. A script would give you most of the benefits above: it is a file, it goes in git, it gets reviewed. The difference is a single property, and it changes everything downstream.

A comparison of running a shell script twice versus running Terraform twice. The shell script column: first run creates a server, giving one server; second run creates another one, giving two servers, marked in red. The Terraform column: first run creates a server, giving one; second run reports No changes and you still have one, marked in green. The conclusion reads that the script describes actions and running it again does them again, while Terraform describes a destination and does nothing if you are already there.
A script is a list of actions. Terraform is a description of a destination. That distinction is the whole thing.

A script is imperative: it says do this, then do this, then do this. Running it twice performs the actions twice. To make a script safe to re-run you have to write every check yourself - does this server already exist, does this rule already exist, is this the right size already - and you have to remember every one of those checks, forever, for every resource. In practice nobody does, which is why infrastructure scripts tend to be run exactly once and then never trusted again.

Terraform is declarative: you describe the end state, and it works out the actions needed to get there from wherever things currently are. Running it twice does nothing the second time, because you are already at the destination. That property has a name, idempotence, and it is what makes the thing usable by a team. You can run it whenever you like without first working out whether it is safe to run.

What a Terraform File Looks Like

Before going further into how it works, here is a real one. This builds the arrangement from lesson one: a private network, a public subnet, a firewall rule, and a server inside it.

hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"      # allow 5.x updates, never 6.x without a decision
    }
  }
}

provider "aws" {
  region = "eu-central-1"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = { Name = "dawn-network" }
}

resource "aws_subnet" "public" {
  # This reference is the important line in the whole file. It is not just
  # reading a value - it is what tells Terraform the subnet needs the VPC
  # to exist first. The build order comes from references like this one.
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "eu-central-1a"
}

resource "aws_security_group" "web" {
  name   = "dawn-web"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]   # the whole internet, deliberately
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web" {
  ami                    = "ami-0a1b2c3d4e5f6a7b8"
  instance_type          = "t3.small"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = { Name = "dawn-web" }
}

Two things in there are worth naming. The provider is a plugin that knows how to translate Terraform's model into one particular API - there is an AWS provider, a Google Cloud one, a Cloudflare one, a Postgres one, several thousand in total. Terraform itself knows nothing about AWS; the provider is where that knowledge lives. And the version constraint on it matters more than it looks: providers change, and pinning is the difference between a plan you can predict and a surprise on a Tuesday.

The second is `aws_vpc.main.id`. That is not a variable lookup. It is how Terraform learns that these resources are related, and it is the entire basis of the next section.

Terraform Builds a Graph

Nothing in that file says what order to create things in. Terraform works it out, by reading every reference between resources and building a dependency graph out of them. Then it walks the graph, creating everything it can in parallel at each level.

A dependency graph. At the top sits the VPC, with the AWS VPC icon. Arrows lead from it down to a subnet, a security group and an internet gateway. Arrows from both the subnet and the security group lead into an EC2 instance, labelled as needing both a subnet to sit in and a security group to obey. An arrow from the internet gateway leads to a route table. Below, the resulting order is given as step 1 the VPC, step 2 the subnet, security group and internet gateway all at once since nothing links them to each other, and step 3 the EC2 instance and route table, also in parallel.
The order came entirely from which resource mentions which. Nobody numbered anything.

This is why the reference matters more than the value. If you had hardcoded the VPC's id as a string instead of referencing it, Terraform would have no idea the two were connected, would happily try to build them in either order, and would fail intermittently depending on which finished first. It is also why destroying works: to tear the whole thing down, Terraform walks the same graph backwards.

The State File, and Why It Exists

Here is the concept that most explanations skip, and the one that causes the most confusion later. Terraform keeps a file recording everything it has created. That file is called the state, it lives in `terraform.tfstate` by default, and it is not a cache or an optimisation. Without it, Terraform cannot work.

The reason is a question that has no other answer: when you write `resource "aws_instance" "web"`, which real server in your account is that? Amazon does not know anything about the name `web`. It knows `i-0abc123def456`. Something has to hold the mapping between the name you chose and the identifier the cloud assigned, and that something is the state file. Delete it, and Terraform no longer knows that your server is yours - it will conclude nothing exists yet and try to build a second one.

A diagram showing Terraform comparing three things. On the left, what you wrote, main.tf checked into git, specifying instance_type t3.small. In the middle, what Terraform recorded, terraform.tfstate, specifying t3.micro and an id of i-0abc123. On the right, what actually exists in the real AWS account, t3.large, because someone resized it. Labels explain that a difference between the first two is a change you made in the code which Terraform will apply, and a difference between the last two is drift changed by hand which Terraform will undo. Below, terraform plan reads all three and outputs a change of instance_type from t3.large to t3.small, with a plan of 0 to add, 1 to change, 0 to destroy.
Three sources, not two. Reality wins the comparison, and state's real job is knowing which real resource to go and look at.

Follow the numbers in that diagram carefully, because there is a subtlety in them. The state file says the server is a t3.micro. Reality says it is a t3.large, because somebody resized it by hand last week. The plan does not propose changing from t3.micro - it proposes changing from t3.large. That is because Terraform refreshes its state against the real world before comparing anything: it goes and asks the API what each resource actually looks like now, updates its picture, and only then works out the difference against your files.

So the mental model to keep is this. Your files are the goal. Reality is the starting point. The state file's unique contribution is knowing which real resources to go and look at, plus remembering what Terraform believes it is responsible for. That last part matters: Terraform will only ever touch things that are in its state. Everything else in your account is invisible to it.

Reading a Plan

`terraform plan` shows you exactly what it intends to do before it does anything. This is the single best feature of the tool, and treating the plan as something to skim past is how people get hurt. There are four symbols, and one of them will eventually ruin your day.

text
$ terraform plan

Terraform will perform the following actions:

  # aws_s3_bucket.uploads will be created
  + resource "aws_s3_bucket" "uploads" {
      + bucket = "dawn-uploads"
    }

  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
        id            = "i-0abc123def456"
      ~ instance_type = "t3.large" -> "t3.small"
    }

  # aws_security_group.old will be destroyed
  - resource "aws_security_group" "old" {
      - name = "legacy-rules"
    }

  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine_version = "15.4" -> "16.2" # forces replacement
    }

Plan: 2 to add, 1 to change, 2 to destroy.

A plus is a new resource, and it is safe. A tilde is a change made in place - the thing keeps existing, one of its settings changes, nothing is lost. A minus is a deletion, which is at least honest about what it is going to do.

`-/+` means destroy and recreate. Some attributes simply cannot be changed on a live resource, so Terraform's only route to your desired state is to delete the existing one and build a new one - which for a database means the data goes with it. The words "forces replacement" in a plan are the most important two words the tool ever prints. Read every plan for them before typing yes, and treat `2 to destroy` on a production apply as a full stop rather than a detail.

The habit worth building early: run `terraform plan -out=tfplan`, read it, then `terraform apply tfplan` to apply exactly that saved plan. Otherwise the apply re-plans from scratch, and what it does is not guaranteed to be what you just read - something could have changed in between.

Where the State File Has to Live

By default the state file is written to the directory you ran the command in. For one person experimenting, fine. For anything else this is the single most common way a team gets into real trouble, and it is worth understanding precisely why before you are the one it happens to.

A two-part diagram. The top shows state on each laptop: Engineer A and Engineer B each with their own terraform.tfstate copy, both pointing at one real AWS account, with a warning that both applied at once, neither file records what the other did, and now both are wrong and Terraform will happily destroy things to fix it. The bottom shows one shared state with a lock: Engineer A takes the lock and reaches a single state file in an S3 bucket marked LOCKED BY A, which in turn reaches the one real AWS account, while Engineer B has to wait, shown by a dashed blocked arrow.
One file, one writer at a time. It is four lines of configuration, and teams that skip it find out why eventually.

Two engineers, two laptops, two state files, one real account. A applies and creates a load balancer; A's state knows about it, B's does not. B applies; B's state has no record of the load balancer, so as far as B's Terraform is concerned it is not managed, and depending on what B changed, B's run may quietly delete resources A just created or create a duplicate set. Neither person did anything wrong, and there is no error message. The two state files simply disagree, and there is no mechanism by which they could ever have agreed.

The fix is remote state: one file, stored centrally, with a lock so that only one apply can run at a time. On AWS this is an S3 bucket, and it is genuinely a few lines.

hcl
terraform {
  backend "s3" {
    bucket = "dawn-terraform-state"
    key    = "production/terraform.tfstate"
    region = "eu-central-1"

    # Locking, so two applies can never run at once. Terraform 1.10 and
    # later do this natively; older guides tell you to create a DynamoDB
    # table for it, which is no longer necessary.
    use_lockfile = true

    encrypt = true   # see the warning below - this is not optional
  }
}

State files contain every attribute of every resource, and that includes secrets in plain text: generated database passwords, private keys, any sensitive variable you passed in. Marking a variable `sensitive` only hides it from console output - it is still written to state in the clear. Encrypt the bucket, lock down who can read it, and never commit a state file to git. A public repository containing a `terraform.tfstate` is a credential leak, not a housekeeping mistake.

Adopting What Already Exists

A fair objection at this point: you already have infrastructure, built by clicking, and none of it is in any state file. You do not have to delete it and start again. `terraform import` tells Terraform that an existing real resource corresponds to a block in your configuration, and writes it into state.

bash
# 1. Write the resource block first, matching what already exists.
#    Terraform needs somewhere to put what it finds.

# 2. Tell Terraform which real resource that block refers to.
$ terraform import aws_instance.web i-0abc123def456

Import successful!

# 3. Now plan. An empty plan means your file matches reality.
#    Anything else is a difference you need to reconcile by hand -
#    and it is far safer to change the file than to let Terraform
#    "fix" a running server to match a guess.
$ terraform plan

No changes. Your infrastructure matches the configuration.

That third step is the real work, and it is tedious: importing tells Terraform what exists, but you still have to write configuration that describes it accurately, attribute by attribute, until the plan comes back empty. For a large hand-built account this is a project rather than an afternoon. Newer Terraform versions have an `import` block that can generate the configuration for you, which helps considerably, though it still needs reviewing.

Modules, and How Staging Finally Matches Production

Remember the original complaint: staging and production drifted apart because both were built by hand from memory. A module is how that stops. It is a folder of Terraform files with inputs and outputs, used more than once.

hcl
# modules/web-service/main.tf  - written once
variable "environment"   { type = string }
variable "instance_type" { type = string }
variable "instance_count" {
  type    = number
  default = 1
}

resource "aws_instance" "app" {
  count         = var.instance_count
  ami           = "ami-0a1b2c3d4e5f6a7b8"
  instance_type = var.instance_type
  tags          = { Name = "dawn-${var.environment}-${count.index}" }
}

output "instance_ids" {
  value = aws_instance.app[*].id
}


# main.tf  - used twice, and the differences are now the only differences
module "staging" {
  source         = "./modules/web-service"
  environment    = "staging"
  instance_type  = "t3.small"
}

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

The two environments are now provably the same shape, and the ways they differ are written down in six lines that a person can read in one glance. That is the actual answer to "it works in staging": not that the environments are identical, which they rarely can be, but that every way they differ is explicit and reviewable instead of accidental and forgotten.

Drift, and What to Do About It

Someone will eventually change something in the console. During an incident at two in the morning, this is the right call - fix the outage, argue about process later. What matters is that the change gets noticed rather than quietly surviving.

It gets noticed because the next plan will show it, as the middle diagram above did. At that point there are exactly two honest options, and picking one deliberately is the whole discipline: either the hand change was correct, in which case put it into the files so it survives, or it was a temporary fix, in which case let the apply revert it. What you must not do is nothing, because the plan now has a permanent difference in it that everyone learns to scroll past - and a plan people have stopped reading is worse than no plan at all.

Run `terraform plan` on a schedule against production, and alert if it is not empty. An empty plan is a real, continuously verified statement that your files describe reality. It is one of the few controls in this area that keeps working without anyone remembering to do anything.

What It Does Not Fix

Terraform is for infrastructure, not for application deployment. Using it to ship a new version of your code is possible and almost always a mistake - it is slow, it has no concept of a health check or a gradual rollout, and it makes every code deploy carry the risk of an infrastructure change. Build the servers with Terraform, ship the code with something else.

Blast radius is a real problem and worth designing around early. One state file holding your entire company means one careless apply can affect everything in it, and it means every apply waits for every other. The usual answer is to split state by environment and by rate of change - production separate from staging, the rarely-touched network layer separate from the applications that sit on it - so that a mistake in one place is bounded.

The feedback loop is slow. A typo in application code fails in seconds; a typo in Terraform may fail six minutes into a fifteen-minute apply, halfway through, leaving you in a state that is neither the old one nor the new one. Terraform handles this better than you might fear, because state is written as it goes and re-running continues from where it stopped, but partial failures are genuinely unpleasant and they are the reason people are nervous about large applies.

And someone still has to run it. If applies happen from laptops, you have moved the problem rather than solved it - now the record is in git but what is actually deployed depends on whose laptop ran last. The end state of this journey is applies running in CI, from the main branch, with the plan posted to the pull request for review. That is a later lesson, but it is worth knowing that is where this is heading.

The Alternatives, Briefly and Honestly

CloudFormation is Amazon's own, AWS-only, and its big advantage is that there is no state file to manage because Amazon keeps it for you. Its disadvantages are that it is slower, its error messages are worse, and it locks you to one cloud. CDK and Pulumi let you write infrastructure in a real programming language - TypeScript, Python, Go - which is genuinely better for anything with complex logic, at the cost of making it much easier to write infrastructure code that nobody else can follow. Ansible is a different job: it configures machines that already exist, rather than creating them, and plenty of teams sensibly use both.

One thing you should know before starting, because it affects which documentation you should trust. In 2023 HashiCorp changed Terraform's licence from open source to the Business Source License, which restricts commercial competing use. The community forked the last open-source version as OpenTofu, now run under the Linux Foundation. The two remain close to drop-in compatible - `tofu` in place of `terraform` - and everything in this lesson applies to both. HashiCorp has since been acquired by IBM. For most people building most things the licence change changes nothing, but it is worth a deliberate choice rather than finding out later.

How to Actually Start

Do not convert everything. That project stalls every time. Pick one thing that is small and annoying - the staging environment, a single service's network setup, the piece nobody remembers configuring - and put only that under Terraform. Set up remote state with locking on day one rather than day thirty, because retrofitting it after two people have diverged is much worse than doing it at the start. Import what already exists rather than rebuilding it. Then leave the rest alone until the next time you would otherwise be clicking, and do that piece in Terraform instead.

The measure that this is working is not how much is under Terraform. It is whether the answer to "why is production configured like that?" has stopped being "ask whoever set it up" and started being "read the file, and the commit that changed it."

Further reading

  • Terraform Documentation: StateThe official explanation of why state exists at all, including the mapping problem described in this lesson.
  • Terraform: The S3 BackendThe remote state configuration used above, including the native locking that replaced the old DynamoDB table requirement.
  • Terraform: Importing Existing InfrastructureBoth the import command and the newer import block that can generate configuration for you.
  • OpenTofuThe Linux Foundation fork created after the licence change - the same tool, and worth understanding before you pick one.
  • HashiCorp's Business Source License FAQHashiCorp's own account of what the licence permits and prohibits, so you can judge the change rather than take anyone's summary of it.
  • Terraform Best Practices, by Anton BabenkoA community reference on structuring real projects: state splitting, module layout, and naming, which is where most of the difficulty actually lives.
  • AWS Provider RegistryEvery AWS resource Terraform can manage, with the full attribute list for each - the page you will keep open while writing any of this.