Basic Notes on Terraform

The goal of this set of notes is for me to have a basic understanding of Terraform, given that it is a very complex piece of software. You are very unlikely to find this useful, unless you also come from a PHP -> DBA/SRE background, and even then, it’s questionable.

What is Terraform?

Terraform is an application that converts configuration files of its own type (Hashicorp Configuration Language / HCL) into a designated set of results. “Result” is an abstract term here, because the outcome of said conversion can be something simple, like creating a text file somewhere, or something more complicated, like a set of virtual machines on a hypervisor. The idea of being able to take configuration files, and turn them into real resources (like VMs) is known as Infrastructure as Configuration (IaC). Besides giving a structure to infrastructure provisioning, one of the advantages it brings is that this configuration can live in git repositories, be version controlled and be integrated into CI/CD pipelines.

Infrastructure as Configuration?

IaC actually stands for Infrastructure as Code, but HCL is not a true programming language, in the sense that it does not have a full customary feature set of a typical programming language like C, and is much more comparable to a templating language. It is not designed for complicated imperative workflows, and the focus is on describing a desired outcome, rather than quantifying the exact steps towards achieving said outcome. With that in mind, it is worth considering the C in IaC as “Configuration”.

Terraform itself does very little, it translates the HCL file into something like an “execution plan” (much like Databases translating queries into an “execution plan”). This execution plan is then passed by terraform into plugins called providers, that are various applications designed to tackle the relevant pieces of the execution plan fed to them by terraform.

A simple example

A safe way to experiement with terraform is through docker. We can always create and destroy docker containers without any major consequences, so let’s do that and create 3 connected docker containers. What we need here:

We simply need a terraform file where we put down the configurations we want to achieve:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
  }
}

provider "docker" {
  host = "ssh://root@192.168.2.99"
}

locals {
  servers = ["server-1", "server-2", "server-3"]
}

resource "docker_network" "lab" {
  name = "tf-lab-net"
}

resource "docker_container" "server" {
  for_each = toset(local.servers)

  name     = each.value
  image    = "nginx:alpine"
  hostname = each.value

  networks_advanced {
    name = docker_network.lab.name
  }

  labels {
    label = "deployed_by"
    value = "terraform"
  }
}

Breaking it down

As this is quite a lot, even for something as simple as a few docker containers, let’s go block by block and understand what all of this is really doing.

terraform

This block allows us to configure certain behaviours of terraform itself, such as what version of terraform should run this file, and what providers (and provider versions) this file will make use of. Basically, we can not use resources from providers without first telling terraform that we want to use those providers. This is kind of like composer in PHP, we can’t just use a library like PHPMailer out of the box, and we would first need to tell Composer that PHPMailer is required.

The composer vs terraform comparison:

{
  "require": {
    "php": ">=8.1",
    "phpmailer/phpmailer": "~6.1"
  }
}

With composer we can do the following:

Composer then can:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
  }
}

It declares:

Terraform then can:

Concept Composer Terraform
Constraints "php": ">=8.1"   required_version = ">= 1.5.0"
Dependencies    "phpmailer/phpmailer"   docker, local providers
Registry    Packagist   Terraform Registry
Lockfile   composer.lock   .terraform.lock.hcl
Install  composer install    terraform init

Composer installs libraries into the application code, these libraries have functions (methods) that are directly callable from the application code.

Terraform installs small applications that talk to existing external applications, they essentially act like a bridge between terraform core and the external application. Terraform doesn’t know how to operate docker, so it needs to give part of its execution plan to an application that does know how to operate docker.

provider

Some providers enable us to add additional configurations into these blocks. For example, in my case, I do not want to run docker on the machine where terraform is going to run from, so this configuration tells the provider that will operate docker, to first ssh to a different target machine.

locals

This is hcl’s way of defining variables. E.g locals { servers = [...] } can later be referenced as local.servers.

resource

A keyword in hcl to indicate that this is a resource to be provisioned. As mentioned before, the notion of what a “resource” is can be quite abstract. It can be a VM, a docker container, a simple text file and much much more.

