Skip to content

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:

  1. Write — declare resources in .tf files.
  2. Planterraform plan shows the diff between desired state and current state. Read-only.
  3. Applyterraform apply executes 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:

terraform version
Terraform v1.8.5
on linux_amd64

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
OpenTofu v1.7.3
on linux_amd64

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 a required_providers block and downloaded by terraform 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 every plan/apply to know what already exists.
  • Variables — typed inputs (variable blocks) 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.tfstate by 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 apply simultaneously 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:

mkdir -p ~/tf-hello && cd ~/tf-hello
# 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

terraform init
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)
Success! The configuration is valid.

plan — preview the change

terraform plan
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 apply

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:

cat hello.txt
Hello from Terraform!

A terraform.tfstate file now exists recording that local_file.greeting maps to the file on disk.

Inspect the state

terraform state list   # addresses Terraform is managing
local_file.greeting
terraform show         # full current state, human-readable
# 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

terraform destroy
  # 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"
cat hello.txt
Hello from AdminGuy!

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 (initfmtvalidateplanapply) 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 fmt and terraform validate in your editor and in CI — consistent style, early error detection.
  • Pin provider versions with version = "~> X.Y" in required_providers, and commit .terraform.lock.hcl so 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 apply against the same local state.
  • Never commit .tfstate or 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 .tf files you call with a module "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 destroy to tear down. plan is 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 apply then touches billable infrastructure, so always read the plan first.

Test yourself