# Infrastructure as Code (Terraform Basics)

> Stop clicking around in cloud consoles. Define your servers, networks, and databases in version-controlled files you can review and apply - that's Infrastructure as Code, and Terraform is how a huge slice of the industry does it.


---

# Infrastructure as Code (Terraform Basics)

You've built a server by clicking through a cloud console. It worked. Then three months later someone asks "how exactly is the staging environment set up?" and the real answer is: nobody knows. Somebody clicked some things, once, and the only record is the running machine itself. Rebuild it from scratch and you'll get something *close*, but not the same.

This guide is about getting out of that trap. **Infrastructure as Code (IaC)** means you describe your servers, networks, databases, and DNS in plain-text files - the same kind of files you already commit, review, and diff - and a tool reads those files and makes the cloud match them. We'll use **Terraform**, because it's the tool you're most likely to meet at work, and because once you understand its three ideas, you understand the whole category.

By the end you'll know *why* click-ops doesn't scale, *how* Terraform's core loop works, and *how* to use it without the two or three mistakes that scare people off it.

## How to read this

- **Want it to finally make sense?** Read in order - each phase builds on the last. The whole guide rests on one idea (you declare *desired state*, not steps), and Phase 1 installs it.
- **Already using Terraform and something bit you?** Phase 3 is the safety-and-danger phase: drift, destroy, and secrets in state. Start there.

## The phases

