The Ultimate Guide to Installing and Using Terraform

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.

Avatar photo

Asif Khan

I have spent over 10 years working across IT systems, open source software, DevOps, Linux administration and cloud operations. Three things drive most of what I do: automation, security and resilience. Much of that work involves planning and building the platforms that sit behind services people rely on daily, which means designing for failure just as carefully as for load. Cloud computing held my attention early on, largely for its flexibility. Being able to scale up and then back down again means far less guessing about how much capacity you will need. Across projects I work with the full DevOps toolchain, from provisioning, orchestration and configuration management through to release management and microservices architecture.