Infrastructure as Code: Terraform Basics¶
Terraform lets you describe infrastructure as code: you declare the desired state in text files, and a tool reconciles reality to match. This tutorial gets you from zero to a working apply/destroy loop using a provider that needs no cloud account, then explains how the same workflow scales to real infrastructure.
Tested on
Terraform 1.8.x and OpenTofu 1.7.x on Linux (Ubuntu 22.04 / 24.04). HCL syntax and the init/plan/apply workflow are stable across the 1.x line. Every command below is copy-pasteable.
What Infrastructure as Code is¶
Instead of clicking through a cloud console or running ad-hoc scripts, you write declarative configuration that states what you want — three servers, a network, a DNS record — and let the tool figure out how to get there. Re-running the same config is safe: if reality already matches, nothing happens (idempotency).
What Terraform does¶
Terraform reads .tf files written in HCL (HashiCorp Configuration Language), builds a dependency graph of the resources you declared, and works out the minimal set of create/update/delete API calls needed. The cycle is always:
- Write — declare resources in
.tffiles. - Plan —
terraform planshows the diff between desired state and current state. Read-only. - Apply —
terraform applyexecutes that plan against the provider's API.
Terraform talks to the outside world through provider plugins — AWS, OpenStack, Cloudflare, Proxmox, Kubernetes, and hundreds more. The core is provider-agnostic; the providers do the API work.
How it differs from Ansible¶
This trips up a lot of people. Both are "automation," but they live at different layers:
| Terraform | Ansible | |
|---|---|---|
| Job | Provision/manage infrastructure lifecycle — create, change, destroy resources | Configure existing machines — install packages, edit files, start services |
| Model | Declarative desired-state with a stored state file | Procedural-ish tasks (declarative modules, but no persistent state) |
| Typical use | "Give me a VM, a network, and a load balancer" | "On this VM, install nginx and deploy the app" |
In practice they pair up: Terraform stands up the servers, then Ansible configures what runs on them. For installing software on a box you already have, reach for Ansible and your distro's package manager, not Terraform.
OpenTofu¶
OpenTofu is the open-source, community-governed fork of Terraform (created after HashiCorp moved Terraform to the BUSL license). It is a drop-in replacement: the configuration language, the workflow, and the commands are identical — just run tofu wherever this tutorial says terraform. Everything here works on either.
Install¶
Terraform via HashiCorp's apt repo (Debian/Ubuntu)¶
# Dependencies
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common curl
# Add HashiCorp's GPG key
wget -O- https://apt.releases.hashicorp.com/gpg | \
gpg --dearmor | \
sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null
# Add the repo
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
# Install
sudo apt-get update && sudo apt-get install -y terraform
Verify:
Or just download the binary¶
Terraform ships as a single static binary, which is handy on non-Debian distros or in CI:
curl -fsSLO https://releases.hashicorp.com/terraform/1.8.5/terraform_1.8.5_linux_amd64.zip
unzip terraform_1.8.5_linux_amd64.zip
sudo install -m 0755 terraform /usr/local/bin/terraform
terraform version
OpenTofu (brief)¶
# Snap is the quickest path; a deb repo and standalone installer also exist.
sudo snap install --classic opentofu
tofu version
Shell completion
terraform -install-autocomplete adds tab completion for bash/zsh. Restart your shell afterward.
Core concepts¶
- Provider — a plugin that knows how to talk to one platform's API (
hashicorp/aws,hashicorp/local,cloudflare/cloudflare, …). Declared in arequired_providersblock and downloaded byterraform init. - Resource — a single managed object: a file, a VM, a DNS record. Written
resource "<type>" "<name>" { ... }. The<type>.<name>pair is its address inside Terraform. - State file (
terraform.tfstate) — a JSON file mapping your config to the real-world objects Terraform created, including their IDs and last-known attributes. Terraform reads it on everyplan/applyto know what already exists. - Variables — typed inputs (
variableblocks) so configs are reusable instead of hard-coded. - Outputs — values a config exposes after apply (an IP, a URL, a generated name), for humans or for other tooling.
The state file is critical — treat it like a database
- Never edit
terraform.tfstateby hand. It is the source of truth for what Terraform thinks exists; corrupt it and Terraform may try to recreate or orphan real resources. - It often contains secrets in plaintext (generated passwords, keys). Never commit it to git.
- For any team, store state in a remote backend (S3, GCS, Terraform Cloud, an OpenStack Swift container, etc.) with state locking so two people can't
applysimultaneously and corrupt it.
A runnable first example (no cloud account needed)¶
We'll use the official hashicorp/local provider, which manages files on your own machine. No credentials, no billing, nothing to clean up but a file.
Create a working directory and a main.tf:
# main.tf
terraform {
required_version = ">= 1.3"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
provider "local" {}
resource "local_file" "greeting" {
filename = "${path.module}/hello.txt"
content = "Hello from Terraform!\n"
file_permission = "0644"
}
init — download providers¶
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.5.1...
- Installed hashicorp/local v2.5.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!
init creates a .terraform/ directory (the downloaded plugins) and .terraform.lock.hcl (the dependency lock file, which pins exact provider versions and checksums — commit this one).
fmt and validate — tidy and sanity-check¶
terraform fmt # rewrites files to canonical style; prints names it changed
terraform validate # checks syntax and internal consistency (no API calls)
plan — preview the change¶
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.greeting will be created
+ resource "local_file" "greeting" {
+ content = "Hello from Terraform!\n"
+ content_base64sha256 = (known after apply)
+ content_md5 = (known after apply)
+ filename = "./hello.txt"
+ file_permission = "0644"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
The + means "create." (known after apply) marks values the provider computes at apply time.
apply — make it real¶
Terraform shows the same plan and prompts:
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.greeting: Creating...
local_file.greeting: Creation complete after 0s [id=...]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Check it worked:
A terraform.tfstate file now exists recording that local_file.greeting maps to the file on disk.
Inspect the state¶
# local_file.greeting:
resource "local_file" "greeting" {
content = "Hello from Terraform!\n"
filename = "./hello.txt"
file_permission = "0644"
id = "..."
}
Run terraform plan again and you'll see No changes. Your infrastructure matches the configuration. — that's idempotency: desired state already equals actual state.
destroy — tear it down¶
# local_file.greeting will be destroyed
- resource "local_file" "greeting" {
- content = "Hello from Terraform!\n" -> null
- filename = "./hello.txt" -> null
...
}
Plan: 0 to add, 0 to change, 1 to destroy.
Enter a value: yes
local_file.greeting: Destroying... [id=...]
local_file.greeting: Destruction complete after 0s
Destroy complete! Resources: 1 destroyed.
hello.txt is gone and the state is empty.
Variables and outputs¶
Hard-coded strings don't scale. Add a variable with a default and an output to your main.tf:
variable "greeting_name" {
description = "Name to greet in the generated file."
type = string
default = "Terraform"
}
resource "local_file" "greeting" {
filename = "${path.module}/hello.txt"
content = "Hello from ${var.greeting_name}!\n"
file_permission = "0644"
}
output "file_path" {
description = "Absolute path of the generated file."
value = abspath(local_file.greeting.filename)
}
Apply with the default, then override on the command line:
terraform apply -auto-approve # uses default "Terraform"
terraform apply -auto-approve -var greeting_name=AdminGuy
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
Outputs:
file_path = "/home/leap/tf-hello/hello.txt"
Variables can also come from a terraform.tfvars file, -var-file=..., or TF_VAR_greeting_name environment variables. Outputs can be read later with terraform output file_path.
-auto-approve skips the confirmation prompt
Convenient for the harmless local provider and for CI. Against real infrastructure, prefer reviewing the plan interactively or running plan -out=tfplan then apply tfplan.
Connecting to the real world¶
The example above is real Terraform — only the provider is local. To provision actual infrastructure, you swap the provider and write resources for that platform. The workflow (init → fmt → validate → plan → apply) is identical. A sketch with the AWS provider:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-south-1"
}
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890" # use a real, region-specific AMI
instance_type = "t3.micro"
tags = {
Name = "adminguy-demo"
}
}
The same shape applies to OpenStack (terraform-provider-openstack/openstack), Cloudflare, Proxmox, Kubernetes, and the rest. Credentials are supplied out-of-band (environment variables, a credentials file, or a secrets manager) — not hard-coded in .tf files.
apply against a real provider creates and destroys real, billable resources
Unlike the local example, an apply against AWS/OpenStack/etc. spins up infrastructure that costs money and a destroy permanently deletes it. Always read the plan before approving. A misplaced change can recreate a database or tear down production. Run terraform plan first, confirm the add/change/destroy counts match your intent, and only then apply.
Best practices¶
terraform fmtandterraform validatein your editor and in CI — consistent style, early error detection.- Pin provider versions with
version = "~> X.Y"inrequired_providers, and commit.terraform.lock.hclso everyone (and CI) uses identical plugin versions. - Use a remote backend with locking for any shared work (S3 + DynamoDB lock, GCS, Terraform Cloud, OpenStack Swift). Never let two people
applyagainst the same local state. - Never commit
.tfstateor secrets. A minimal.gitignore:
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars # often holds secrets; commit a *.tfvars.example instead
crash.log
Do commit: your *.tf files and .terraform.lock.hcl.
- Use modules to reuse code. A module is just a directory of
.tffiles you call with amodule "name" { source = "./modules/vpc" ... }block — parameterize with variables, expose results with outputs. Start flat; refactor into modules once a pattern repeats.
Verify your work¶
Run through the local example end to end and confirm each step:
cd ~/tf-hello
terraform init # -> "Terraform has been successfully initialized!"
terraform validate # -> "Success! The configuration is valid."
terraform plan # -> "Plan: 1 to add, 0 to change, 0 to destroy."
terraform apply -auto-approve
test -f hello.txt && echo "file created OK" # -> file created OK
terraform state list # -> local_file.greeting
terraform output file_path # -> path to hello.txt
terraform destroy -auto-approve
test ! -f hello.txt && echo "cleaned up OK" # -> cleaned up OK
If init fails to download the provider, check outbound HTTPS to registry.terraform.io. If apply reports the file already exists and differs, that's Terraform detecting drift — exactly what state is for.
Summary¶
- Terraform (and its drop-in fork OpenTofu) manages infrastructure declaratively: you describe desired state, it computes and executes the diff via provider plugins.
- The loop is always write → init → plan → apply, with
destroyto tear down.planis a safe, read-only preview. - The state file maps config to real resources; never edit it by hand, never commit it, and use a locked remote backend for teams.
- Terraform provisions infrastructure; Ansible and your package manager configure the machines it creates.
- Swapping the provider is how you go from a local file to real cloud resources — same workflow, but
applythen touches billable infrastructure, so always read the plan first.