Terraform · lesson 1 of 17
Beginner
7- 1 Terraform Fundamentals (IaC)
- 2 Terraform Workflow (init → validate → plan → apply)
- 3 Terraform State (tfstate, remote state, locking)
- 4 Terraform Variables and Outputs
- 5 Providers and Resources (the building blocks of Terraform)
- 6 Terraform Locals: Computed Values Without Repetition
- 7 Terraform Modules (reuse, composition, and clean structure)
Intermediate
7- 8 Terraform count and for_each (repeat resources safely)
- 9 The lifecycle Block: prevent_destroy, create_before_destroy, ignore_changes
- 10 Data Sources and Dependencies (read existing infrastructure)
- 11 sensitive = true: What It Hides, and What It Doesn't
- 12 Remote State Safety (locking, drift, and recovery)
- 13 Workspaces and Environments (dev/stage/prod patterns)
- 14 Terraform CI/CD with GitHub Actions (plan/apply)
Advanced
3Also part of Terraform
4Also 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 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.