Skip to main content
Terraform · lesson 1 of 17
Terraform beginner Lesson 1 of 17

Also part of: DevOps Data Engineering

Terraform Fundamentals (IaC)

Write a first Terraform config, apply it, change it and destroy it, seeing the real plan each time and why Terraform compares config, state and reality.

By SkillByExample · Updated

terraform iac beginner

Terraform reads .tf files that describe what should exist, compares them with what it already created, and makes only the changes needed to match. Every example on this track uses providers that need no cloud account, so you can run all of them yourself. The outputs come from Terraform 1.16.5 with hashicorp/random 3.9.1 and hashicorp/local 2.9.1, all captured with -no-color so they paste cleanly as plain text. Drop the flag and your terminal shows colours.

A first configuration

Save this as main.tf in an empty directory, run terraform init once to download the two providers, then apply it.

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

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

resource "local_file" "motd" {
  filename = "${path.module}/motd.txt"
  content  = "Welcome to ${random_pet.server.id}\n"
}

output "server_name" {
  value = random_pet.server.id
}
$ 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.motd will be created
  + resource "local_file" "motd" {
      + content              = (known after apply)
      + 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             = "./motd.txt"
      + id                   = (known after apply)
    }

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

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

Changes to Outputs:
  + server_name = (known after apply)
random_pet.server: Creating...
random_pet.server: Creation complete after 0s [id=winning-collie]
local_file.motd: Creating...
local_file.motd: Creation complete after 0s [id=4635b703140bce991665f91334396dc9808188fd]

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

Outputs:

server_name = "winning-collie"
$ cat motd.txt
Welcome to winning-collie

The plan lists local_file.motd first (it sorts alphabetically), but the apply creates random_pet.server first. The file’s content refers to random_pet.server.id, so Terraform knows the pet name has to exist before the file can be written. That’s also why the plan shows content = (known after apply): the name doesn’t exist yet. Your pet name will be different, because it’s random.

Running it again changes nothing

$ terraform plan -no-color
random_pet.server: Refreshing state... [id=winning-collie]
local_file.motd: Refreshing state... [id=4635b703140bce991665f91334396dc9808188fd]

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.

A shell script that creates a file would just run again. Terraform describes an end state instead. It saved what it created in terraform.tfstate, refreshed each object, and found nothing to do. It also kept the same pet name. random_pet only picks a new name when the resource is replaced, not on every run.

Changing reality behind Terraform’s back