output

This block is used to define what output should be returned by a module. It can be used to return info back into the terminal, write results into a file, pass data to scripts and more.

Quick stopping point

As it can be seen, there is very little indication as to how the docker containers are to be created. We simply assume that the provider that terraform will send its execution plan to will take care of it correctly. All we are doing here is writing down a desired outcome, and (unless we do a lot of deep diving into some golang code) do not see how it is going to be executed.

Effectively, what we can put into these hcl files is a “desired state”, we want 3 docker containers, and we don’t care how exactly the mechanism of producing those images work in the background.

This is much like ordering a pizza with garlic bread and a bottle of pepsi from pizza hut. In that order, the desired state is one delicious pizza, a warm garlic bread, and a refreshing bottle of pepsi to help it go down. Do we really care how exactly the kitchen goes on about kneading the dough and putting on all of the toppings and what not? We simply tend to assume that the employees can interpret the order correctly, follow their internal procedures and deliver the food as specified in the order.

That is basically how terraform works, when the hcl file is interpreted and executed by terraform, the “desired state” becomes something in reality, also known as the “actual state”.

Executing the configuration file

This is now as good a time as any to see this in action:

terraform init

This is the first stage of executing a terraform file.

root@linuxpc:/opt/terraform# terraform init
Initializing the backend...
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
- Finding hashicorp/local versions matching "~> 2.0"...
- Installing kreuzwerker/docker v3.6.2...
- Installed kreuzwerker/docker v3.6.2 (self-signed, key ID BD080C4571C6104C)
- Installing hashicorp/local v2.7.0...
- Installed hashicorp/local v2.7.0 (signed by HashiCorp)
Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://developer.hashicorp.com/terraform/cli/plugins/signing
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.

It does a basic syntax check, scans for providers, installs and downloads them into a local directory, installs modules from remote sources (if applicable), and creates the earlier mentioned dependency lock file.

This should be run for new projects, as well as situations where new providers were added, or provider versions were updated.

terraform apply

This is the second stage, this is the stage that will actually call out to the providers and attempt to close the gap between “desired state” and “actual state"

...
docker_network.lab: Creating...
docker_network.lab: Creation complete after 2s [id=5cc62e85f0d23599d3ad59279a9e14f399f3745089a1a16d7e1246c9e376b5cd]
docker_container.server["server-1"]: Creating...
docker_container.server["server-2"]: Creating...
docker_container.server["server-3"]: Creating...
docker_container.server["server-2"]: Still creating... [00m10s elapsed]
docker_container.server["server-1"]: Still creating... [00m10s elapsed]
docker_container.server["server-3"]: Still creating... [00m10s elapsed]
docker_container.server["server-3"]: Creation complete after 12s [id=293b112aca730a1f4238855ed7d442713d67c78844779cb0df1afbf2c7a19a32]
docker_container.server["server-2"]: Creation complete after 12s [id=cb0c5b9b511f12261dd32691e59751b985b050d9fac3a054618b03452b5a3aab]
docker_container.server["server-1"]: Creation complete after 12s [id=2f7809c466df5490d03fc3b94effdb53a663b741967f0554df0a353205064f91]
local_file.inventory: Creating...
local_file.inventory: Creation complete after 0s [id=856fc5f465e538ed3d5f5369403bfca967f85421]

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

Outputs:

inventory_path = "./inventory.json"
server_ips = {
  "server-1" = "172.18.0.4"
  "server-2" = "172.18.0.2"
  "server-3" = "172.18.0.3"
}

And it appears to have worked:

root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps"
CONTAINER ID   IMAGE          COMMAND                  CREATED              STATUS              PORTS     NAMES
cb0c5b9b511f   nginx:alpine   "/docker-entrypoint.…"   About a minute ago   Up About a minute   80/tcp    server-2
2f7809c466df   nginx:alpine   "/docker-entrypoint.…"   About a minute ago   Up About a minute   80/tcp    server-1
293b112aca73   nginx:alpine   "/docker-entrypoint.…"   About a minute ago   Up About a minute   80/tcp    server-3

