How Terraform Works

8 min
Audience
beginner, lesson 01 read
Duration
30 to 40 min
Module
1/7
Skill
name the four pieces of Terraform, read an HCL block word by word, and recognize in the terminal the sentences that init, plan, apply and destroy display when everything goes right

An overview

Terraform connects four elements:

  • The configuration describes the result you want.
  • Terraform reads this configuration and builds a dependency graph.
  • Providers translate Terraform's requests into API calls to platforms.
  • The state ties the blocks of your code to true remote objects.

In the lesson 01 picture: the configuration is the blueprint, the state is the record, the providers are the trades, and the CLI is the architect.

The HCL language

Terraform uses primarily the HashiCorp Configuration Language, or HCL. Code is organized into blocks.

hcl
resource "aws_s3_bucket" "logs" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

In this example:

  • resource announces an object Terraform must manage;
  • aws_s3_bucket is the type provided by the AWS provider;
  • logs is the local name you choose in this module;
  • bucket and tags are arguments;
  • var.bucket_name and var.environment come from variables.

The type and local name together form the address of the resource: aws_s3_bucket.logs. That is the name you will find it under in a plan, in terraform state list and in references from other blocks.

Provider

A provider is a plugin that knows the API of a platform and provides resource and data source types. Terraform installs providers during terraform init. Version constraints and the lock file make runs more predictable. Official reference — Provider requirements

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

provider "aws" {
  region = "ca-central-1"
}

The version of Terraform CLI and the version of a provider are two separate things. You control them separately: terraform version shows the first (Terraform v1.12.2 on the course machine), .terraform.lock.hcl freezes the second.

Resource and data source

A resource generally represents an object Terraform creates or manages.

hcl
resource "aws_security_group" "web" {
  name = "web"
}

A data source reads an object or information that already exists without automatically becoming its owner.

hcl
data "aws_vpc" "default" {
  default = true
}

You can then reference data.aws_vpc.default.id in a resource.

Variables, locals and outputs

Variables are configurable inputs:

hcl
variable "environment" {
  type        = string
  description = "Name of the environment"

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "Environment must be dev, test or prod."
  }
}

Locals calculate internal values to avoid repetition:

hcl
locals {
  prefix = "application-${var.environment}"
}

Outputs expose useful results:

hcl
output "bucket_id" {
  description = "Bucket identifier"
  value       = aws_s3_bucket.logs.id
}

A project with no output block has nothing to display: terraform output then replies Warning: No outputs found. That is the case in Project 01; outputs arrive in Project 02.

The dependency graph

Terraform detects a dependency when one resource references another.

hcl
resource "aws_s3_object" "log" {
  bucket = aws_s3_bucket.logs.id
  key    = "log.txt"
  source = "log.txt"
}

The object depends on the bucket. Terraform must therefore create the bucket before sending the file. Resources without dependencies between them can be handled in parallel. Block order in the file does not matter: only the reference counts.

The central workflow

HashiCorp sums up the Terraform workflow in three steps: Write, Plan, Apply. Official reference — Core workflow

Before the first of these steps, there is a unique gesture per folder: terraform init. The outputs below are those from Project 01 of this module, captured with Terraform 1.12.2 on the course machine; you will see them word-for-word when you do it yourself.

Init — prepare the folder

powershell
terraform init
text
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- 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. …

Terraform has been successfully initialized!

Three things happen: Terraform downloads the provider (Installing hashicorp/local v2.9.1), puts it in the hidden folder .terraform/, and writes .terraform.lock.hcl to retain the version chosen. The sentence to expect: Terraform has been successfully initialized!. Without init, any other command stops: Error: Inconsistent dependency lock file for plan, Error: Missing required provider for validate, and both tell you what to do (run: terraform init).

Write — write

You write or modify .tf files, then usually run:

powershell
terraform fmt
terraform validate

terraform fmt realigns spaces and indentation; it displays the names of files it modified, and nothing if everything was already clean. terraform validate checks syntax and block consistency without touching anything, and replies:

text
Success! The configuration is valid.

It catches typos in argument names (An argument named "contenu" is not expected here. Did you mean "content"?) and forgotten braces (Error: Unclosed configuration block). It does not check that the result would be good architecture.

Plan — preview

Terraform compares the desired state to what it knows of the infrastructure:

