Terraform – Install It and Run Your First Apply

July 13, 20267 min readUpdated 8/24/2026

Terraform has about thirty subcommands and you will use four of them. This lesson installs it, points it at AWS, and creates something real — then destroys it, so the lesson costs nothing.

Install it

It is a single Go binary with no runtime and no dependencies. Every install method is a way of putting that one file on your PATH.

brew tap hashicorp/tap && brew install hashicorp/tap/terraform   # macOS
choco install terraform                                          # Windows
# Linux: add the HashiCorp apt/yum repo, or just download the zip

terraform version

If you would rather not use a package manager — or yours refuses, which happens — download the zip from HashiCorp's release site, verify it, and drop the binary somewhere on your PATH. The checksum step is worth doing on something that will hold your cloud credentials:

V=1.15.9
curl -fLO "https://releases.hashicorp.com/terraform/${V}/terraform_${V}_darwin_arm64.zip"
curl -fLO "https://releases.hashicorp.com/terraform/${V}/terraform_${V}_SHA256SUMS"
shasum -a 256 -c terraform_${V}_SHA256SUMS --ignore-missing

unzip terraform_${V}_darwin_arm64.zip -d ~/bin

This track is written against Terraform 1.15.9 and AWS provider 6.61.0.

Point it at AWS

Terraform does not have its own credentials. The AWS provider uses exactly the same chain the AWS CLI does — environment variables, then ~/.aws/credentials, then an instance or container role. If the CLI works, Terraform works.

aws configure          # or: export AWS_PROFILE=your-profile
aws sts get-caller-identity

If that last command prints your account id, you are ready. If it does not, fix that first — every Terraform error you would otherwise get is a worse version of the same message.

Write something

Make an empty directory and put this in main.tf. The filename does not matter; Terraform reads every .tf file in the directory and treats them as one configuration.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "us-west-2"
}

resource "aws_s3_bucket" "first" {
  # ⚠️ Change this. S3 bucket names are globally unique across every AWS
  # account on earth, so "my-bucket" was taken in 2006.
  bucket = "tf-first-bucket-change-me-8f3a1c"
}

Three blocks, and they are the three you will see at the top of almost every Terraform project: which providers this configuration needs, how to configure one, and a thing that should exist.

init

terraform init

init reads required_providers, downloads the AWS provider plugin into .terraform/, and writes .terraform.lock.hcl recording the exact versions and checksums it chose.

The provider is large — the AWS one is several hundred megabytes, because it contains a generated client for every AWS service. That is why the first init takes a while and every later one is instant. Run it again in a new directory and it downloads again, which is what TF_PLUGIN_CACHE_DIR exists to fix.

You run init once per directory, and again whenever you add a provider, change a version constraint, or add a module.

plan

This is the command that makes Terraform worth using.

terraform plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_s3_bucket.first will be created
  + resource "aws_s3_bucket" "first" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "tf-first-bucket-change-me-8f3a1c"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      ... plus tags_all and nine nested blocks, all (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Nothing has happened yet. plan reads your files, reads the state, calls AWS to see what is actually there, and prints the difference.

Note that you wrote one argument and Terraform is tracking about twenty. Everything you did not set has a default or is assigned by AWS, and Terraform records all of it — which is what lets it tell you later that something changed underneath you.

Learn to read the symbols now, because one of them is the reason this command exists:

+   create
~   update in place
-   destroy
-/+ destroy and then create      <-- REPLACEMENT. read this one carefully.
+/- create and then destroy      <-- replacement, with create_before_destroy

-/+ on a database is how people lose data. Some arguments cannot be changed on a live resource, so changing them means AWS deletes the old one and makes a new one. Terraform tells you, in the plan, and says why — forces replacement appears next to the offending attribute. The whole discipline of using Terraform safely is reading that output before typing yes.

(known after apply) just means AWS assigns it — an ARN cannot be known before the thing exists.

apply

terraform apply

It prints the plan again and waits for you to type yes. Nothing is created before that.

aws_s3_bucket.first: Creating...
aws_s3_bucket.first: Creation complete after 1s [id=tf-first-bucket-change-me-8f3a1c]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The bucket exists. There is now also a terraform.tfstate file in the directory — Terraform's record of what it built and what every attribute came out as. Lesson 5 is about that file; for now, know that it is important and that it does not belong in git.

Change something

This is the loop you will actually live in. Add tags to the bucket:

resource "aws_s3_bucket" "first" {
  bucket = "tf-first-bucket-change-me-8f3a1c"

  tags = {
    Environment = "learning"
  }
}
terraform plan
  # aws_s3_bucket.first will be updated in-place
  ~ resource "aws_s3_bucket" "first" {
        id                          = "tf-first-bucket-change-me-8f3a1c"
      ~ tags                        = {
          + "Environment" = "learning"
        }
      ~ tags_all                    = {
          + "Environment" = "learning"
        }
        # (14 unchanged attributes hidden)

        # (3 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

tags is what you wrote; tags_all is what the resource ends up with once any provider-level default tags are merged in. Lesson 12 uses that feature; for now it is enough to know why the same change appears twice.

~ and "updated in-place" — it will modify the existing bucket, not replace it. Apply that, then run terraform plan a third time:

No changes. Your infrastructure matches the configuration.

That is the property that makes this a tool rather than a script. Terraform is not "run the commands"; it is "make reality match the file", and when it already does, it does nothing. You can run apply as many times as you like.

destroy

terraform destroy

It prints everything it is about to delete and asks for yes. Do it now — an empty S3 bucket costs nothing, but the habit is the point, and every AWS lesson in this track ends the same way.

aws_s3_bucket.first: Destroying... [id=tf-first-bucket-change-me-8f3a1c]
aws_s3_bucket.first: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.

⚠️ destroy deletes everything in the state file, not just the thing you were last looking at. In a directory holding your production VPC, that is exactly as bad as it sounds. Lesson 15 covers prevent_destroy, and lesson 11 covers keeping production in a directory of its own so a careless destroy cannot reach it.

The four commands

terraform init      download providers, prepare the directory   (once, and after adding one)
terraform plan      show me the difference, change nothing      (constantly)
terraform apply     make it so, after showing me and asking     (deliberately)
terraform destroy   delete everything in this state             (carefully)

Two more you will want on day one:

terraform fmt       rewrite files in canonical style
terraform validate  check syntax and provider schema, no AWS calls needed

validate is the fast one. It catches a misspelled argument or a resource type that does not exist without touching AWS at all, because the provider ships a schema of everything it supports. It is what every example in this track is checked with before it is published.

What not to commit

Before you put any of this in git:

.terraform/          # the downloaded providers. Hundreds of MB, regenerable.
terraform.tfstate    # ⚠️ SECRETS. see below.
terraform.tfstate.*
*.tfvars             # real values for variables
*.tfplan             # a saved plan contains every value the apply would write

State files contain secrets in cleartext. Not hashed, not encrypted — if a resource has a password attribute, the password is in that file as text. This is the single most common way infrastructure credentials end up in a public repository. Lesson 8 moves state somewhere it belongs; until then, keep it local and keep it out of git.

.terraform.lock.hcl is the exception — that one is committed, and lesson 4 explains why.

Next

You now have the loop. Lesson 3 covers the language itself — the block types, the type system, and the handful of functions worth knowing — so that the configurations from here on read as obvious rather than as magic.