Terraform state

Now notice the following file was created just as we ran terraform apply:

root@linuxpc:/opt/terraform# stat terraform.tfstate
  File: terraform.tfstate
  Size: 13881           Blocks: 32         IO Block: 4096   regular file
Device: 812h/2066d      Inode: 10891412    Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2026-02-24 22:39:46.097838800 +0100
Modify: 2026-02-24 22:35:25.384701792 +0100
Change: 2026-02-24 22:35:25.384701792 +0100
 Birth: 2026-02-24 22:35:03.853952023 +0100

Basically terraform did quite a lot here, so let’s unpack it:

Note: Never commit terraform.tfstate to git, doing so has a lot of hidden and obvious dangers. For example, if someone accidentally commits an older version of this state file back to master, will mean this statefile no longer matches reality, so terraform could either think that nothing has changed, or see huge differences that aren’t real. Depending on which, terraform then might:

In production, the following should be in .gitignore:

terraform.tfstate
terraform.tfstate.backup
*.tfstate.*

Perceived state vs actual state

This is an important distinction, because the actual state can always change, outside of terraform’s control, or knowledge.

root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker kill 2f7809c466df"
2f7809c466df
root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps"
CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS     NAMES
cb0c5b9b511f   nginx:alpine   "/docker-entrypoint.…"   13 minutes ago   Up 13 minutes   80/tcp    server-2
293b112aca73   nginx:alpine   "/docker-entrypoint.…"   13 minutes ago   Up 13 minutes   80/tcp    server-3

We just changed the actual state. Terraform is none the wiser.

Running terraform apply again will show us several messages about how the actual state has now changed from what terraform’s perceived state is. Once we allow the apply to go through, we’ll be back to having three docker containers:

...
local_file.inventory: Destroying... [id=856fc5f465e538ed3d5f5369403bfca967f85421]
local_file.inventory: Destruction complete after 0s
docker_container.server["server-1"]: Creating...
docker_container.server["server-1"]: Creation complete after 0s [id=4e63467b772dea430c4bd47ee41daedb80faba8a62a33ebde5c64178aa2390d9]
local_file.inventory: Creating...
local_file.inventory: Creation complete after 0s [id=7987d9092644f685c12f1c92e7ba9add2f77c721]

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


Outputs:


inventory_path = "./inventory.json"
server_ips = {
  "server-1" = "172.18.0.4"
  "server-2" = "172.18.0.2"
  "server-3" = "172.18.0.3"
}
root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps"
CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS     NAMES
4e63467b772d   nginx:alpine   "/docker-entrypoint.…"   23 seconds ago   Up 22 seconds   80/tcp    server-1
cb0c5b9b511f   nginx:alpine   "/docker-entrypoint.…"   14 minutes ago   Up 14 minutes   80/tcp    server-2
293b112aca73   nginx:alpine   "/docker-entrypoint.…"   14 minutes ago   Up 14 minutes   80/tcp    server-3

This is nice. But what if a server gets renamed instead?

root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps --all" | grep serv
4e63467b772d   nginx:alpine      "/docker-entrypoint.…"   5 minutes ago    Up 5 minutes               80/tcp                                      server-1
cb0c5b9b511f   nginx:alpine      "/docker-entrypoint.…"   19 minutes ago   Up 19 minutes              80/tcp                                      server-2
293b112aca73   nginx:alpine      "/docker-entrypoint.…"   19 minutes ago   Up 19 minutes              80/tcp                                      server-3
root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker rename server-3 server-4"
root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps --all" | grep serv
4e63467b772d   nginx:alpine      "/docker-entrypoint.…"   5 minutes ago    Up 5 minutes               80/tcp                                      server-1
cb0c5b9b511f   nginx:alpine      "/docker-entrypoint.…"   19 minutes ago   Up 19 minutes              80/tcp                                      server-2
293b112aca73   nginx:alpine      "/docker-entrypoint.…"   19 minutes ago   Up 19 minutes              80/tcp                                      server-4

If we run terraform apply again, something curious will happen:

