Clicking through a cloud console works fine right up until it doesn’t. You build a VPC, a couple of subnets, some security groups, an instance or two. Three months later somebody asks you to build the same thing in a second region, and nobody can remember which boxes were ticked. Terraform exists because that situation is miserable and completely avoidable.
This guide covers the commands you’ll actually type, what their output means, and the handful of problems that catch people out in the first few months. Install instructions are here too, though if you want the longer walkthrough for a specific distribution there are dedicated guides for setting up Terraform on Ubuntu and installing Terraform on CentOS 8.
What Terraform actually does
You write what you want in configuration files. Terraform works out what already exists, compares the two, and makes the smallest set of changes needed to close the gap. That comparison is the whole product.
It keeps a record of what it built in a state file. State is how Terraform knows the instance in your config is the same instance running in AWS, rather than a request to build a second one. Nearly every confusing thing Terraform does traces back to state, which is why it gets its own section further down.
Installing Terraform
HashiCorp maintain their own repositories. Use them rather than downloading a zip, because you get updates through the normal package manager.
Ubuntu and Debian
wget -O- 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 terraform
RHEL, Rocky and CentOS
sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo sudo yum -y install terraform
Confirming it works
$ terraform version Terraform v1.9.5 on linux_amd64
Two things worth doing straight away. Turn on tab completion, then check it took:
$ terraform -install-autocomplete $ exec $SHELL
The core workflow
Four commands carry almost everything: init, plan, apply, destroy. Learn these properly and the rest is detail.
terraform init
Run once in a new directory, and again whenever you add a provider or change the backend. It downloads providers and sets up the working directory.
$ 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. Terraform has been successfully initialized!
Commit that lock file. It pins provider versions so your colleague and your CI runner get the same ones you did, which is exactly the sort of thing you want pinned.
terraform plan
Plan shows what would change without changing anything. Read it every time.
$ terraform plan
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c7217cdde317cfec"
+ instance_type = "t3.micro"
+ id = (known after apply)
+ private_ip = (known after apply)
+ tags = {
+ "Name" = "web-01"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
The symbols are the important part:
+ create - destroy ~ update in place -/+ destroy and recreate
That last one deserves attention. -/+ means the resource can’t be changed in place, so Terraform will delete it and build a new one. On a database or a volume that’s a genuinely bad afternoon. If you see it where you didn’t expect it, stop and work out why before applying.
Save the plan when it matters:
$ terraform plan -out=tfplan $ terraform apply tfplan
Applying a saved plan means you apply exactly what you reviewed, not whatever the world looks like thirty seconds later.
terraform apply
$ terraform apply 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 32s [id=i-0a1b2c3d4e5f67890] Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Note it wants the literal word yes, not y. There’s -auto-approve, and it belongs in a CI pipeline that already ran a reviewed plan. Typing it by hand on a laptop is how people delete production.
terraform destroy
$ terraform destroy Plan: 0 to add, 0 to change, 1 to destroy. Enter a value: yes aws_instance.web: Destroying... [id=i-0a1b2c3d4e5f67890] aws_instance.web: Destruction complete after 41s Destroy complete! Resources: 1 destroyed.
Brilliant for tearing down test environments. Terrifying everywhere else. Target a single resource when that’s what you mean:
$ terraform destroy -target=aws_instance.web
Commands you will use every day
terraform fmt and validate
Formatting first, then a syntax check:
$ terraform fmt -recursive main.tf modules/network/variables.tf $ terraform validate Success! The configuration is valid.
fmt prints the files it rewrote. validate catches syntax errors and bad references, but it doesn’t talk to your cloud provider, so it will happily approve a config that references an AMI that doesn’t exist. Both belong in a pre-commit hook.
terraform output
$ terraform output instance_id = "i-0a1b2c3d4e5f67890" public_ip = "54.221.18.44" $ terraform output -raw public_ip 54.221.18.44
The -raw form is what you want in scripts, since it drops the quotes and the newline.
terraform state list and show
$ terraform state list
aws_instance.web
aws_security_group.web_sg
aws_vpc.main
$ terraform state show aws_instance.web
# aws_instance.web:
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
id = "i-0a1b2c3d4e5f67890"
instance_type = "t3.micro"
private_ip = "10.0.1.24"
public_ip = "54.221.18.44"
}
These two answer most “why is Terraform doing that” questions. list tells you what it thinks it manages, show tells you what it recorded about one thing.
Three problems you will actually hit
1. State locked after a crash
Your laptop sleeps mid-apply, or a pipeline gets cancelled. The next run refuses to start:
Error: Error acquiring the state lock Lock Info: ID: 8f3c1a92-4d17-4e2a-9b3f-71c2d5e08a44 Operation: OperationTypeApply Who: asif@build-01 Created: 2024-08-05 09:12:41.882 +0000 UTC
Terraform’s protecting you from two applies running at once. Confirm nobody else is actually running one, then release it with the ID from the message:
$ terraform force-unlock 8f3c1a92-4d17-4e2a-9b3f-71c2d5e08a44
Check first. Forcing a lock while a colleague’s apply is genuinely running is how state gets corrupted.
2. Somebody changed things in the console
A plan reports changes you never made. Usually that means someone edited the resource by hand, and Terraform wants to put it back. See the drift without applying anything:
$ terraform plan -refresh-only
Then decide. Either let the next apply revert the manual change, or update your config to match reality if the change was correct. What you shouldn’t do is ignore it, because the gap only widens.
3. A resource is broken and you want it rebuilt
An instance came up wrong and no config change will fix it. The old answer was terraform taint, which is deprecated. Use replace instead:
$ terraform apply -replace="aws_instance.web"
That marks the one resource for destroy and recreate, leaving everything else alone.
Best practices worth adopting early
Use remote state. A local terraform.tfstate is fine for one person learning. The moment two people touch the same infrastructure it becomes a problem, and remote state with locking solves it.
Never commit state files. They contain resource IDs, IPs, and depending on your providers, secrets in plain text. Put terraform.tfstate, *.tfstate.backup and .terraform/ in .gitignore before your first commit.
Pin your provider versions. Commit the lock file and set a version constraint. An unpinned provider will upgrade itself one morning and produce a plan nobody expected.
Plan before apply, always. Even for a change you’re sure about. It costs ten seconds and it’s the only thing standing between you and a -/+ on a database you forgot was in scope.
Conclusion
The whole thing runs on four commands: init to set up, plan to preview, apply to build, destroy to tear down. Everything else is either a diagnostic tool or a way out of a mess.
What separates people who are comfortable with Terraform from people who are nervous about it isn’t knowing more commands. It’s reading the plan properly every single time, keeping state somewhere safe, and understanding that -/+ means something is about to be deleted. Get those three right, and the rest follows.

