Skip to content
Commitpedia
Log in Join & edit

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?

Edit history of Manage infrastructure as code with Terraform: from first file to plans on pull requests: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
10 min

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 plan on 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-ok is printed and validation succeeds.
  • -detailed-exitcode returns 0 when there are no changes, 2 when there are changes, and 1 on errors; you want exit=0.
  • The state lists four resources and generated/ contains blog.ini, shop.ini, wiki.ini and lab-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-state if 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 answer yes.
  • A plan wants to replace something you did not expect. Look for # forces replacement in the plan: some attributes, like a file name here, cannot be changed in place.
  • The provider version changed unexpectedly. init uses the version in .terraform.lock.hcl. Only terraform init -upgrade moves 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-migration or the .backup file) and run terraform plan to 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 import and import blocks 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

Repos in the wild

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. Commitpedia +15,129 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?