πŸ—οΈ Terraform Basics: Describe It, Plan It, Apply It

Infrastructure as code from the first file to the state, run for real with providers that only make files: init, plan, apply, change, drift, state, for_each against count, and then how the same steps point at AWS, Azure, Google Cloud and Oracle Cloud

πŸ’‘ How this page was tested. Unlike the account and services pages, almost all of this page was run for real. On 6 October 2026 I downloaded Terraform 1.16.5 from HashiCorp's release server, checked its SHA-256 checksum against HashiCorp's published list (it matched), and ran it in a throwaway folder with its own empty home directory. To avoid any cloud account, I used two providers that make nothing in the cloud: local (it creates files on this machine) and random (it makes a random name). So every plan, apply, error and state file below is real output, and the "resources" were small text files. The section on real cloud providers is from the documentation, not run here. The folder was deleted afterwards.

What Terraform is

Terraform is a tool for infrastructure as code. You write down what you want (a network, a server, a bucket) in text files. Terraform compares that with what exists and works out the changes needed. You review them, and then it makes them. Three ideas carry everything else:

  1. Declarative. You describe the end result ("a bucket with this name"), not the steps. If it already exists, Terraform does nothing.
  2. Plan, then apply. Terraform shows you exactly what it would do (the plan) before it does anything (the apply). That one habit is most of its value.
  3. State. Terraform keeps a record of what it made, so it knows what to change or delete later.

