ποΈ 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:
- Declarative. You describe the end result ("a bucket with this name"), not the steps. If it already exists, Terraform does nothing.
- 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.
- 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:
- terraform { required_providers { ... } } says which providers are needed and which versions are acceptable. ~> 2.5 means "2.5 or newer, but below 3.0".
- variable is an input, with a type and an optional default.
- resource "TYPE" "NAME" is something to create. random_pet (a random two-word name) and local_file (a file) are resource types from the two providers. The first word is the type, the second is your name for it.
- output is a value to print when it is done.
- References. random_pet.suffix.id means "the id of the resource of type random_pet that I called suffix". var.project is the variable. "${...}" puts a value inside a string. Terraform works out the order itself from these references: the file needs the pet name, so the pet is made first.
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.
- Before init, validate fails: "Missing required provider ... You may be able to install it automatically by running: terraform init".
- terraform init found versions matching the constraints (hashicorp/local 2.9.1 and hashicorp/random 3.9.1), downloaded them, and said each was "signed by HashiCorp" (it verifies the signature).
- It wrote .terraform.lock.hcl, a record of the exact provider versions and their checksums. Commit this file to version control: it makes everyone, and every build, use the same versions.
- It created the .terraform folder (35 MB here, mostly provider programs). Do not commit it: terraform init recreates it.
- After init, terraform validate says "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.
- I broke the file on purpose (suffix became suffx). terraform validate pointed to the file, the line and the exact problem: "A managed resource "random_pet" "suffx" has not been declared in the root module." No cloud was needed: validate checks your files only.
- terraform fmt rewrites files into the standard style. With -check -diff it shows what it would change (here, an unindented length=2) and, run alone, it fixes it and prints the name of each file it changed. Run it before every commit.
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:
- + create is the symbol for a new resource. You will also see ~ (change in place), - (destroy) and -/+ (destroy and create again, a replacement).
- (known after apply) marks values that only exist once the thing is made, such as the file's checksums and its ID.
- The summary: "Plan: 2 to add, 0 to change, 0 to destroy." Check these numbers first. A plan that says something unexpected is a plan you do not apply.
- The Note at the end: without -out, a later apply makes a new plan, which might differ. We use -out below.
- A detail in this real output worth noticing: the local_file resource has file_permission = "0777" by default, which means "anyone can read, write and run the file". Terraform shows every default so that you can spot ones like this. Set file_permission = "0640" (as the example below does) when a file should be private. The same habit applies to cloud resources: look at the defaults in a plan before applying.
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
- apply shows the plan again and then asks you to confirm by typing yes. I used -auto-approve to skip that in a demo. In daily use, leave it off and read the plan.
- It made the pet name (alert-slug), then the file (that order again), and printed "Apply complete! Resources: 2 added, 0 changed, 0 destroyed."
- The file exists on disk, with the content from the file.
- terraform output shows the outputs, and terraform output -raw file_path prints one value with no quotes and no trailing newline (in the transcript the next prompt follows straight on). That is what you want in a script.
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
- -/+ "destroy and then create replacement", and the marker # forces replacement shows which attribute forced it: here the file name and content, because a file cannot be renamed in place. The random pet is unchanged.
- This is the plan to read slowly. On a real cloud, some changes (the name of a database, the network of a server) force a replacement, and a replaced database is an empty database. The marker tells you before it happens.
- After the apply the file is blog-alert-slug.txt, and the old one is gone.
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",
- terraform state list shows what Terraform manages, and terraform state show NAME shows one thing's recorded attributes.
- The record is the file terraform.tfstate, in JSON, with the previous version in terraform.tfstate.backup. It holds the full attributes of everything, including (look at "content") the plain text of the file's content.
- State is a secret file. HashiCorp's guidance (read on 6 October 2026): Terraform stores state in a plaintext file that includes any secret value in your configuration, so exclude it from version control, store it remotely with encryption and access control, and keep audit logs. Marking a variable sensitive = true hides it from the screen only: the documentation says it is still stored in the state and in plan files.
- Never commit terraform.tfstate (add *.tfstate* and .terraform/ to .gitignore), and treat saved plan files the same way.
- Teams use a remote backend. The default is the local backend (the file above). The documentation lists remote ones: S3 (AWS), azurerm (Azure), gcs (Google Cloud), oci (Oracle Cloud) and HTTP or S3-compatible stores. A remote backend shares the state and, in many cases, locks it so two people cannot apply at once. After you add a backend block, terraform init offers to migrate the existing state. (From the documentation, not run here.)
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
- The file terraform.tfvars sets project, members and names_list.
- plan | grep ... was a quick way to see only the headline lines: six resources to create (three from each new block).
- terraform plan -out=tfplan saved the plan, and terraform apply tfplan applied exactly that plan, with no new prompt, because you already approved it. This is how pipelines work: plan in one step, a person reviews, apply the saved plan. (A saved plan is sensitive, as above.)
- The files made from members have mode -rw-r----- (0640) as written, while the others have the 0777 default.
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
- for_each (a set of names): removing "ben" gave "1 to destroy": only team["ben"]. Each copy is tied to its name, so the others are untouched.
- count (a list): removing "green" shifted everything. Terraform tracks copies by position, so listed[1] (now "blue") must be replaced and the last one, listed[2], is destroyed. In the final plan: "1 to add, 0 to change, 3 to destroy" (together with the team["ben"] removal). Nobody asked for the "blue" file to be touched.
- Rule of thumb: use for_each with stable names. Use count only for "N identical copies" or on/off switches (count = 0 or 1). On a real cloud, a shifted count list means servers destroyed and rebuilt. This is the single most common Terraform surprise.
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
- terraform console evaluates expressions against your configuration: upper("hello") gave "HELLO", and var.members showed the set that I had left. It is the quick way to test a function or a reference.
- terraform destroy removes everything Terraform made (its plan lists the resources first, and it asks for yes unless you give -auto-approve). Here: "Destroy complete! Resources: 6 destroyed." The out folder was empty and the state list had zero entries. For cloud experiments, terraform destroy is your clean-up (the Terraform version of deleting a resource group or project).
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:
- AWS (hashicorp/aws). The provider looks for credentials in this order: the provider block, environment variables, the shared config and credentials files, an SSO profile, and a role on the machine. Select a profile with profile = "lab" or AWS_PROFILE. HashiCorp's warning: never hardcode access keys in the configuration.
provider "aws" {
region = "eu-west-1"
}
resource "aws_s3_bucket" "demo" {
bucket = "your-unique-name-12345"
}
- Azure (hashicorp/azurerm). Microsoft's page says Terraform authenticates to Azure through the Azure CLI (not Azure PowerShell): az login for you at the keyboard, and a service principal or managed identity for automation. Check where you are with az account show. The provider needs a features {} block (and recent versions also ask for the subscription_id: see the provider's documentation).
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "demo" {
name = "lab-rg"
location = "westeurope"
}
- Google Cloud (hashicorp/google). The documented ways: Application Default Credentials (gcloud auth application-default login: the separate sign-in for programs that the gcloud page explains), a service account key via GOOGLE_APPLICATION_CREDENTIALS (discouraged), or impersonation. Set project and region in the provider block.
provider "google" {
project = "my-lab-4821"
region = "us-central1"
}
resource "google_storage_bucket" "demo" {
name = "my-lab-4821-bucket"
location = "US-CENTRAL1"
}
- Oracle Cloud (oracle/oci). It can use the same kind of API-key configuration as the oci CLI (a user, a tenancy OCID, a key and a region). Resources are made in a compartment, so the compartment OCID is an input of almost everything.
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
- Read every plan. The summary line and any - or -/+ first.
- Version control your .tf files and .terraform.lock.hcl. Keep state, plans and secrets out of it.
- Pin provider versions (~> 2.5) and commit the lock file.
- Run terraform fmt and terraform validate before each commit.
- Prefer for_each with stable keys to count.
- Use a saved plan (-out) in automation, and review it.
- Change by editing the files, not the console. Terraform will undo hand edits.
- Small steps. One new resource at a time is easier to read in a plan.
- No secrets in .tf files. Use the provider's normal credential methods and environment variables.
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
- The loop: terraform init (once per folder) β terraform fmt β terraform validate β terraform plan β terraform apply β terraform destroy.
- Files: *.tf (the configuration), .terraform.lock.hcl (commit it), .terraform/ (do not), terraform.tfstate and *.tfvars with secrets (never commit).
- Blocks: terraform { required_providers }, variable, resource "TYPE" "NAME", output. References: TYPE.NAME.attribute, var.NAME.
- Reading a plan: + create, ~ change, - destroy, -/+ replace (# forces replacement), (known after apply). Check the "Plan: X to add, Y to change, Z to destroy" line.
- State: terraform state list, terraform state show ADDR. It is plaintext and secret. Teams: a remote backend with locking (s3, azurerm, gcs, oci).
- Input: -var k=v, terraform.tfvars, -var-file=F, TF_VAR_k. Output: terraform output -raw NAME.
- Safe applies: terraform plan -out=tfplan, then terraform apply tfplan. Do not use -auto-approve by hand.
- Many copies: for_each (by name, stable) over count (by position, shifts).
- Test an expression: echo 'upper("x")' | terraform console.
- Clouds: the provider uses your CLI credentials (AWS profile or SSO, az login, gcloud auth application-default login, an OCI config file). terraform destroy to clean up.
↑ All tutorials · Cloud Networking Essentials: Addresses, Subnets, Routes and Rules →