Getting started with Terraform: what the tutorial doesn't cover

Terraform's HCL syntax is learned in an hour: declaring a resource, a provider, a variable. What takes much longer to truly understand, and what most tutorials barely touch on, is state: the file that links the declaration to reality.
What Terraform does, in one sentence
Terraform compares three things on every run: what you declared (the code),
what it thinks it already created (the state), and what actually exists at
the cloud provider. A terraform plan computes the difference between
these three sources; a terraform apply applies it.
resource "aws_s3_bucket" "frontend" {
bucket = "my-app-frontend"
}
This block doesn't create anything on its own: it declares an intent. It's the state that turns that intent into a record of what actually exists.
code (.tf) state (.tfstate) cloud provider
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ declared │ plan │ last state │ │ resources │
│ intent │◄──────►│ known to │◄───────►│ that actually │
│ │ │ Terraform │ apply │ exist │
└─────────────┘ └─────────────────┘ └─────────────────┘
plan compares the three columns and shows the gap; apply makes the
state and the cloud converge toward what the code says.
The real subject: state, not syntax
State is a JSON file that maps every declared resource to its real
identifier at the cloud provider. The problem isn't understanding it in
theory, it's understanding what happens when it drifts from reality:
someone manually changes a resource in the AWS console, and the state
doesn't know. On the next plan, Terraform proposes to "fix" that manual
change, potentially by destroying it.
That's why state should never stay local on a developer's machine, even on a solo project: a crashed disk, a file accidentally committed, and the infrastructure's history is lost or inconsistent.
Remote storage from day one
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "env/prod/terraform.tfstate"
region = "eu-west-3"
dynamodb_table = "terraform-locks"
}
}
The S3 bucket stores the state; the DynamoDB table manages the lock that prevents two simultaneous runs from corrupting the same state. On MissionMatch, I set up this configuration from day one, even solo: it isn't a question of scale, it's a question of discipline. It removes "works on my machine" from the equation as soon as infrastructure is involved.
What remains a real trap for a beginner
The most common trap isn't the syntax, it's confusing plan and apply
under pressure: applying a change without carefully reading the displayed
plan, especially the lines indicating a destruction rather than a simple
modification.
| Symbol | Meaning | Vigilance level |
|---|---|---|
+ | Creating a new resource | Low, generally low risk |
~ | In-place modification, no recreation | Medium, depends on the attribute changed |
-/+ | Destroy then recreate (often an immutable attribute changing) | High, the resource is briefly gone during replacement |
- | Pure deletion | Maximum, especially on a stateful resource |
The -/+ case is the one that surprises people most while learning
Terraform: a change that looks harmless in the code (say, an EC2 instance's
AMI id) can force a full destroy-and-recreate if that attribute can't be
modified in place at the cloud provider, with nothing in the code itself
signaling that before reading the plan.
On a stateful resource (a database, a bucket with data), an unanticipated destruction isn't recoverable like a code rollback.
What I take from learning it
Learning Terraform seriously isn't about memorizing provider syntax: it's
about understanding that every apply modifies a shared, persistent
system, unlike an application deployment you can redo at will. That
difference in nature is what justifies reading every plan before applying
it, even on a personal project where "it doesn't matter." On MissionMatch,
every module and both environments pass terraform validate, but nothing
has been apply'd yet, precisely because I prefer validating the structure
before running anything against a real cloud account.