It reaches each cloud through a provider, a plug-in for that service. The same tool, the same commands and the same files work for AWS, Azure, Google Cloud, Oracle Cloud and hundreds of other services. It is the cross-cloud counterpart of the provider-specific tools in the services maps (Azure's Bicep and ARM templates, AWS CloudFormation).

A note on the licence. The LICENSE.txt in the download is the Business Source License, which is "source-available" and not an open-source licence. There is an open-source fork called OpenTofu, which uses the same commands and file formats under the name tofu (from the documentation, not run here). Everything on this page applies to both.

Install

Terraform is a single program. I downloaded the zip, verified it and unpacked it:

curl -O https://releases.hashicorp.com/terraform/1.16.5/terraform_1.16.5_linux_amd64.zip
curl -O https://releases.hashicorp.com/terraform/1.16.5/terraform_1.16.5_SHA256SUMS
grep linux_amd64.zip terraform_1.16.5_SHA256SUMS | sha256sum -c -
unzip terraform_1.16.5_linux_amd64.zip

(sha256sum -c printed OK.) Package managers and the HashiCorp apt repository exist too (from the documentation, not run here). Checking the checksum before you run a downloaded program is a good habit for anything you install by hand.

user@lab:~/demo$ terraform version
Terraform v1.16.5
on linux_amd64
user@lab:~/demo$ terraform -help | head -n 24
Usage: terraform [global options] <subcommand> [args]

The available commands for execution are listed below.
The primary workflow commands are given first, followed by
less common or more advanced commands.

Main commands:
  init          Prepare your working directory for other commands
  validate      Check whether the configuration is valid
  plan          Show changes required by the current configuration
  apply         Create or update infrastructure
  destroy       Destroy previously-created infrastructure

All other commands:
  console       Try Terraform expressions at an interactive command prompt
  fmt           Reformat your configuration in the standard style
  force-unlock  Release a stuck lock on the current workspace
  get           Install or upgrade remote Terraform modules
  graph         Generate a Graphviz graph of the steps in an operation
  import        Associate existing infrastructure with a Terraform resource
  login         Obtain and save credentials for a remote host
  logout        Remove locally-stored credentials for a remote host
  metadata      Metadata related commands
  modules       Show all declared modules in a working directory

terraform version prints the version, and terraform -help lists the commands. The five main commands are the ones in the first block: init, validate, plan, apply, destroy. The rest (console, fmt, graph, import, state, output...) support them.

The first file

Terraform reads every file ending in .tf in the current folder. The language is HCL. This is the whole starting configuration, in a file called main.tf:

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

variable "project" {
  type        = string
  description = "Name of the project, used in the file name"
  default     = "lab"
}

resource "random_pet" "suffix" {
  length = 2
}

resource "local_file" "notes" {
  filename = "${path.module}/out/${var.project}-${random_pet.suffix.id}.txt"
  content  = "Project: ${var.project}\nManaged by Terraform\n"
}

output "file_path" {
  value = local_file.notes.filename
}

The block types:

init: download the providers

A new folder needs terraform init. First, to see what happens without it, terraform validate was run before init:

user@lab:~/demo$ terraform validate
β•·
β”‚ Error: Missing required provider
β”‚ 
β”‚ This configuration requires provider registry.terraform.io/hashicorp/local,
β”‚ but that provider isn't available. You may be able to install it
β”‚ automatically by running:
β”‚   terraform init
β•΅
β•·
β”‚ Error: Missing required provider
β”‚ 
β”‚ This configuration requires provider
β”‚ registry.terraform.io/hashicorp/random, but that provider isn't available.
β”‚ You may be able to install it automatically by running:
β”‚   terraform init
β•΅
user@lab:~/demo$ terraform init -no-color
Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Finding hashicorp/random versions matching "~> 3.6"...
- 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 created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
user@lab:~/demo$ ls -a
.
..
main.tf
.terraform
.terraform.lock.hcl
user@lab:~/demo$ head -n 12 .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/local" {
  version     = "2.9.1"
  constraints = "~> 2.5"
  hashes = [
    "h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=",
    "zh:25606c7a5e308144fb627f6e31611bb52ff72bb9ae2d27af39673ab1a6b3c1bf",
    "zh:2568c4ef4dab31821f6f7040af0d1a2aa2b9455b8d9cc546598b791b8ada34cd",
    "zh:25ac210f136042047975896e5e11fe6012425301c826d6b7ce22a49f96a1e0e8",
    "zh:55d3a7bf01eced8e1f259b548020ef1221c5687e0fe6b5b30f81ad0b4121ff12",
user@lab:~/demo$ du -sh .terraform
35M	.terraform
user@lab:~/demo$ terraform validate
Success! The configuration is valid.

validate and fmt: catch mistakes early

user@lab:~/demo$ terraform validate
β•·
β”‚ Error: Reference to undeclared resource
β”‚ 
β”‚   on main.tf line 25, in resource "local_file" "notes":
β”‚   25:   filename = "${path.module}/out/${var.project}-${random_pet.suffx.id}.txt"
β”‚ 
β”‚ A managed resource "random_pet" "suffx" has not been declared in the root
β”‚ module.
β•΅
user@lab:~/demo$ terraform fmt -check -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -18,7 +18,7 @@
 }
 
 resource "random_pet" "suffix" {
-length=2
+  length = 2
 }
 
 resource "local_file" "notes" {
user@lab:~/demo$ terraform fmt
main.tf
user@lab:~/demo$ terraform validate
Success! The configuration is valid.

plan: read before you act

user@lab:~/demo$ terraform plan -no-color

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:

  # local_file.notes will be created
  + resource "local_file" "notes" {
      + content              = <<-EOT
            Project: lab
            Managed by Terraform
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = (known after apply)
      + id                   = (known after apply)
    }

  # random_pet.suffix will be created
  + resource "random_pet" "suffix" {
      + id        = (known after apply)
      + length    = 2
      + separator = "-"
    }

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

Changes to Outputs:
  + file_path = (known after apply)

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Learn to read this, because it is the safety net:

apply: make it so

user@lab:~/demo$ terraform apply -auto-approve -no-color

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:

  # local_file.notes will be created
  + resource "local_file" "notes" {
      + content              = <<-EOT
            Project: lab
            Managed by Terraform
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = (known after apply)
      + id                   = (known after apply)
    }

  # random_pet.suffix will be created
  + resource "random_pet" "suffix" {
      + id        = (known after apply)
      + length    = 2
      + separator = "-"
    }

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

Changes to Outputs:
  + file_path = (known after apply)
random_pet.suffix: Creating...
random_pet.suffix: Creation complete after 0s [id=alert-slug]
local_file.notes: Creating...
local_file.notes: Creation complete after 0s [id=3673cc2b23d3c91877c322c0359ae7763a38fe8f]

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

Outputs:

file_path = "./out/lab-alert-slug.txt"
user@lab:~/demo$ ls out; cat out/*.txt
lab-alert-slug.txt
Project: lab
Managed by Terraform
user@lab:~/demo$ terraform output
file_path = "./out/lab-alert-slug.txt"
user@lab:~/demo$ terraform output -raw file_path
./out/lab-alert-slug.txt

Running plan again straight away:

user@lab:~/demo$ terraform plan -no-color
random_pet.suffix: Refreshing state... [id=alert-slug]
local_file.notes: Refreshing state... [id=3673cc2b23d3c91877c322c0359ae7763a38fe8f]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

"No changes. Your infrastructure matches the configuration." This is the declarative idea at work: Terraform refreshes (checks each object it made), finds nothing to do, and does nothing. You can run apply as often as you like.

Change something

Changing the variable (-var project=blog) changes the file name and content:

user@lab:~/demo$ terraform plan -var project=blog -no-color
random_pet.suffix: Refreshing state... [id=alert-slug]
local_file.notes: Refreshing state... [id=3673cc2b23d3c91877c322c0359ae7763a38fe8f]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.notes must be replaced
-/+ resource "local_file" "notes" {
      ~ content              = <<-EOT # forces replacement
          - Project: lab
          + Project: blog
            Managed by Terraform
        EOT
      ~ content_base64sha256 = "+bDvlUGiDf1UfeKdzQ+K2kya0OQTBZUVHtw+OIX7HIo=" -> (known after apply)
      ~ content_base64sha512 = "xClhtOFKPMqJItIV7VFcB+O1/6XyDCdLpORTu4VNYQFFSDpuwwUNNdUFF6AN0IER/v8Y4vXkj39qijfg3p876A==" -> (known after apply)
      ~ content_md5          = "853610c9e2830a80efea997ece3e06ee" -> (known after apply)
      ~ content_sha1         = "3673cc2b23d3c91877c322c0359ae7763a38fe8f" -> (known after apply)
      ~ content_sha256       = "f9b0ef9541a20dfd547de29dcd0f8ada4c9ad0e4130595151edc3e3885fb1c8a" -> (known after apply)
      ~ content_sha512       = "c42961b4e14a3cca8922d215ed515c07e3b5ffa5f20c274ba4e453bb854d610145483a6ec3050d35d50517a00dd08111feff18e2f5e48f7f6a8a37e0de9f3be8" -> (known after apply)
      ~ filename             = "./out/lab-alert-slug.txt" -> "./out/blog-alert-slug.txt" # forces replacement
      ~ id                   = "3673cc2b23d3c91877c322c0359ae7763a38fe8f" -> (known after apply)
        # (2 unchanged attributes hidden)
    }

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

Changes to Outputs:
  ~ file_path = "./out/lab-alert-slug.txt" -> "./out/blog-alert-slug.txt"

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
user@lab:~/demo$ terraform apply -var project=blog -auto-approve -no-color | tail -n 12
Changes to Outputs:
  ~ file_path = "./out/lab-alert-slug.txt" -> "./out/blog-alert-slug.txt"
local_file.notes: Destroying... [id=3673cc2b23d3c91877c322c0359ae7763a38fe8f]
local_file.notes: Destruction complete after 0s
local_file.notes: Creating...
local_file.notes: Creation complete after 0s [id=0594a53b3b6326b990c46d2dd06f0a4d29ed9d00]

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

Outputs:

file_path = "./out/blog-alert-slug.txt"
user@lab:~/demo$ ls out
blog-alert-slug.txt

Drift: someone changed it by hand

I deleted the file myself, outside Terraform:

user@lab:~/demo$ rm out/*.txt; ls out | wc -l
0
user@lab:~/demo$ terraform plan -var project=blog -no-color
random_pet.suffix: Refreshing state... [id=alert-slug]
local_file.notes: Refreshing state... [id=0594a53b3b6326b990c46d2dd06f0a4d29ed9d00]

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:

  # local_file.notes will be created
  + resource "local_file" "notes" {
      + content              = <<-EOT
            Project: blog
            Managed by Terraform
        EOT
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./out/blog-alert-slug.txt"
      + id                   = (known after apply)
    }

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

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
user@lab:~/demo$ terraform apply -var project=blog -auto-approve -no-color | tail -n 6

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

Outputs:

file_path = "./out/blog-alert-slug.txt"

plan refreshed its state, noticed that the file no longer exists, and planned to create it again. Apply did. That is how Terraform handles drift: reality is compared with your files every time, and your files win. The same applies to a cloud resource that someone edited in the console: Terraform will undo the change at the next apply (unless the file says otherwise), which is a feature, and a surprise if you did not know.

State: the record

user@lab:~/demo$ terraform state list
local_file.notes
random_pet.suffix
user@lab:~/demo$ terraform state show random_pet.suffix
# random_pet.suffix:
resource "random_pet" "suffix" {
    id        = "alert-slug"
    length    = 2
    separator = "-"
}
user@lab:~/demo$ ls -l terraform.tfstate*
-rw-rw-r-- 1 user user 2335 Oct  6 16:28 terraform.tfstate
-rw-rw-r-- 1 user user 2332 Oct  6 16:28 terraform.tfstate.backup
user@lab:~/demo$ head -n 22 terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.16.5",
  "serial": 6,
  "lineage": "08f7c79f-77e8-d934-d5b6-dac2f3dcef64",
  "outputs": {
    "file_path": {
      "value": "./out/blog-alert-slug.txt",
      "type": "string"
    }
  },
  "resources": [
    {
      "mode": "managed",
      "type": "local_file",
      "name": "notes",
      "provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "content": "Project: blog\nManaged by Terraform\n",

Variables, tfvars and saved plans

Variables can come from several places: a default, -var name=value, a file such as terraform.tfvars (read automatically) or -var-file=FILE, and environment variables named TF_VAR_name (from the documentation, not run here). For this run I added two more variables and a terraform.tfvars:

user@lab:~/demo$ cat terraform.tfvars
project    = "blog"
members    = ["ana", "ben", "cleo"]
names_list = ["red", "green", "blue"]
user@lab:~/demo$ terraform plan -no-color | grep -E '^  # |^Plan:'
  # local_file.listed[0] will be created
  # local_file.listed[1] will be created
  # local_file.listed[2] will be created
  # local_file.team["ana"] will be created
  # local_file.team["ben"] will be created
  # local_file.team["cleo"] will be created
Plan: 6 to add, 0 to change, 0 to destroy.
user@lab:~/demo$ terraform plan -out=tfplan -no-color > /dev/null
-rw-rw-r-- 1loland 5530 Oct  6 16:28 tfplan
user@lab:~/demo$ terraform apply tfplan -no-color | tail -n 5
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

file_path = "./out/blog-alert-slug.txt"
user@lab:~/demo$ ls -l out | cut -c1-10,40-
total 28
-rwxrwxr-x6 16:28 blog-alert-slug.txt
-rwxrwxr-x6 16:28 listed-0.txt
-rwxrwxr-x6 16:28 listed-1.txt
-rwxrwxr-x6 16:28 listed-2.txt
-rw-r-----6 16:28 team-ana.txt
-rw-r-----6 16:28 team-ben.txt
-rw-r-----6 16:28 team-cleo.txt
user@lab:~/demo$ terraform state list
local_file.listed[0]
local_file.listed[1]
local_file.listed[2]
local_file.notes
local_file.team["ana"]
local_file.team["ben"]
local_file.team["cleo"]
random_pet.suffix

for_each and count: the classic trap

Both make many copies of one resource. The new blocks used them side by side:

resource "local_file" "team" {
  for_each        = var.members
  filename        = "${path.module}/out/team-${each.key}.txt"
  content         = "Hello, ${each.key}\n"
  file_permission = "0640"
}

resource "local_file" "listed" {
  count    = length(var.names_list)
  filename = "${path.module}/out/listed-${count.index}.txt"
  content  = "Entry: ${var.names_list[count.index]}\n"
}

Then I removed one item from each list: "ben" from the members, and "green" (the middle) from the colors:

user@lab:~/demo$ terraform plan -no-color | grep -E '^  # |^Plan:'
  # local_file.team["ben"] will be destroyed
  # (because key ["ben"] is not in for_each map)
Plan: 0 to add, 0 to change, 1 to destroy.
user@lab:~/demo$ terraform plan -no-color | grep -E '^  # |^Plan:'
  # local_file.listed[1] must be replaced
  # local_file.listed[2] will be destroyed
  # (because index [2] is out of range for count)
  # local_file.team["ben"] will be destroyed
  # (because key ["ben"] is not in for_each map)
Plan: 1 to add, 0 to change, 3 to destroy.
user@lab:~/demo$ terraform apply -auto-approve -no-color | tail -n 3
Outputs:

file_path = "./out/blog-alert-slug.txt"
user@lab:~/demo$ ls out
blog-alert-slug.txt
listed-0.txt
listed-1.txt
team-ana.txt
team-cleo.txt

The console, and clean-up

user@lab:~/demo$ echo 'upper("hello")' | terraform console
"HELLO"
user@lab:~/demo$ echo 'var.members' | terraform console
toset([
  "ana",
  "cleo",
])
user@lab:~/demo$ terraform destroy -auto-approve -no-color | tail -n 6
local_file.team["ana"]: Destruction complete after 0s
local_file.listed[1]: Destruction complete after 0s
local_file.listed[0]: Destruction complete after 0s
random_pet.suffix: Destruction complete after 0s

Destroy complete! Resources: 6 destroyed.
user@lab:~/demo$ ls out | wc -l
0
user@lab:~/demo$ terraform state list | wc -l
0

Pointing it at a real cloud (from the documentation, not run here)

The workflow above does not change: you swap the provider and the resource types. A provider block says which cloud, and the credentials come from the same place as the command-line tools, so what you set up on the CLI pages carries over:

provider "aws" {
  region = "eu-west-1"
}

resource "aws_s3_bucket" "demo" {
  bucket = "your-unique-name-12345"
}
provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "demo" {
  name     = "lab-rg"
  location = "westeurope"
}
provider "google" {
  project = "my-lab-4821"
  region  = "us-central1"
}

resource "google_storage_bucket" "demo" {
  name     = "my-lab-4821-bucket"
  location = "US-CENTRAL1"
}

The cost angle: terraform plan creates nothing and costs nothing. terraform apply creates real, billable resources, so use a budget alert (see the account pages), small resources, and terraform destroy when you finish. The -/+ replacement marker is the one to fear on a real account.

Habits that save you

What this page did not cover

Modules (reusable bundles of configuration), workspaces, data sources (reading things that already exist), terraform import (bringing an existing resource under management), provisioners, HCP Terraform (HashiCorp's hosted service), testing, and OpenTofu's differences. The Terraform documentation covers each of them.

The cheat sheet

↑ All tutorials · Cloud Networking Essentials: Addresses, Subnets, Routes and Rules →