Terraform Fundamentals for DevOps
The Terraform mental model that makes everything else click: you declare the infrastructure you want, Terraform makes reality match and tracks it in state. Plus providers, resources, and the init/plan/apply/destroy workflow you actually use.
Terraform looks like just another config tool from the outside, and most tutorials make it worse by throwing HCL syntax at you before the idea lands. But the whole system rests on one simple relationship: you write down the infrastructure you want, and Terraform figures out the create, update, or delete calls needed to make reality match, tracking everything it built in a file called state. Once that clicks, providers, resources, plans, and the rest stop being trivia and become obvious consequences of that one idea. This guide builds that mental model, then gives you the core building blocks and the everyday workflow that real operations actually need.
The one idea: declare desired state, Terraform makes reality match
Forget the syntax for a minute. The thing that makes Terraform Terraform is declarative infrastructure reconciled against tracked state.
You do not write a script that says "call the AWS API to create a server, then call it again to open a firewall port." That is the imperative way, and it breaks the moment you run it twice - you get two servers, or an error, because the script does not know what already exists. Instead you describe the end result you want: "I want one server of this size, with this firewall, in this region." Terraform reads that description, compares it against what it already built (recorded in state), works out the difference, and makes exactly the API calls needed to close the gap. Nothing exists yet? It creates. Something changed? It updates or replaces. You deleted a block? It destroys.
This is why declarative beats scripts. Run the same Terraform config ten times and you get the same result - it only acts on the difference between what you asked for and what already exists. This property is called idempotency, and it is the whole point. A shell script full of aws create-... commands has to handle "does this already exist?" everywhere and usually does not; Terraform makes that question the core of how it works. Every concept below is a special case of this one loop: you declare, Terraform diffs against state, Terraform reconciles.
Providers and resources
Terraform itself knows nothing about AWS, or Kubernetes, or Cloudflare. It is a small engine, and all the actual knowledge lives in providers - plugins that translate your desired state into a specific platform's API calls. There is an aws provider, a google provider, an azurerm provider, a kubernetes provider, and hundreds more. You pick the providers you need and Terraform downloads them.
Each provider exposes resources: the individual pieces of infrastructure you can declare. The aws provider gives you aws_instance (a server), aws_s3_bucket (storage), aws_vpc (a network), and so on. A resource block is you saying "I want one of these, configured like this." That block is the unit of desired state - the thing Terraform will create, track, and later update or destroy.
The mental split is clean: the provider is the translator for a platform, and a resource is one object on that platform you want to exist. You spend almost all your time writing resource blocks; the provider is mostly setup you configure once.
A first real example
Here is a minimal but complete configuration that creates one server and a storage bucket on AWS. Read it top to bottom - it shows the three things every project has: a required-providers block, a provider block, and resource blocks.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "web" {
ami = "ami-0c101f26f147fa7fd"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-prod"
}
A few things worth naming. The terraform { required_providers { ... } } block pins which providers (and versions) this project uses, so terraform init knows what to download. The provider "aws" block configures that provider once - here just the region. Then each resource block has a type (aws_instance) and a local name (web) that you choose; together they form the address aws_instance.web, which is how you and Terraform refer to this specific resource everywhere else. The body sets the resource's arguments.
Resources can also refer to each other. If a resource needs a value from another, you write aws_instance.web.id and Terraform reads that as a dependency - it will create the referenced resource first. You almost never manage ordering by hand; Terraform builds a dependency graph from these references and figures out the order itself.
The core workflow: init, plan, apply, destroy
You drive Terraform with four commands, and they map directly onto the one idea. This is the loop you run every day.
terraform init # download providers, set up the working directory
terraform plan # show what WOULD change - a preview, no changes made
terraform apply # make reality match the config (asks to confirm first)
terraform destroy # tear down everything this config manages
terraform initprepares the working directory. It reads yourrequired_providers, downloads the provider plugins into a local.terraformdirectory, and sets up the backend where state will live. You run it once per project and again whenever you add a provider or change the backend. Nothing about your infrastructure changes.terraform planis the safety net, and it is the command that makes Terraform trustworthy. It compares your config against state and against the real world, then prints exactly what it would do - what it would create, change, or destroy - without touching anything. More on this next; it is the most important habit in the whole tool.terraform applyactually reconciles. It computes the same plan, shows it to you, waits for you to typeyes, and then makes the API calls to bring reality in line with your config. When it finishes, it updates state to record what now exists.terraform destroyis the reverse: it deletes every resource this configuration manages. Useful for tearing down short-lived environments, and something to run with real care against anything that matters.
The plan is a diff, and you always read it
terraform plan deserves its own moment because it is the feature that separates Terraform from "just run the script and hope." A plan is a diff: Terraform refreshes the current real-world state, compares it against your desired config, and shows you the exact set of changes it intends to make before a single API call goes out.
The output uses a simple symbol legend:
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c101f26f147fa7fd"
+ instance_type = "t3.micro"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
+means create - a new resource.-means destroy - a resource being deleted.~means update in place - an argument changed and Terraform can modify the existing resource.-/+means replace - the change cannot be done in place, so Terraform will destroy the old resource and create a new one. This is the line to watch: a-/+on a database or anything with data means you are about to lose it.
The habit that keeps you out of trouble: read the plan every time, and especially the summary line (N to add, N to change, N to destroy). Most Terraform disasters are not the tool doing something surprising - they are someone typing yes to a plan they did not read, and applying a destroy or a -/+ replace they did not intend. The plan told them; they skipped it. In CI you often save the plan to a file (terraform plan -out=tf.plan) and apply exactly that (terraform apply tf.plan), so what you reviewed is precisely what runs.
State: the map from config to real resources
When Terraform creates that aws_instance.web, it has to remember the actual AWS instance ID it got back, so that next time it knows this real server is the one your aws_instance.web block describes. That memory is state: a file (by default terraform.tfstate) that maps every resource address in your config to the real object it created, plus that object's last-known attributes.
State is why Terraform is declarative and idempotent. Without it, Terraform could not tell "create a new server" apart from "this server already exists, leave it alone" - it would have no record of what it built. The plan diff is really a three-way comparison between your config (what you want), state (what Terraform thinks exists), and the real world (what actually exists).
Because state is the source of truth about your infrastructure, it matters where it lives and who can touch it. On a real team you never keep it as a local file - you use a remote backend (S3, Terraform Cloud, and others) so state is shared, versioned, and locked to prevent two people applying at once. State can also contain secrets in plain text. This is important enough that it has its own guide: see Terraform state for backends, locking, terraform import, and how to recover when state and reality drift apart.
Variables and outputs, at a glance
The example above hard-codes everything. Real configurations do not - you parameterize them so the same code runs in dev, staging, and prod, and you expose the useful results. Terraform has two constructs for this.
Variables are inputs. You declare them, then reference them as var.name, and supply values per environment (via a .tfvars file, the CLI, or environment variables).
variable "instance_type" {
type = string
default = "t3.micro"
}
resource "aws_instance" "web" {
ami = "ami-0c101f26f147fa7fd"
instance_type = var.instance_type
}
Outputs are the values worth surfacing after an apply - a server's public IP, a bucket's name, a database endpoint - so you (or another tool) can read them without digging through the console.
output "web_ip" {
value = aws_instance.web.public_ip
}
Together these turn a one-off config into reusable infrastructure code: variables parameterize what goes in, outputs expose what comes out. There is more to both - types, validation, sensitive values, and how outputs feed between configurations - covered in Terraform variables and outputs. Once you are comfortable parameterizing a single config, the natural next step is packaging reusable pieces into modules, so you stop copy-pasting the same blocks across projects; that is Terraform modules.
The shape of it
Everything here is one idea wearing different hats: you declare the infrastructure you want, and Terraform reconciles reality to match, tracking what it built in state. Providers teach Terraform how to talk to a given platform; resources are the individual objects you declare. The workflow is always the same loop - init to set up, plan to preview the diff, apply to reconcile, destroy to tear down - and the one habit that keeps you safe is reading the plan's diff and summary line before you type yes. State is the map that makes the whole thing idempotent, and variables and outputs turn a fixed config into reusable code. Internalize the declare-diff-reconcile loop and Terraform stops being a pile of HCL and becomes a system you can reason about.