e29425d5a884   nginx:alpine      "/docker-entrypoint.…"   9 seconds ago    Up 9 seconds               80/tcp                                      server-3
4e63467b772d   nginx:alpine      "/docker-entrypoint.…"   6 minutes ago    Up 6 minutes               80/tcp                                      server-1
cb0c5b9b511f   nginx:alpine      "/docker-entrypoint.…"   20 minutes ago   Up 20 minutes              80/tcp                                      server-2

It destroyed server-4! So be very careful about mixing operations within and outside of terraform.

Finally, what if we rename the servers in the locals block?

e.g.

locals {
  servers = ["server-x", "server-y", "server-z"]
}

What we can see is:

root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps --all" | grep serv
4743b3df59db   nginx:alpine      "/docker-entrypoint.…"   4 seconds ago   Up 2 seconds               80/tcp                                      server-x
994371563abe   nginx:alpine      "/docker-entrypoint.…"   4 seconds ago   Up 2 seconds               80/tcp                                      server-z
2146756c8cc2   nginx:alpine      "/docker-entrypoint.…"   4 seconds ago   Up 2 seconds               80/tcp                                      server-y

That it destroyed all of the old servers it knew about, and replaced them with brand new servers. So, also be mindful of quick terraform renames!

Checking reality?

We can use terraform refresh to bring terraform’s state file up to date based on what resources appear in “reality”.

root@linuxpc:/opt/terraform# terraform refresh
docker_network.lab: Refreshing state... [id=5cc62e85f0d23599d3ad59279a9e14f399f3745089a1a16d7e1246c9e376b5cd]
docker_container.server["server-x"]: Refreshing state... [id=4743b3df59db3c822575e2e58d2bac4763f1f8fe920c55c580a2f4b3974ab1c6]
docker_container.server["server-z"]: Refreshing state... [id=994371563abef5f0c09f0e4de8d002e09642e6ef4669ee35e15fb130119644cf]
docker_container.server["server-y"]: Refreshing state... [id=2146756c8cc2a01e15ab0e6be5eca58c73d23ee8dd88a35fb1ea8b59acfcb615]
local_file.inventory: Refreshing state... [id=df6ef182e73b7101ec44006bfd0abff3a6b30e19]

Outputs:

inventory_path = "./inventory.json"
server_ips = {
  "server-x" = "172.18.0.5"
  "server-y" = "172.18.0.2"
  "server-z" = "172.18.0.3"
}

Let’s destroy server-z:

root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker kill server-z"
server-z
root@linuxpc:/opt/terraform# ssh root@192.168.2.99 "docker ps"
CONTAINER ID   IMAGE          COMMAND                  CREATED       STATUS       PORTS     NAMES
4743b3df59db   nginx:alpine   "/docker-entrypoint.…"   3 hours ago   Up 3 hours   80/tcp    server-x
2146756c8cc2   nginx:alpine   "/docker-entrypoint.…"   3 hours ago   Up 3 hours   80/tcp    server-y

and re-run terraform refresh:

root@linuxpc:/opt/terraform# terraform refresh
docker_network.lab: Refreshing state... [id=5cc62e85f0d23599d3ad59279a9e14f399f3745089a1a16d7e1246c9e376b5cd]
docker_container.server["server-x"]: Refreshing state... [id=4743b3df59db3c822575e2e58d2bac4763f1f8fe920c55c580a2f4b3974ab1c6]
docker_container.server["server-y"]: Refreshing state... [id=2146756c8cc2a01e15ab0e6be5eca58c73d23ee8dd88a35fb1ea8b59acfcb615]
local_file.inventory: Refreshing state... [id=df6ef182e73b7101ec44006bfd0abff3a6b30e19]

Outputs:

inventory_path = "./inventory.json"
server_ips = {
  "server-x" = "172.18.0.5"
  "server-y" = "172.18.0.2"
  "server-z" = "172.18.0.3"
}

We can see that it updates its state to no longer include server-z.

The end

This is quite a lengthy piece at this point, so this is a good place to stop.