Getting Started with Terraform
A from-scratch walkthrough of Terraform — covering the core mechanics using no credentials, no cloud accounts, and nothing you can break.
Working on an IT engineering team, I’ve found that a lot of people in this field still rely heavily on GUIs and click-ops to manage their systems. In many cases, it’s what they are comfortable with. The idea of using Git and infrastructure as code still feels foreign, and many people are unsure where to even begin.
And honestly, the GUI approach makes sense at first. It feels easier, and sometimes safer, to open a management console, log in, find the setting you need, and make the change directly.
The problem usually shows up later.
Someone else on the team needs to make the same change, so you write a wiki article. They follow the steps, or maybe they don’t. Maybe they have managed the same system before and decide to adjust something while troubleshooting. The issue turns out to be unrelated, but the setting never gets changed back.
Or months later, security asks why a feature was disabled in your identity platform. No one remembers. There is no ticket, no review history, and no clear record explaining why the system is in its current state.
If that sounds familiar, this is where click-ops starts to fall short.
One way to solve this problem is with infrastructure as code. In this article, I’m going to focus specifically on Terraform.
What Terraform actually does
Terraform talks to APIs. That’s the whole model.
This is why Terraform is so common in platform engineering, DevOps, and software engineering teams. Those teams often use it to manage complex cloud environments: AWS accounts, IAM roles, Kubernetes clusters, networking, databases, secrets, CI/CD integrations, and more.
But the same model applies just as well to IT systems.
If a platform exposes an API, there is a good chance Terraform can manage at least some part of it. That could be GitHub repositories, Okta groups and applications, DNS records, Proxmox virtual machines, or even certain network devices. The specific system changes, but the workflow stays mostly the same: describe what should exist, review the change, apply it, and keep a version controlled record of what changed.
Every platform you might want to manage has a provider plugin that knows how to translate your configurations into API calls. Instead of clicking "Create repository" in the GitHub UI, you write a resource block. Instead of filling in a form in the AWS console, you write a resource block. Terraform figures out what API calls to make, in what order, and what to do if something already exists or needs to be removed.
The web UI version of this is:
- open a browser
- navigate to the right page
- click the right options
- hope you didn't miss a setting
- repeat the process each time you need to make this change for each environment
Whereas the Terraform version of this is:
- write the configuration
- run
terraform apply - have it work the same way every time
The tool keeps track of what already exists is called the state. Terraform stores a record of everything it manages in a terraform.tfstate file. Every time you run terraform plan, it compares your config against that snapshot to figure out what needs to change.
Learning the mechanics, with nothing to break
I believe that for some people, the hesitation to adopt Terraform, or other infrastructure as code tooling, comes from not knowing where to begin. Can you read the official Terraform docs? Absolutely. And you should. But if you’re anything like me, reading alone does not always make it click. Using it, breaking it, and playing around with it is usually where things start to sink in.
But what should you run Terraform against?
If you have a homelab, you may already have apps or systems in your environment with Terraform providers you can test against. You could also sign up for a cloud provider like AWS and deploy free-tier resources. But even that can feel daunting, and if you are not sure what you are doing, there is still some risk of creating unexpected cost.
To help explain Terraform in a way you can follow along with, we’ll start with an example that does not require credentials, cloud accounts, or real infrastructure. The goal is simply to get visible output and understand the workflow without worrying about breaking anything important.
For this first example, we are not going to focus on building useful infrastructure yet. Instead, we’ll put together a basic workflow and walk through some fundamentals: writing configuration, initializing Terraform, reviewing a plan, applying a change, inspecting state, and then destroying what we created.
To build this workflow, we can use a placeholder resource and a local command. The resource itself does not create anything in an external system. It just gives Terraform something to manage, while the local command lets us see when Terraform creates or destroys that resource.
This pattern is useful for learning because the feedback is immediate and low risk. In real environments, though, local commands and provisioners should be treated as a last resort, not a default design. They can introduce risk or leak data if they are not implemented carefully. Ideally, Terraform should manage infrastructure through providers and APIs directly.
First things first: Install Terraform
Before we can run through the workflow, we need the Terraform CLI installed locally.
Terraform is distributed as a single command-line tool named terraform. You can install it with a package manager or by downloading the binary directly from HashiCorp. I’d recommend using HashiCorp’s official install instructions for your operating system, but the common paths look like this.
macOS with Homebrew
brew tap hashicorp/tap
brew install hashicorp/tap/terraformLinux
Ubuntu/Debian
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraformCentOS/RHEL
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install terraformFedora
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo yum list available | grep hashicorp
sudo dnf -y install terraformWindows
If you use Chocolatey, you can try:
choco install terraformOnce Terraform is installed, confirm it works:
terraform versionYou should see output showing the Terraform version installed on your machine.
The workflow: init, plan, apply
Create a directory for the example and add a main.tf file:
mkdir terraform-null-example && cd terraform-null-example
touch main.tfAdd the following Terraform configuration:
terraform {
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.0"
}
}
}
resource "null_resource" "hello" {
provisioner "local-exec" {
command = "echo 'Terraform ran this'"
}
}Before running anything, format the file:
terraform fmt
This keeps your Terraform file consistently formatted. It matters more once you are working in a shared repository, but it is a good habit to build early.
Now run the core Terraform workflow:
terraform init
This initializes the working directory. Terraform downloads the provider plugin(s) into .terraform/ and writes a lock file called .terraform.lock.hcl, which pins the exact provider version that was specified.
You typically run terraform init when setting up a working directory for the first time, and again when you add or change providers or modules. In a team setting, you can commit the lock file so everyone uses the same resolved provider versions.
Next run:
terraform plan
This will show you what Terraform would do if you applied the configuration. Nothing changes yet. In this case, the output should show that null_resource.hello will be created.
Paying attention to what the symbols in the plan show is important:
+ create
~ update in place
- destroy
-/+ destroy and recreate
You always want to read the plan before every apply so you can ensure you understand what changes are going to take place.
Finally run:
terraform apply
Terraform will show the plan again only this time will ask for your confirmation to apply the changes. Type yes and you should see output similar to:
null_resource.hello: Creating...
null_resource.hello: Provisioning with 'local-exec'...
null_resource.hello (local-exec): Terraform ran this
null_resource.hello: Creation complete after 0s [id=8675309]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.The exact ID will be different, but the important part is that Terraform created the resource and ran the local command attached to it.
State
After the apply finishes, you should see a new file in the directory named terraform.tfstate.
This file is JSON, and you can open it to see what Terraform is tracking. In this case, it contains a record for the resource we just created: null_resource.hello. The state file stores information Terraform needs in order to manage that resource, such as the resource ID, the provider that manages it, and other metadata.
State is important because it is how Terraform connects your configuration to the real resources it manages. Your main.tf file says what should exist. The state file records what Terraform currently knows exists.
State files should be treated carefully. Depending on the provider and resources being used, state can contain sensitive values such as secrets, generated passwords, tokens, private attributes, resource IDs, or other metadata. For that reason, you should not commit terraform.tfstate files to your Git repository.
Now that Terraform is tracking our null_resource.hello resource, run terraform plan again:
terraform planBecause we have not changed the configuration since the first apply, Terraform should report that no changes are needed:
No changes. Your infrastructure matches the configuration.
Terraform looked at the configuration in main.tf, compared it against the resource tracked in terraform.tfstate, and determined that nothing needs to be created, changed, or destroyed.
Finally, we can clean up the example by running:
terraform destroyterraform destroy is a destructive action. Terraform reads the state file, shows you what it plans to delete, and asks for confirmation before doing anything. Once approved, Terraform destroys the resource and updates the state file so it no longer tracks that resource.
Why this matters
State is what lets Terraform understand the relationship between your configuration and real-world resources. Without state, Terraform would not know that an EC2 instance with an ID like i-0abc123 is the same resource your configuration calls aws_instance.web_server.
State also helps Terraform detect drift. If someone changes a managed resource directly through a web UI or API, the next terraform plan can compare your configuration, the state file, and the real resource returned by the provider. If they do not match, Terraform can show you what changed and what it would do to bring the resource back in line with your configuration.
This is one of the major differences between Terraform and click-ops. With Terraform, changes are intended to flow through configuration, review, plan, and apply — not through one-off manual changes that no one remembers later.
Do not commit state files. Add these to .gitignore when you start a local Terraform workspace:
*.tfstate
*.tfstate.backupVariables
Hardcoding values becomes a problem as soon as you want to reuse a configuration.
Maybe you want one message for staging and another for production. Maybe one team needs slightly different settings than another. Variables let you separate the structure of what you are building from the specific values used for a given run.
Update the example to use a variable:
variable "message" {
type = string
description = "Message to echo."
default = "hello from Terraform"
}
resource "null_resource" "hello" {
triggers = {
message = var.message
}
provisioner "local-exec" {
command = "echo '${var.message}'"
}
}The var.message reference tells Terraform to use the value of the message variable.
The triggers map is specific to null_resource. It tells Terraform to recreate the resource if one of the trigger values changes. Without it, Terraform would see that null_resource.hello already exists and would not run the provisioner again on later applies.
You can supply the variable at runtime:
terraform apply -var="message=hello from staging"Or as an environment variable:
export TF_VAR_message="hello from production"
terraform applyOr in a terraform.tfvars file:
message = "hello from CI"Terraform loads variable values from several places. A simplified way to think about the order is:
- variable defaults
- environment variables like
TF_VAR_message terraform.tfvars*.auto.tfvars- CLI flags like
-varor-var-file.
Values provided later override values provided earlier.
You will use all of these in practice. Defaults are useful for safe baseline values. .tfvars files are useful for environment-specific configuration. CLI flags are useful for one-off overrides or CI/CD pipelines.
Environment variables are also useful for values you do not want written directly into a .tfvars file. Just remember that this does not automatically make a value secret. If Terraform stores that value as part of a managed resource, it may still appear in state.
What You Learned
In this article, we looked at why Terraform can be useful for IT teams, especially in environments where systems are still managed heavily through GUIs and click-ops. We also covered some core Terraform concepts and walked through a local example that does not require credentials, cloud accounts, or real infrastructure.
We covered:
- What Terraform does
- Why providers matter
- How to initialize a working directory
- How to read a plan
- How to apply a change
- How Terraform state works
- How to clean up with
terraform destroy - How to use variables
Those same mechanics apply whether you are managing a placeholder null_resource, an AWS VPC, a GitHub repository, an Okta group, or a DNS record.
In a future post, I will look to take these same ideas and apply them to something real: managing Okta with Terraform — groups, applications, and policies, all version controlled and reviewed before they are applied.