Clicking through a cloud console is fine the first time. By the third server, one of them has a slightly different security group, nobody remembers why, and the “documentation” is a screenshot buried in someone’s chat history. Terraform is the tool people reach for to stop that from happening.
This post walks through how it actually works: the workflow, state, the dependency graph and what happens to a resource from creation to removal. The demo creates a text file instead of a cloud server, so there’s no account to set up and no bill to worry about. Everything below was run on Ubuntu 26.04.1 LTS and AlmaLinux 10.2 with Terraform 1.16.6.
What Terraform is, and what it fixes
Terraform lets you describe infrastructure in text files, then builds it for you. You don’t write the steps. You write what you want to end up with, and Terraform works out the steps. That’s the “declarative” part people keep mentioning.
It solves a few annoying problems at once:
- Snowflake servers. Two environments built by hand are never quite identical. Two built from the same files are.
- No history. Config in git has a diff, an author and a reviewer. A console click has none of those.
- Slow repeats. Rebuilding a whole stack becomes one command instead of an afternoon.
- Forgotten leftovers. Tearing down means Terraform removes what it created, not what you happen to remember.
How the pieces fit together
Three things matter here. Terraform core is the binary you install. It reads your files, decides what has to change and in what order. Providers are plugins that know how to talk to one platform’s API, such as AWS, Google Cloud, Kubernetes or even a local filesystem. And state is a file where Terraform records what it has built.
Core never calls AWS itself. It hands the provider a request like “create this”, and the provider turns that into the right API calls. That’s why the same workflow works on every platform, and why a provider bug can look like a Terraform bug when it isn’t.
Install Terraform
HashiCorp publishes its own package repositories. On Ubuntu:
sudo apt update && sudo apt install -y gnupg curl lsb-release
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
On AlmaLinux, Rocky or RHEL:
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y terraform
terraform version
Terraform v1.16.6
on linux_amd64
Older versions of these steps are in setting up Terraform on Ubuntu 23.04 and installing Terraform on CentOS 8 if you’re on something older.
A demo project with no cloud account
Make a folder and put this in main.tf:
terraform {
required_providers {
random = { source = "hashicorp/random" }
local = { source = "hashicorp/local" }
}
}
resource "random_pet" "name" {
length = 2
}
resource "local_file" "greeting" {
filename = "${path.module}/hello-${random_pet.name.id}.txt"
content = "Hello from ${random_pet.name.id}\n"
}
output "file_path" {
value = local_file.greeting.filename
}
Two resources. random_pet picks a silly two-word name. local_file writes a file with that name in its filename. Keep an eye on that reference to random_pet.name.id, because it comes back later.
The workflow: init, plan, apply, destroy
terraform init downloads the providers your files ask for and sets up the folder:
terraform init
Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
Terraform has been successfully initialized!
terraform plan compares what you wrote with what exists, and shows the difference without changing anything:
terraform plan
# local_file.greeting will be created
# random_pet.name will be created
Plan: 2 to add, 0 to change, 0 to destroy.
terraform apply makes it happen. It shows the plan again and asks for a yes, or you can skip the prompt with -auto-approve in scripts:
terraform apply -auto-approve
random_pet.name: Creating...
random_pet.name: Creation complete after 0s [id=pretty-sunbird]
local_file.greeting: Creating...
local_file.greeting: Creation complete after 0s
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello-pretty-sunbird.txt"
The last command, terraform destroy, removes everything Terraform built. We’ll run it at the end. Run plan again right now and you get the line you want to see in real life:
No changes. Your infrastructure matches the configuration.
Desired, recorded and actual state
This is the part that confuses most beginners, so here’s the plain version. There are three versions of the truth:
- Desired state is what your
.tffiles say should exist. - Recorded state is what Terraform wrote down last time, in
terraform.tfstate. - Actual state is what’s really out there right now.
A plan is Terraform reconciling all three. It refreshes the record against reality, then compares that to your files. You can see the record yourself:
terraform state list
local_file.greeting
random_pet.name
terraform state show random_pet.name
# random_pet.name:
resource "random_pet" "name" {
id = "pretty-sunbird"
length = 2
separator = "-"
}
Now break something on purpose. Edit the file by hand, the way a colleague might “just fix it quickly” in a console:
echo "edited by hand" > hello-pretty-sunbird.txt
terraform plan
# local_file.greeting will be created
Plan: 1 to add, 0 to change, 0 to destroy.
terraform apply -auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
cat hello-pretty-sunbird.txt
Hello from pretty-sunbird
Terraform noticed the real file no longer matched its record, treated the original as gone, and put it back. Cloud resources behave the same way when someone changes a setting outside Terraform. That’s called drift.
Sometimes you want to accept reality instead of undoing it. That’s what a refresh-only plan is for. It updates the record and touches nothing else:
rm hello-pretty-sunbird.txt
terraform plan -refresh-only
Note: Objects have changed outside of Terraform
# local_file.greeting has been deleted
This is a refresh-only plan, so Terraform will not take any actions to undo
these.
How the dependency graph gets built
Remember the reference to random_pet.name.id inside the file resource? That’s how Terraform knows the pet has to exist before the file. You never wrote “do this first”. The reference said it for you.
terraform graph
digraph G {
rankdir = "RL";
"local_file.greeting" -> "random_pet.name";
}
Read the arrow as “depends on”. Resources with no link between them get built in parallel, ten at a time by default. And on the way out, the graph runs backwards:
terraform destroy -auto-approve
local_file.greeting: Destroying...
local_file.greeting: Destruction complete after 0s
random_pet.name: Destroying...
random_pet.name: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.
File first, pet second. If you ever need to force an order that no reference creates, there’s a depends_on argument, but reach for it last. A normal reference is almost always the better answer.
A resource’s life: create, update, replace, destroy
Plans mark every action with a symbol:
| Symbol | Meaning | When it happens |
|---|---|---|
+ |
Create | It’s in your files but not in the record |
~ |
Update in place | A setting changed and the platform allows changing it live |
-/+ |
Replace | A setting changed that can’t be edited, so it’s destroyed and rebuilt |
- |
Destroy | It’s in the record but gone from your files |
The replace one is the one to watch. Change the file’s content in main.tf and run apply:
# local_file.greeting must be replaced
Plan: 1 to add, 0 to change, 1 to destroy.
local_file.greeting: Destroying...
local_file.greeting: Creating...
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
Here that costs nothing. On a database or a server, a replace means data loss or downtime, so always read the plan for -/+ before you approve. When something must never be deleted by accident, a lifecycle block helps:
resource "local_file" "greeting" {
# ...
lifecycle {
prevent_destroy = true
}
}
With that in place, any plan that would destroy the resource fails instead. create_before_destroy and ignore_changes live in the same block and cover the other two common cases.
Terraform words you’ll keep hearing
| Term | What it means |
|---|---|
| Provider | Plugin that talks to one platform’s API |
| Resource | One thing Terraform manages, such as a server or a file |
| Data source | Read-only lookup of something that already exists |
| Variable | An input, so one config can serve several environments |
| Output | A value printed or passed on after apply |
| State | Terraform’s record of what it built |
| Backend | Where state is stored, local disk or something shared |
| Module | A reusable folder of Terraform code |
Where Terraform fits, and where it doesn’t
It’s a strong fit for building and repeating cloud infrastructure: networks, servers, databases, DNS, Kubernetes clusters. The limits are worth knowing before you hit them:
- It builds infrastructure, it doesn’t configure what’s inside. Installing packages on a server is a job for Ansible, cloud-init or a similar tool.
- State is sensitive. It can hold passwords and keys in plain text. Teams keep it in a remote backend with locking, never in git.
- Drift is only found when you run a plan. Nothing watches your cloud between runs.
- Providers set the rules. If the API can’t do something, Terraform can’t either.
- Licensing changed. Since 2023 Terraform ships under HashiCorp’s Business Source License. OpenTofu is the community fork if that matters to you.
Setup confirmation
To check your install works end to end, run the demo from start to finish. This is the output from Ubuntu 26.04.1 LTS:
lsb_release -d
Description: Ubuntu 26.04.1 LTS
terraform version
Terraform v1.16.6
on linux_amd64
terraform validate
Success! The configuration is valid.
terraform apply -auto-approve
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello-pretty-sunbird.txt"
ls
hello-pretty-sunbird.txt main.tf terraform.tfstate
terraform plan
No changes. Your infrastructure matches the configuration.
terraform destroy -auto-approve
Destroy complete! Resources: 2 destroyed.
And the same steps on AlmaLinux 10.2:
cat /etc/redhat-release
AlmaLinux release 10.2 (Lavender Lion)
terraform version
Terraform v1.16.6
on linux_amd64
terraform apply -auto-approve
random_pet.name: Creation complete after 0s [id=blessed-owl]
local_file.greeting: Creation complete after 0s
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello-blessed-owl.txt"
cat hello-blessed-owl.txt
Hello from blessed-owl
terraform plan
No changes. Your infrastructure matches the configuration.
terraform destroy -auto-approve
Destroy complete! Resources: 2 destroyed.
Your pet name will differ, since it’s random. What matters is the version line, “Apply complete” and a clean “No changes” on the second plan. There’s no web page involved here, so there’s no screenshot to show.
Frequently asked questions
Is Terraform free to use?
The command line tool is free to download and use. HashiCorp’s hosted platform has paid tiers, but you don’t need it to follow anything in this post.
What happens if I lose the state file?
Terraform forgets it ever built those resources. A plan will want to create them all again, which usually means duplicates or errors. You can recover with terraform import, but it’s tedious, so keep state in a remote backend with versioning.
Does Terraform replace Ansible?
No, they do different jobs. Terraform creates the servers and networks. Ansible sets up what runs on them. Plenty of teams use both.
Why does a tiny change sometimes show a replace?
Some settings can’t be edited on a live resource. The provider marks them as forcing replacement, and Terraform tears down and rebuilds. The plan always says so, which is why reading it matters.
Where to go next
Once the demo makes sense, swap the local provider for a real one and build something small. The Ultimate Guide to Installing and Using Terraform covers the everyday commands in more depth, and creating a GKE cluster with Terraform is a good first real project.