powershell
terraform plan
text
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.message will be created
  + resource "local_file" "message" {
      + content              = "Hello, this file was created with Terraform."
      + content_md5          = (known after apply)
      + filename             = "./message.txt"
      + id                   = (known after apply)

    }

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

The plan answers three questions, and the last line sums them up in three numbers:

  • What will be created? (to add)
  • What will be modified? (to change)
  • What will be destroyed or replaced? (to destroy)

Each attribute line bears the symbol of the action. (known after apply) signals a value Terraform cannot know before creating the object: here the file's ID and content checksums.

Apply — apply

After review:

powershell
terraform apply

Terraform redisplays the plan then asks for your approval:

text
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

local_file.message: Creating...
local_file.message: Creation complete after 0s [id=ff6b93dc92e7e1d2ba4f9dad3cc16e03ac649b45]

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

Only yes in full is accepted; y, Y or an empty line give Apply cancelled. and nothing is touched. Terraform then calls the providers in the order dictated by dependencies, and updates the state. The last line repeats the three numbers from the plan: Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

If you rerun terraform plan without changing anything, the estimate is empty:

text
No changes. Your infrastructure matches the configuration.

That is the idempotence from lesson 01, seen in the terminal.

Destroy — undo everything

powershell
terraform destroy

destroy is an apply whose plan contains only -. The question asked is more insistent (Do you really want to destroy all resources? … There is no undo.), the expected answer is still yes, and the last line is:

text
Destroy complete! Resources: 1 destroyed.

After a destroy, terraform state list displays nothing more. In this course, each project ends thus.

Complete cycle

The four symbols of a plan

SymbolActionSentence in the planWhat it means for you
+createwill be createdA new object; nothing existing is touched.
~modify in placewill be updated in-placeThe object remains, an attribute changes.
-/+replacemust be replacedThe object is destroyed then recreated; its content and ID vanish.
-destroywill be destroyedThe object vanishes.

Declarative does not mean magical

Terraform figures out how to reach the desired state, but the exact behavior depends on each provider. Some arguments can be modified in place; others force resource replacement. The local provider of Project 01 gives a clear example: changing the content line of a local_file does not produce a ~, but a replacement -/+. Here is the real plan, captured after the text change:

text
  # local_file.message must be replaced
-/+ resource "local_file" "message" {
      ~ content              = "Hello, this file was created with Terraform." -> "Second version of the file created with Terraform." # forces replacement
      ~ content_md5          = "6daf774f0eb6d3da439c871afec7cf90" -> (known after apply)
      ~ id                   = "ff6b93dc92e7e1d2ba4f9dad3cc16e03ac649b45" -> (known after apply)
        # (3 unchanged attributes hidden)
    }

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

Read the three clues: must be replaced in the title, # forces replacement at the end of the content line, and 1 to add, 0 to change, 1 to destroy in the summary. For a text file, the nuance is inconsequential. For a database, -/+ means loss of data: that is why the plan must be read, even when the code change looks small.

The key difference between ~ and -/+. The ~ before content says the value changes. The -/+ before resource says how Terraform will do it: destroy, then recreate. When a ~ line bears # forces replacement, it is this line that triggered the -/+ of the whole block.

The essentials

  1. Four pieces: configuration describes, CLI compares, providers call APIs, state remembers.
  2. A resource "type" "name" block has an address, type.name, that you find in the plan and in state.
  3. init once per folder (Terraform has been successfully initialized!), then fmt, validate (Success! The configuration is valid.), plan (Plan: 1 to add, 0 to change, 0 to destroy.), apply with yes (Apply complete! Resources: 1 added, 0 changed, 0 destroyed.), and destroy at end of session (Destroy complete! Resources: 1 destroyed.).
  4. Four symbols: + creates, ~ modifies in place, -/+ destroys then recreates, - destroys. The summary Plan: N to add, N to change, N to destroy. counts them.
  5. The provider decides if a change is a ~ or a -/+; the local_file of Project 01 is replaced as soon as content changes.

Questions for understanding

  1. Which component communicates directly with AWS or GitHub API?
  2. Why must terraform init be executed, and what files does it create?
  3. What is the difference between a resource and a data source?
  4. How does Terraform know that an S3 object depends on a bucket?
  5. In the plan of Project 01 after modifying content, which three clues say it is a replacement and not an in-place modification?
  6. What does Terraform reply if you type y instead of yes?