$ rm motd.txt
$ terraform plan -no-color
random_pet.server: Refreshing state... [id=winning-collie]
local_file.motd: Refreshing state... [id=4635b703140bce991665f91334396dc9808188fd]

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.motd will be created
  + resource "local_file" "motd" {
      + content              = <<-EOT
            Welcome to winning-collie
        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             = "./motd.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.

Terraform has three things to compare: the config (main.tf), the state (what it recorded creating) and reality (the actual file). The state still says the file exists. The refresh found it missing, so the plan creates it again. This time content is fully known, because the pet name already exists in state.

A change that cascades

After applying the plan above to restore the file, change length = 2 to length = 3:

$ terraform apply -auto-approve -no-color
random_pet.server: Refreshing state... [id=winning-collie]
local_file.motd: Refreshing state... [id=4635b703140bce991665f91334396dc9808188fd]

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.motd must be replaced
-/+ resource "local_file" "motd" {
      ~ content              = <<-EOT
            Welcome to winning-collie
        EOT -> (known after apply) # forces replacement
      ~ content_base64sha256 = "k6zaFFl2ksBeWxvCtVbBKgzU5Et9z6SpfGZts8deGt8=" -> (known after apply)
      ~ content_base64sha512 = "46Qt2yrftG2hZ0LwJzld9L4Qyzw+3UQqi4trw1aRDcTQ1AlhUzVa6+QL+VdgNUPDaP1FPd3bxHy9OKLRGpMjNQ==" -> (known after apply)
      ~ content_md5          = "7050202410958a1d2476c38fa72243c0" -> (known after apply)
      ~ content_sha1         = "4635b703140bce991665f91334396dc9808188fd" -> (known after apply)
      ~ content_sha256       = "93acda14597692c05e5b1bc2b556c12a0cd4e44b7dcfa4a97c666db3c75e1adf" -> (known after apply)
      ~ content_sha512       = "e3a42ddb2adfb46da16742f027395df4be10cb3c3edd442a8b8b6bc356910dc4d0d4096153355aebe40bf957603543c368fd453ddddbc47cbd38a2d11a932335" -> (known after apply)
      ~ id                   = "4635b703140bce991665f91334396dc9808188fd" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

  # random_pet.server must be replaced
-/+ resource "random_pet" "server" {
      ~ id        = "winning-collie" -> (known after apply)
      ~ length    = 2 -> 3 # forces replacement
        # (1 unchanged attribute hidden)
    }

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

Changes to Outputs:
  ~ server_name = "winning-collie" -> (known after apply)
local_file.motd: Destroying... [id=4635b703140bce991665f91334396dc9808188fd]
local_file.motd: Destruction complete after 0s
random_pet.server: Destroying... [id=winning-collie]
random_pet.server: Destruction complete after 0s
random_pet.server: Creating...
random_pet.server: Creation complete after 0s [id=physically-growing-civet]
local_file.motd: Creating...
local_file.motd: Creation complete after 0s [id=4027c583f0926a7425018971aee8a9c415a37e70]

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

Outputs:

server_name = "physically-growing-civet"

You edited one line, and Terraform replaced two resources. A pet name can’t be changed in place, so length is marked # forces replacement. The new name isn’t known until apply, so the file’s content is unknown too, and that forces the file to be replaced as well. Look for -/+ and # forces replacement before approving any plan. On a real database, the same symbols mean the old database gets deleted.

Destroying everything

$ terraform destroy -auto-approve -no-color | tail -6
local_file.motd: Destroying... [id=4027c583f0926a7425018971aee8a9c415a37e70]
local_file.motd: Destruction complete after 0s
random_pet.server: Destroying... [id=physically-growing-civet]
random_pet.server: Destruction complete after 0s

Destroy complete! Resources: 2 destroyed.

destroy works from the state, not from a directory listing, and goes in reverse dependency order: the file first, then the pet name it depends on. tail trims the full plan, which looks like the create plan with every + turned into -.

Practice

1. Add separator = "_" to random_pet.server and predict how many resources the plan replaces before running it.

Solution
resource "random_pet" "server" {
  length    = 2
  separator = "_"
}
$ terraform plan -no-color | grep -E "forces|Plan:"
        EOT -> (known after apply) # forces replacement
      ~ separator = "-" -> "_" # forces replacement
Plan: 2 to add, 0 to change, 2 to destroy.

Two, for the same reason as the length change. Every argument of random_pet forces replacement, and the new name changes the file’s content.

2. Add an output called motd_path that exposes the file’s filename, apply, and print just that value with terraform output -raw.

Solution
output "motd_path" {
  value = local_file.motd.filename
}
$ terraform apply -auto-approve -no-color | tail -5

Outputs:

motd_path = "./motd.txt"
server_name = "moving-boa"
$ terraform output -raw motd_path
./motd.txt

Adding an output doesn’t change any resource. -raw prints the bare string without quotes, which is the form a shell script needs.

3. Run terraform apply twice in a row without editing anything. What does the second run report?

Solution
$ terraform apply -auto-approve -no-color
random_pet.server: Refreshing state... [id=moving-boa]
local_file.motd: Refreshing state... [id=3b93c5444f547728d1a8bbe410166fda8eba984f]

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.

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

Outputs:

motd_path = "./motd.txt"
server_name = "moving-boa"

Applying the same config a second time does nothing. This property is called idempotence, and it’s what makes running terraform apply from CI on every merge safe.

Next: the workflow commands, meaning what init, validate, plan -out and apply each guarantee, and what a stale saved plan looks like.

Frequently Asked Questions

What is Terraform in one sentence?
Terraform is a tool that reads files describing the infrastructure you want, compares them with what it already created, and makes only the API calls needed to close the gap. You describe the end state; Terraform works out the steps.
Why does Terraform need a state file?
State is Terraform's record of which real objects it created for which resource blocks. Without it, Terraform cannot tell whether a resource in your config already exists, so it would try to create everything again. State is also what lets plan show what will change rather than just what you declared.
What's the difference between terraform plan and terraform apply?
plan computes and prints the changes Terraform would make, without touching anything. apply computes the same plan, then carries it out. Running plan first, or saving it with -out and applying that file, is how you review a change before it happens.
Do I need a cloud account to learn Terraform?
No. Providers such as hashicorp/random, hashicorp/local and hashicorp/time manage things on your own machine or inside the state file itself, so every Terraform concept, including plans, state, modules, imports and tests, can be practised for free. The same workflow then applies unchanged to AWS, Azure or GCP providers.
What happens if I change something Terraform created by hand?
The next plan refreshes each resource, notices the real object no longer matches, and proposes changes to put it back the way the config describes. For example, delete a file Terraform created with local_file and the next plan offers to create it again.