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:
-> docker should be installed on the target machine
-> terraform should be installed on the source machine
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:
- -> composer.json
{
"require": {
"php": ">=8.1",
"phpmailer/phpmailer": "~6.1"
}
}
With composer we can do the following:
declare required PHP version (8.1 or any higher version in this case)
declare required packages
define version constraints (e.g. ~6.1 = at least v6.1 but lower than v7.0)
declare dependency sources
Composer then can:
resolve dependencies
download them
lock versions in composer.lock
-> terraform {} block
terraform {
required_version = ">= 1.5.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
local = {
source = "hashicorp/local"
version = "~> 2.0"
}
}
}
It declares:
required Terraform CLI version
required providers (essentially plugin applications)
provider source registry
version constraints for providers
Terraform then can:
download providers
resolve versions
write a .terraform.lock.hcl (this is basically very similar to what composer.lock does)
-> Direct Comparison:
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
- -> Important Differences
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:
-> parsed the hcl configuration file
-> used the information to build a graph of all the resources that it was asked to provision (desired state), and figure out the logical order in which it can create them (and resolve any dependencies, if applicable)
-> inspected the state to understand what has already been deployed (our case, nothing, since this was the first deployment), and what it hasn’t yet deployed. This is known as the
perceived state. The notion here is that what terraform thinks exists is not necessarily the same as what actually exists.-> performed the diff between the desired and perceived states (terraform’s memory) and the refreshed state from provider info
-> performed (through the providers) all the operations needed to bring us to the actual state, such that desired state = actual state
-> terraform updates what it knows (the state), this is the contents of that
terraform.tfstatefile above
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:
-> do nothing, when it should update/destroy things
-> try (and fail) to re-create resources that already exist
-> delete and recreate things because IDs in the old state no longer match with what’s running in production
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.