Manage infrastructure as code with Terraform: from first file to plans on pull requests
Learn the full Terraform workflow without a cloud account: variables, a module, outputs, state, plan, apply and destroy, a backend for shared state, formatting checks and a CI plan on every pull request.
Not checked by a trusted editor yet Last edited Outdated?
Infrastructure as code means you describe servers, networks or files in text, keep that text in Git, and let a tool make reality match it. Terraform is the best-known tool for this. OpenTofu is an open-source fork of it with the same language and nearly the same commands (tofu instead of terraform).
This tutorial is for beginners. Instead of a cloud account, it uses the hashicorp/local provider, which manages files on your own disk. That keeps it free and safe, while every concept you learn, from plan to state to modules, is exactly what you use later with AWS, Azure, Proxmox or Kubernetes. The whole tutorial was run end to end with Terraform 1.16 and the local provider 2.9.
What you will build
- A Terraform project that generates one config file per website from a map variable, plus an index file.
- A small reusable module with input validation and outputs.
- The everyday loop:
fmt,validate,plan,apply, change, plan again,destroy. - A look inside the state file, drift detection and a migration of state to a different backend location.
- A GitHub Actions workflow that checks formatting, validates and runs
terraform planon every pull request.
Before you start
- A computer with Linux, macOS or Windows and internet access to download Terraform and the provider.
- A text editor; the HashiCorp Terraform extension for VS Code adds highlighting and completion.
- Optional: Git and a GitHub account for the CI step.
- Time: about 60 minutes.
- Knowledge: basic command line use. No cloud knowledge is needed. Knowing what Git branches and pull requests are helps for the last step.
Step 1: Install Terraform
On macOS use Homebrew with HashiCorp’s tap; on Debian or Ubuntu add HashiCorp’s APT repository. Other systems, including Windows, can download a zip file with a single binary from the install page.
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
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 terraform
terraform version
You should see a version line such as Terraform v1.16.6. To follow along with OpenTofu, install it from its own install page and type tofu wherever this tutorial says terraform.
Step 2: Create the project and pin the provider
A Terraform project is a folder of .tf files; Terraform reads all of them together, so file names are only for humans. Start with the requirements: which Terraform version and which providers, from where, in which version range.
mkdir -p tf-demo/modules/site_config
cd tf-demo
# versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
~> 2.5 allows any 2.x release from 2.5 on, but not 3.0, which could contain breaking changes.
Step 3: Write a module
A module is a folder of Terraform files that you call with inputs and that returns outputs, like a function. This one writes one INI file for one site and refuses ports below 1024.
# modules/site_config/variables.tf
variable "name" {
type = string
}
variable "port" {
type = number
validation {
condition = var.port >= 1024 && var.port <= 65535
error_message = "The port must be between 1024 and 65535."
}
}
variable "owner" {
type = string
}
variable "output_dir" {
type = string
}
# modules/site_config/main.tf
resource "local_file" "config" {
filename = "${var.output_dir}/${var.name}.ini"
file_permission = "0644"
content = <<-EOT
[site]
name = ${var.name}
port = ${var.port}
owner = ${var.owner}
EOT
}
# modules/site_config/outputs.tf
output "path" {
value = local_file.config.filename
}
output "checksum" {
value = local_file.config.content_sha256
}
local_file is the resource type from the local provider, config is the name you give this instance. ${...} inserts values into strings, and <<-EOT starts a multi-line string with the indentation removed.
Step 4: Add variables, the root module and outputs
The folder where you run Terraform is the root module. It declares input variables with defaults, calls the module once per site with for_each, and writes an index file.
# variables.tf
variable "project" {
description = "Short project name used in file names"
type = string
default = "demo"
}
variable "sites" {
description = "Sites to generate config for, keyed by name"
type = map(object({
port = number
owner = string
}))
default = {
blog = { port = 8080, owner = "web-team" }
shop = { port = 8081, owner = "shop-team" }
}
}
# main.tf
locals {
output_dir = "${path.module}/generated"
}
module "site" {
source = "./modules/site_config"
for_each = var.sites
name = each.key
port = each.value.port
owner = each.value.owner
output_dir = local.output_dir
}
resource "local_file" "index" {
filename = "${local.output_dir}/${var.project}-sites.txt"
content = join("\n", [for name, site in var.sites : "${name} -> port ${site.port}"])
}
# outputs.tf
output "config_files" {
description = "Generated config file per site"
value = { for name, m in module.site : name => m.path }
}
output "index_file" {
value = local_file.index.filename
}
Step 5: Format and validate
terraform fmt rewrites files into the standard style, and -check only reports which files need it. If you typed the files by hand, it is likely to complain about alignment:
terraform fmt -check -recursive
terraform fmt -recursive -diff
variables.tf
--- old/variables.tf
+++ new/variables.tf
@@ -7,8 +7,8 @@
type = map(object({
- port = number
- owner = string
+ port = number
+ owner = string
}))
The first command lists the file and exits with a non-zero code; the second fixes it. Next, download the provider and check the configuration for errors:
terraform init
terraform validate
init downloads the provider into .terraform/ and writes .terraform.lock.hcl, which records the exact provider version and checksums. Commit the lock file so everyone and CI use the same version. validate should print Success! The configuration is valid.
Step 6: Plan and apply
plan compares the configuration with the state and with reality, and shows what it would do. Saving the plan to a file guarantees that apply does exactly what you reviewed.
terraform plan -out=tfplan
terraform apply tfplan
Plan: 3 to add, 0 to change, 0 to destroy.
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
config_files = {
"blog" = "./generated/blog.ini"
"shop" = "./generated/shop.ini"
}
index_file = "./generated/demo-sites.txt"
cat generated/blog.ini
terraform output
terraform output -json config_files
terraform output -raw index_file
Without -out, terraform apply makes a fresh plan and asks you to type yes. Run terraform plan again now: it should say No changes. because reality matches the configuration.
Step 7: Look at the state
Terraform remembers what it created in the state, by default the file terraform.tfstate in the project folder. Without state, Terraform could not know that generated/blog.ini belongs to it.
terraform state list
terraform state show 'module.site["blog"].local_file.config'
local_file.index
module.site["blog"].local_file.config
module.site["shop"].local_file.config
Treat the state as sensitive and never edit it by hand. It contains every attribute of every resource, and with real providers that includes passwords and keys. Do not commit it to Git.
Step 8: Change something and watch drift
Create terraform.tfvars. Terraform loads this file automatically, and its values override the defaults:
project = "lab"
sites = {
blog = { port = 8080, owner = "web-team" }
shop = { port = 8081, owner = "shop-team" }
wiki = { port = 8082, owner = "docs-team" }
}
terraform plan
The plan adds the wiki file and replaces the index file, because its file name changed from demo-sites.txt to lab-sites.txt; the plan marks such attributes with # forces replacement. Apply with terraform apply and confirm.
Now simulate someone changing things behind Terraform’s back. Delete a generated file and plan again:
rm generated/wiki.ini
terraform plan
Note: Objects have changed outside of Terraform
...
# module.site["wiki"].local_file.config has been deleted
...
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform noticed the drift. Run terraform apply to recreate the file. Finally, try the validation rule; it fails before anything is changed:
terraform plan -var='sites={bad={port=80,owner="x"}}'
Error: Invalid value for variable
...
The port must be between 1024 and 65535.
Step 9: Move the state to a backend
A state file on one laptop does not work for a team: two people would apply with different states. A backend stores the state somewhere shared, and most remote backends (S3-compatible storage, the HTTP backend, HCP Terraform, Consul, Kubernetes and others) also lock it while someone runs plan or apply. Backends cannot use variables; values that differ per environment are passed with -backend-config at init time.
To practise the migration without any account, point the built-in local backend at another folder. The steps are the same for a real remote backend. Back up the state first:
cp terraform.tfstate terraform.tfstate.before-migration
mkdir -p ../shared-state
# backend.tf
terraform {
backend "local" {
path = "../shared-state/lab.tfstate"
}
}
terraform init -migrate-state
Terraform detects the backend change and asks Do you want to copy existing state to the new backend?. Type yes. Afterwards ../shared-state/lab.tfstate holds the state, terraform state list still shows four resources and terraform plan reports no changes. The old local file is left behind empty, plus a .backup copy.
With -input=false, as in CI, the migration refuses to run because it cannot ask for confirmation. Migrations are a human step.
Step 10: Run a plan on every pull request
Put the project into Git, with a .gitignore that keeps local and secret files out:
.terraform/
*.tfstate
*.tfstate.*
tfplan
generated/
Then add .github/workflows/terraform-plan.yml. It installs Terraform with HashiCorp’s official action and runs the same checks you ran by hand:
name: terraform-plan
on:
pull_request:
paths:
- "**.tf"
- ".github/workflows/terraform-plan.yml"
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.16.6"
- name: Format check
run: terraform fmt -check -recursive
- name: Init
run: terraform init -input=false
- name: Validate
run: terraform validate -no-color
- name: Plan
run: terraform plan -no-color -input=false
Every pull request that touches .tf files now shows a check that fails on unformatted code, invalid configuration or a plan error, and the plan output is in the job log for reviewers. The action’s README also shows how to post the plan as a pull request comment.
For this demo the local backend works in CI because state is not shared. With a real project, CI needs read access to the remote backend and to the provider’s credentials, stored as GitHub secrets; keep apply as a separate, protected step after merge.
Check that it works
terraform fmt -check -recursive && echo fmt-ok
terraform validate
terraform plan -detailed-exitcode; echo "exit=$?"
terraform state list | wc -l
ls generated
fmt-okis printed and validation succeeds.-detailed-exitcodereturns0when there are no changes,2when there are changes, and1on errors; you wantexit=0.- The state lists four resources and
generated/containsblog.ini,shop.ini,wiki.iniandlab-sites.txt. - On GitHub, a test pull request shows the workflow as passed.
If something goes wrong
- Backend initialization required, please run “terraform init”. You added or changed a backend or a module. Run
terraform init(with-migrate-stateif you want to keep the state). - Can’t ask approval for state migration when interactive input is disabled. You ran
init -migrate-state -input=false. Run it interactively and answeryes. - A plan wants to replace something you did not expect. Look for
# forces replacementin the plan: some attributes, like a file name here, cannot be changed in place. - The provider version changed unexpectedly.
inituses the version in.terraform.lock.hcl. Onlyterraform init -upgrademoves to a newer version within your constraint. - The state is lost or wrong. Stop and do not apply. Restore the most recent copy (here
terraform.tfstate.before-migrationor the.backupfile) and runterraform planto compare before doing anything else.
Clean up
Warning: terraform destroy deletes everything in the state. Here that is only a few generated files, but with a real provider it deletes servers and databases. Always run terraform plan -destroy first and read the list.
terraform plan -destroy
terraform destroy
Plan: 0 to add, 0 to change, 4 to destroy.
...
Destroy complete! Resources: 4 destroyed.
The empty generated/ folder stays behind because Terraform did not create it as a resource. After destroying, you can delete the whole project folder and ../shared-state.
Next steps
- Swap the local provider for a real one, for example the Docker provider on your machine or your hypervisor’s provider, and keep the same workflow.
- Set up a real remote backend with locking, such as an S3-compatible bucket, and pass its settings with
-backend-config. - Learn
terraform importandimportblocks to bring existing resources under Terraform’s control. - Split environments with separate state per environment (separate folders or workspaces) and reuse the same modules.
- Try the same project with OpenTofu to see how little changes.
Official documentation
See also
- 0039 A minimal GitHub Actions workflow that runs your tests Add one YAML file under .github/workflows and GitHub runs your tests on every push and pull request. Here is a small, explained… Intermediate · How-to · Any system Article GitHub Actions 15 Oct 2026
- 0187 Terraform concepts: providers, state, plan and apply, modules, and OpenTofu Terraform describes infrastructure in code, compares it with what exists, and changes only the difference. Learn providers, resources, state, the plan/apply loop,… Beginner · Concept · Any system Article Terraform 15 Oct 2026
- 0188 First Terraform configuration: variables, a module and outputs (no cloud needed) A complete Terraform project that writes local files with the hashicorp/local provider, so you can practice init, plan, apply, modules and destroy… Intermediate · How-to · Any system Script Terraform 15 Oct 2026
- 0189 Terraform remote state: backends, locking and migrating local state Move Terraform state from a local file into a shared backend such as S3 with locking, so a team can work safely.… Advanced · How-to · Any system Article Terraform 15 Oct 2026
- 0190 Fix: Terraform Error acquiring the state lock Another run holds the lock on your Terraform state, or a crashed run left it behind. Find out who holds it, wait… Advanced · Fix · Any system Article Terraform 15 Oct 2026
Repos in the wild
- OpenTofu: open-source infrastructure as code opentofu/opentofu OpenTofu is the community fork of Terraform under the MPL-2.0 license, run as a Linux Foundation project. It uses the same configuration language and providers. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Commitpedia +15,129 chars