1. **[Why Click-Ops Doesn't Scale](01-why-click-ops-doesnt-scale.md)** - why clicking in a console is unrepeatable, undocumented, and quietly drifts, and the mental shift IaC asks of you: declare *what you want*, not *what to do*.
2. **[How Terraform Works](02-how-terraform-works.md)** - the `.tf` files that describe resources, the core loop `init` → `plan` → `apply`, and **state**, the file where Terraform records what it built. Annotated HCL and real `plan` output.
3. **[Using It Safely](03-using-it-safely.md)** - plan-before-apply as a habit, modules for reuse, and the real dangers: drift, destroy operations, and secrets ending up in state.

> This is the *basics*. Deeper material - writing your own modules, workspaces and environments, CI/CD pipelines that run Terraform, and the testing tools around it - is deliberately left for a follow-up guide so this one stays a clean on-ramp.

**Related guides:** [Cloud Platforms Explained](/guides/cloud-platforms-explained) (what the resources Terraform creates actually *are*) · [Git With Other People](/guides/git-with-other-people) (the review-and-merge habits that make IaC safe on a team).


---

# Why Click-Ops Doesn't Scale

Before any tool, let's take a clear-eyed look at the thing IaC replaces - because the whole reason Terraform exists makes no sense until you've felt the pain it removes.

You open the cloud console. You click *Launch Instance*. You pick a size from a dropdown, choose a region, attach a security group, name it `web-1`, and hit go. A minute later you have a running server. It felt productive. It *was* productive - once.

The trouble starts the second time, and every time after.

## What "click-ops" actually is

📝 **Terminology.** *Click-ops* (sometimes *ClickOps*) is the informal name for managing infrastructure by hand through a web console's point-and-click interface - as opposed to defining it in code. Nobody named it as a compliment.

**What it actually is.** Click-ops is operating your cloud the way you operate a settings app: you navigate menus, fill in forms, and click buttons, and the cloud provider does what you asked *in that moment*. The result is a running resource. The *record* of how you got there is - nothing. The clicks evaporate the instant you make them.

**Why people start here.** It's the front door - every cloud console is designed to make the first server easy, because that's how they win you. For genuinely one-off exploration ("what does this service even do?"), clicking around is the right tool. The problem isn't that click-ops exists; it's what happens when it becomes how you *run* things.

Here's where it falls apart, and these three are worth naming clearly because each maps to a thing IaC fixes:

```text
   CLICK-OPS PROBLEM                     WHAT IT MEANS IN REAL LIFE
   ──────────────────────────────       ─────────────────────────────────────────
   Unrepeatable                   │     "Build staging exactly like prod" becomes
                                  │     an afternoon of clicking and guessing.
                                  │
   Undocumented                   │     The only record of your setup is the
                                  │     running thing itself. Lose it, lose the
                                  │     knowledge. No diff, no history, no review.
                                  │
   Drifts                         │     Someone clicks one "quick fix" at 2am.
                                  │     Now reality and everyone's mental model
                                  │     disagree, and no one knows.
```

### Unrepeatable

You set up `web-1` perfectly. Now your boss wants an identical staging copy, and a third one in another region for the EU launch. You're back in the console, retracing clicks from memory, hoping you pick the same instance size and the same security group rules and the same disk settings. You won't, exactly. Three servers that were *supposed* to be identical now differ in ways you'll discover at the worst possible time.

### Undocumented

Six months pass. The person who built the production network has left. A new hire asks, reasonably, "why is the database in this subnet and not that one?" There is no answer written down anywhere, because the decision was a click, and clicks don't leave notes. The infrastructure is its own and only documentation, and you can't `git log` a running server.

### Drift

This is the quiet killer. 📝 **Terminology.** *Drift* is when the real, running infrastructure no longer matches what anyone believes it to be - because someone changed it out-of-band. Production is slow one night, an engineer opens the console and bumps the server to a bigger size to get through the incident, and forgets to tell anyone or write it down. Now the live system silently disagrees with every diagram, every runbook, and every teammate's mental model. The next person who tries to "fix" something is working from a map that's wrong.

🪖 **War story.** A classic version: a team rebuilds staging from their notes, points the app at it, and half the features break. The cause: a single environment variable someone had clicked into the *old* staging months earlier to debug something, never removed, never documented. The notes were faithful - to a configuration that no longer existed. Days lost chasing a ghost left by a click.

## The mental shift: desired state, not steps

Here's the idea the entire rest of this guide stands on. It's a shift in *what you write down*.

**The click-ops way is imperative - you give steps.** "Launch an instance. Attach this disk. Open port 443. Set the name." You're a person performing a procedure, and the cloud follows along one click at a time. Stop halfway and you're left in a half-built state; a second copy means performing the whole procedure again.

**Infrastructure as Code is declarative - you describe the destination.** You write down *what should exist*: "one web server of this size, in this region, with port 443 open, named `web-1`." You do **not** write the steps to create it - you hand the description to a tool, and it figures out what to create, what to change, and what's already correct, to make reality match.

📝 **Terminology.** *Desired state* is that description: the complete picture of what your infrastructure *should* look like, written in files. *Declarative* means you specify the end state and let the tool work out how to reach it. *Imperative* means you specify the steps yourself. (You've met this split before - `git pull` is declarative-ish "make my branch match the remote," not "fetch object 1, then object 2.")

```mermaid
flowchart LR
  subgraph Imperative[Imperative: click-ops, scripts of steps]
    direction LR
    You1[you] --> S1[do step 1] --> S2[do step 2] --> S3[do step 3] --> Hope[hope]
  end
  subgraph Declarative[Declarative: Infrastructure as Code]
    direction LR
    You2[you] -->|this is what should exist| Tool[tool works out the steps and makes reality match]
  end
```

**Why this changes everything.** Once the desired state lives in a file:

- **It's repeatable.** Want a second identical environment? Point the tool at the same description. There's no "retracing clicks from memory" because there were never any clicks.
- **It's documented.** The file *is* the documentation, and it's the real, authoritative kind - because the tool enforces it. If the file says port 443 is open, port 443 is open.
- **It's reviewable.** The file goes in Git. Changes come as pull requests your teammates read before anything touches production. (This is exactly why [Git With Other People](/guides/git-with-other-people) pairs so naturally with IaC - the review muscle is the same one.)
- **Drift becomes visible.** Because the tool knows what *should* exist, it can compare that to what *does* exist and show you the difference. Drift stops being a silent ghost and becomes a line of output you can see and decide about.

⚠️ **The mindset gotcha that trips everyone.** Coming from click-ops, the instinct is to read a Terraform file as a *script that runs top to bottom*. It isn't. The order you write resources in mostly doesn't matter; Terraform reads the whole picture, figures out dependencies itself, and decides the order. Stop thinking "first do this, then that." Start thinking "here is the world I want; go make it so." Hold onto that and Phase 2 will feel obvious instead of strange.

## Recap

1. **Click-ops** - managing infrastructure by hand through a console - fails in three specific ways: it's **unrepeatable**, **undocumented**, and it **drifts**.
2. **Drift** is the dangerous one: reality silently diverges from what everyone believes, usually from an undocumented "quick fix."
3. **Infrastructure as Code** flips the model from **imperative** (you list the steps) to **declarative** (you describe the **desired state** and let a tool reach it).
4. Putting desired state in version-controlled files makes infrastructure **repeatable, documented, reviewable, and drift-visible** - the four things click-ops can never be.

Next: the tool itself. How Terraform turns a description into reality, and the one file you must respect to use it on a team.

Watch it animated: [infrastructure as code](/explainers/InfrastructureAsCode.dc.html)


---

# How Terraform Works

You've got the mindset from [Phase 1](01-why-click-ops-doesnt-scale.md): you describe the desired state, the tool makes it real. Now let's open the hood. There are exactly three things to understand - the **files**, the **loop**, and the **state** - and the third one is where the genuine "must not get this wrong" lives. We'll build up to it.

## The files: HCL describing resources

**What it actually is.** Terraform reads files ending in `.tf`, written in **HCL** (HashiCorp Configuration Language). HCL isn't a programming language built around loops and logic - it's mostly a way to *declare blocks*, where each block says "I want a thing of this type, with these settings." You describe resources; you don't write a procedure.

📝 **Terminology.** A *provider* is the plugin that teaches Terraform how to talk to one specific platform - AWS, Google Cloud, Azure, Cloudflare, and hundreds more. A *resource* is one piece of infrastructure that provider can manage: a virtual machine, a network, a DNS record, a database. (For what these resources actually *are* under the hood, see [Cloud Platforms Explained](/guides/cloud-platforms-explained).)

Here's a small, real example: one server on AWS. Read it as a *description*, not a recipe.

```hcl
# Tell Terraform which provider we need and pin its major version,
# so a future provider release can't silently change behavior on us.
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Configure that provider: which region these resources live in.
provider "aws" {
  region = "eu-west-1"
}

# The resource we actually want to exist.
# "aws_instance" is the TYPE; "web" is OUR name for it (used to
# reference it elsewhere in our config - it's not the cloud's name).
resource "aws_instance" "web" {
  ami           = "ami-0abcdef1234567890"  # the OS image to boot
  instance_type = "t3.micro"               # the size of the machine

  tags = {
    Name = "web-1"
  }
}
```

*What just happened:* You declared three things - *which* provider plugin you depend on (and what version range is acceptable), *how* to configure it (the region), and *what* should exist (one `t3.micro` instance booting that image, tagged `web-1`). Nowhere did you say "create" or "launch" - you stated the desired end state. The `aws_instance.web` label is your internal handle for this resource; use it to wire other resources to this one (a disk, a DNS record) and Terraform works out the dependency order itself.

💡 **Key point.** Every resource has a *type* (`aws_instance`, defined by the provider) and a *local name* (`web`, chosen by you). Together, `aws_instance.web` is how this resource is referred to throughout your config and state. The cloud's own ID (like `i-0a1b2c3d`) is something Terraform learns *after* it creates the resource, and stores in state - coming up.

## The core loop: init → plan → apply

You don't run a `.tf` file like a script - you run *Terraform commands* against your files, in a three-step loop you'll repeat for the rest of your life with this tool. Learn it as a rhythm.

```mermaid
flowchart LR
  Init[init<br/>download providers] -->|once per project| Plan[plan<br/>preview the diff, read-only]
  Plan -->|your seatbelt - read every time| Apply[apply<br/>make reality match]
  Apply -.->|the only step that changes your cloud| Cloud[(your cloud)]
```

### `terraform init` - set up the working directory

**What it does in real life.** `init` reads your config, sees you need the `aws` provider, and downloads that plugin into a local `.terraform/` folder - the "install dependencies" step. Run it once when you start a project, and again whenever you add or upgrade a provider.

```console
$ terraform init

Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.62.0...
- Installed hashicorp/aws v5.62.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!
```

*What just happened:* Terraform fetched the AWS provider and wrote a lock file, `.terraform.lock.hcl`, pinning the *exact* provider version it chose (`5.62.0`) - the infrastructure equivalent of `package-lock.json`. Commit it, and every teammate and CI run uses the identical provider instead of quietly picking up a newer one that behaves differently.

### `terraform plan` - preview the diff

This is the most important command in the tool, and the habit that separates calm Terraform users from scared ones. **`plan` changes nothing** - it compares three things (your `.tf` files, the state file, and the real cloud) and prints exactly what it *would* do to make reality match your config.

```console
$ terraform plan

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0abcdef1234567890"
      + instance_type                = "t3.micro"
      + id                           = (known after apply)
      + private_ip                   = (known after apply)
      + tags                         = {
          + "Name" = "web-1"
        }
      # (several other computed attributes omitted)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
```

*What just happened:* Terraform told you, in advance and with zero risk, its entire intent: create one resource, change none, destroy none. Read the **symbols** - they're the whole language of a plan:

```text
   +   will be CREATED
   -   will be DESTROYED          ← the one to always pause on
   ~   will be CHANGED in place
  -/+  will be DESTROYED and recreated  ← also pause: this is a replacement
```

The `(known after apply)` markers are plain unknowns: the cloud hasn't assigned an `id` or `private_ip` yet, so Terraform can't show them until the resource exists. The summary line - `1 to add, 0 to change, 0 to destroy` - is the headline you check before every apply.

⚠️ **Gotcha.** Never let your eyes slide past `to destroy` because you were focused on the thing you meant to add. A small config change can mean "this resource has to be replaced," and a replace is a destroy *plus* a create. If that resource is your database, the destroy half ruins your week. The plan shows you this *before* it happens - your only job is to actually read it. Phase 3 tackles the destroy danger head-on.

### `terraform apply` - make it real

**What it does in real life.** `apply` runs a plan and then *executes* it - the step that actually talks to the cloud and creates, changes, or destroys resources. By default it shows you the plan once more and waits for you to type `yes`. That pause isn't bureaucracy; it's your last look before reality changes.

```console
$ terraform apply

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0abcdef1234567890"
      + instance_type = "t3.micro"
      # ...
    }

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

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 22s [id=i-0a1b2c3d4e5f67890]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
```

*What just happened:* You confirmed with `yes`, Terraform called AWS to create the instance, waited for it to come up, and reported success - including the real cloud ID, `i-0a1b2c3d4e5f67890`, that the cloud assigned. Your description is now reality. Critically, Terraform also just *recorded* what it built - the third and final piece.

## State: Terraform's memory of what it built

This is the part people skip and then get burned by. Slow down here.

**What it actually is.** When Terraform creates a resource, it writes down what it created - type, your local name, and the real cloud ID and attributes - into a file called **state** (by default, `terraform.tfstate`). State is Terraform's *memory*: the bridge between `aws_instance.web` in your config and `i-0a1b2c3d4e5f67890` in the real cloud.

**Why it has to exist.** Think about your second `plan`. Terraform needs to answer "does the thing my config describes already exist, and is it correct?" It can't re-derive that from the config alone - the config doesn't contain the cloud's IDs - so Terraform keeps the mapping in state. Without it, Terraform wouldn't know that the `web` in your file is the *same* server already running, and might try to create a second one.

```mermaid
flowchart LR
  Config[YOUR CONFIG .tf<br/>aws_instance.web - what I want] <-->|maps to| State[STATE .tfstate<br/>web ⇄ i-0a1b2c3d... - what I built]
  State <-->|maps to| Cloud[REAL CLOUD<br/>running EC2 - what actually exists]
```

**Why people get this wrong.** The instinct is to treat state as a disposable cache you can ignore or delete. It's not - state is the authoritative record linking your code to your real, possibly-expensive, possibly-production infrastructure. Lose or corrupt it and Terraform forgets it owns those resources: the next `plan` may want to create duplicates of everything, or lose the ability to manage what's already there.

### ⚠️ State is critical, and for teams it must be shared and locked

This is the single most important operational fact about Terraform, so it gets its own warning.

By default, state is a file on *your* laptop. That's fine for learning alone. The moment a second person is involved, a local state file is a disaster waiting to happen, for two reasons:

1. **Sharing.** If state lives on your laptop, your teammate's Terraform has no idea what you've built. They run `plan`, see none of your resources in their (empty) state, and Terraform proposes creating everything again - two people, two divergent pictures of one shared cloud.

2. **Locking.** Even with shared state, if you and a teammate run `apply` at the same time against the same state, you can interleave writes and **corrupt** it - leaving the file describing a world that never existed.

The fix is **remote state**: store the state file in a shared, locked location instead of on a laptop.

📝 **Terminology.** A *backend* is where Terraform keeps its state - the default is `local` (a file on disk). A *remote backend* puts state in a shared service (commonly an S3 bucket, Google Cloud Storage, Azure Blob Storage, or Terraform Cloud) so the whole team reads and writes the *same* state. *State locking* means that while one person runs `apply`, the backend holds a lock so nobody else can write at the same time - others get "state is locked" and wait, instead of corrupting it.

```hcl
# Store state in a shared S3 bucket instead of on a laptop.
# Modern AWS backends can lock using the bucket itself.
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "prod/web/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true   # turn on state locking
  }
}
```

*What just happened:* You told Terraform to keep state in a shared bucket at a known path, and to lock it during writes. Now the whole team reads and writes one authoritative state, and two simultaneous applies can't trample each other - the second is told the state is locked and waits its turn.

💡 **Key point.** If you remember one thing: **on a team, configure remote state with locking before your second person ever runs `apply`.** It's far easier to set up on day one than to untangle after two laptops have built conflicting realities.

## Recap

1. You describe infrastructure in **`.tf`** files written in **HCL** - declaring **resources** (`aws_instance.web`) that a **provider** (`aws`) knows how to manage.
2. The core loop is **`init`** (download providers, write the lock file), **`plan`** (preview the diff - read-only, your seatbelt), **`apply`** (make it real - the only step that changes the cloud).
3. Read the plan **symbols**: `+` create, `-` destroy, `~` change, `-/+` replace. Always check the `to destroy` count before you type `yes`.
4. **State** is Terraform's memory - the mapping between your config and the real cloud's IDs. It's authoritative, not disposable.
5. ⚠️ For teams, state **must** be shared and **locked** via a **remote backend**, or two people will silently build divergent or corrupted infrastructure.

Next: turning this knowledge into safe habits - plan-before-apply for real, modules for reuse, and the dangers (drift, destroy, secrets in state) that bite people who skip the discipline.


---

# Using It Safely

You can now read a `.tf` file, run the loop, and explain what state is. This last phase is about the difference between *using* Terraform and using it *without scaring yourself* - one habit and three dangers. Let's start with a panic-moment cheat-card, since some of you arrived here mid-incident.

## Cheat-card: "something feels wrong"

| Symptom | Calm move |
|---|---|
| `plan` wants to **destroy** something you didn't expect | **Stop. Don't type `yes`.** Read *why* it's destroying. Often a config change forced a replace - see [Destroy operations](#danger-2-destroy-operations) below. |
| `plan` shows changes you **didn't make** (it wants to "fix" things) | Someone changed it by hand. That's **drift** - see [Drift](#danger-1-drift-someone-changed-it-by-hand). Decide: update your code to match, or let Terraform revert reality. |
| "Error acquiring the state lock" | Someone else is running `apply` (or one crashed mid-run). **Wait.** Don't force-unlock unless you've confirmed no one is actually running. |
| You're about to commit and worried a password is in the files | Check **state**, not just `.tf` - secrets leak into state even when they're not in your config. See [Secrets in state](#danger-3-secrets-in-state). |
| `apply` failed halfway | Terraform is usually safe to re-run - it picks up from the real state. Run `plan` again first to see where you actually are. |

Now the habit and the dangers, properly.

## The one habit: plan before apply, always

**What it actually is.** Plan-before-apply is the discipline of *always* reading `terraform plan` output and understanding it before you let `apply` touch anything. You met `plan` in [Phase 2](02-how-terraform-works.md) as a command; here it becomes a *rule you don't break* - the single thing standing between "I changed a tag" and "I deleted the production database."

**Why it matters more than it looks.** Terraform is powerful in both directions. The same tool that builds your whole environment from a file can tear it down from a file. The plan is the moment you see which one is about to happen, in plain symbols, before it's real. Skipping it to "save time" is the infrastructure version of merging without reading the diff.

**What it does in real life on a team.** The grown-up version isn't a person squinting at a terminal - it's automation. Open a pull request changing a `.tf` file; CI runs `terraform plan` and posts the output as a comment; a human reviews *that diff* before approving; only a merge to main triggers `apply`. The plan becomes a reviewable artifact, exactly like a code diff. (Same review habit as [Git With Other People](/guides/git-with-other-people), pointed at infrastructure.)

💡 **Key point.** A `plan` that says `0 to change, 0 to destroy` when you only meant to change one thing is *good news* - it means you understand your blast radius. A plan with a surprise `destroy` you can't explain is a *full stop*, not a speed bump.

## Modules: reuse instead of copy-paste

**What it actually is.** A *module* is a reusable bundle of Terraform configuration - a folder of `.tf` files that describes a set of resources together (say, "a web server plus its disk plus its security group"), parameterized by inputs. Instead of copy-pasting that block for every environment, you call the module and pass it different values.

📝 **Terminology.** Every Terraform configuration is technically a module - the top-level one is the *root module*. When people say "a module," they usually mean a *child module*: a folder you reference from elsewhere with a `module` block, reusing its resources without duplicating them.

**Why people get this wrong.** The first instinct in IaC is to copy your working `web-1` block, paste it, change the name to `web-2`, and repeat. That's just click-ops with extra steps - the same drift-between-copies problem, only in text. The two servers were supposed to be identical, and now differ because someone edited one copy and forgot the other.

```hcl
# Define the shape once in modules/web_server/, then call it
# three times with different inputs - one source of truth.
module "web_staging" {
  source        = "./modules/web_server"
  instance_type = "t3.micro"
  environment   = "staging"
}

module "web_prod" {
  source        = "./modules/web_server"
  instance_type = "t3.large"   # prod is bigger; everything else identical
  environment   = "prod"
}
```

*What just happened:* You described one kind of web server *once* (inside `modules/web_server/`), then asked for two that differ only in the inputs you chose to vary - size and environment name. Fix a security setting in the module and *both* environments get the fix on the next apply. That's the reuse click-ops can never give you.

⚠️ **Gotcha.** Don't build a module before you have a reason. A module abstracting a single resource you use once adds indirection for no reuse - now you hop between files to read one server. Reach for a module when you're about to copy-paste the *second* time, not preemptively (the "no abstractions for single-use code" instinct, applied to infrastructure).

## The three dangers

Terraform doesn't bite often, but when it does it's almost always one of these three. Knowing them by name is most of the defense.

### Danger 1: Drift (someone changed it by hand)

You met drift in [Phase 1](01-why-click-ops-doesnt-scale.md) as the click-ops killer. Terraform doesn't *prevent* drift - someone can always open the console and change a thing Terraform manages. What Terraform gives you is the power to **see** drift and decide what to do about it.

**What it does in real life.** Because Terraform compares your config and state against the real cloud, drift shows up as a `plan` proposing changes *you* didn't write - Terraform wanting to "fix" reality back to match your code.

```console
$ terraform plan

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

  # aws_instance.web has been changed
  ~ resource "aws_instance" "web" {
        id            = "i-0a1b2c3d4e5f67890"
      ~ instance_type = "t3.large" -> "t3.micro"
    }

Terraform will perform the following actions:

  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
      ~ instance_type = "t3.large" -> "t3.micro"
    }

Plan: 0 to add, 1 to change, 0 to destroy.
```

*What just happened:* Terraform noticed the real instance is now `t3.large`, but your code still says `t3.micro` - someone resized it by hand (probably during an incident, exactly the 2am story from Phase 1). Terraform offers to drag reality *back* to `t3.micro`. Now you have a clear decision, not a silent mystery: either the bigger size was intentional, so update your code to `t3.large` and commit it, or it was a temporary hack, so let the apply revert it. Either way, drift is visible and resolved instead of festering.

⚠️ **Gotcha.** Don't reflexively `apply` a drift-correcting plan. If someone scaled production up to survive load, blindly reverting to the old size *reintroduces the outage*. Read what changed and *why* before you let Terraform "fix" it. The plan gave you the information precisely so you could make this call.

### Danger 2: Destroy operations

Terraform deletes things. That's a feature - `terraform destroy` cleanly tears down everything in a config, which is wonderful for temporary environments. It's also the command most likely to ruin your day if it runs against the wrong thing.

**Two ways resources get destroyed**, and you must recognize both in a plan:

- **An explicit `terraform destroy`** - you asked to tear it all down.
- **A `-/+` replacement inside a normal `apply`** - you changed an attribute that *can't* be modified in place, so Terraform's only option is destroy-then-recreate. This is the sneaky one: a small change, with a destroy hiding inside it.

```console
$ terraform plan

  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
      ~ ami           = "ami-0abcdef1234567890" -> "ami-0newimage99999" # forces replacement
        instance_type = "t3.micro"
      ~ id            = "i-0a1b2c3d4e5f67890" -> (known after apply)
    }

Plan: 1 to add, 0 to change, 1 to destroy.
```

*What just happened:* You changed the `ami` (the base image). You can't swap a running machine's underlying image in place, so Terraform reports `-/+ must be replaced`, and `# forces replacement` tells you exactly which attribute caused it. The summary's `1 to destroy` is what matters: your existing server will be **deleted** and a new one created. If that server held data on its local disk, or its IP was hard-coded somewhere, the destroy is the part that hurts.

⚠️ **Gotcha - read the summary line, every time.** `1 to add, 0 to change, 1 to destroy` looks almost like the harmless create-only plan from Phase 2 - the difference is one number, and that number is a deletion. For irreplaceable resources, especially databases, Terraform supports a guard: `prevent_destroy = true` makes it *refuse* to produce any plan that would delete the resource, turning a catastrophe into an error message. Use it on anything you can't afford to lose.

🪖 **War story.** The canonical Terraform horror story: a tiny change to a database resource that, because of how that attribute works, forces a replacement - and the engineer, watching the change they *intended*, types `yes` without registering the `1 to destroy`. The database is gone, recreated empty. Every word they needed was on screen. The lesson isn't "Terraform is dangerous"; it's "the plan is the safety device, and it only works if you read the destroy count."

### Danger 3: Secrets in state

This one surprises people because it's invisible until you go looking. **Sensitive values can end up stored in plain text inside your state file** - even if they're nowhere in your `.tf` files.

**Why it happens.** State is Terraform's record of what it *built*. If a resource has a password, a private key, or a generated token as one of its attributes, Terraform stores that value in state so it can detect future changes. A database resource with an initial password, for example, writes that password into `terraform.tfstate` - which is, by default, unencrypted JSON.

**What this means in real life:**

- ⚠️ **Never commit `terraform.tfstate` to Git.** It can contain secrets in clear text, and Git history is forever. Add it to `.gitignore` on day one. (This is one more reason remote state from Phase 2 is the right default - it keeps state out of the repo entirely.)
- ⚠️ **Treat the state backend as a secret store.** Whoever can read the state bucket can read those secrets. Lock down access to it the way you'd lock down a password vault: tight permissions, and encryption-at-rest enabled on the bucket.
- **Marking a variable `sensitive` hides it from console output, not from state.** `sensitive = true` stops Terraform from *printing* a value in `plan`/`apply` output - useful, but the value is still written to the state file. Hiding it from your terminal is not the same as protecting it at rest.

💡 **Key point.** The mental model: *your `.tf` files describe intent and are safe to share with reviewers; your state file is a record of reality that can contain real secrets and must be guarded.* Conflating the two - committing state, or leaving the state bucket world-readable - is how IaC setups leak credentials. Keep secrets out of the config where you can (inject them at apply time from a real secrets manager), and protect the state regardless.

## Recap

1. **Plan before apply, always** - reading the plan is the one habit that stands between a tag change and a deleted database. On teams, make the plan a reviewed PR artifact.
2. **Modules** let you describe a piece of infrastructure once and reuse it with different inputs - real reuse, not copy-paste drift. Build them when you'd otherwise copy, not before.
3. **Drift**: Terraform can't stop hand-changes, but it *shows* them as unexpected plan changes. Decide whether to revert reality or update your code - don't blindly apply.
4. **Destroy**: deletions come from explicit `destroy` *and* hidden `-/+` replacements. Read the `to destroy` count every time; use `prevent_destroy` on irreplaceable resources.
5. **Secrets in state**: state can hold secrets in plain text. Never commit it, guard the backend like a vault, and know that `sensitive` hides output, not state.

That's the foundation. You can now reason about Terraform instead of fearing it: declare desired state, run the loop, respect state, read every plan. The deeper craft - authoring modules well, multi-environment workspaces, full CI/CD pipelines - builds directly on these ideas, and is the subject of the follow-up guide.
