Terraform ships as a single static binary: no runtime, no dependencies, no virtual environments. You download one file, place it on your PATH, and you are done. This simplicity is one of Terraform's best qualities.
This tutorial covers three installation paths:
After installing, you will verify the setup and run a real (but harmless) Terraform configuration that writes a local file, no cloud account needed.
Homebrew is the fastest and cleanest way to install Terraform on macOS. It handles upgrades automatically.
# Add the official HashiCorp tap
brew tap hashicorp/tap
# Install Terraform
brew install hashicorp/tap/terraformTo upgrade to a newer version later:
brew upgrade hashicorp/tap/terraformIf you do not use Homebrew, download the binary directly:
.zip for darwin_amd64 (Intel) or darwin_arm64 (Apple Silicon)/usr/local/bin:unzip terraform_*.zip
sudo mv terraform /usr/local/bin/Add the official HashiCorp repository and install:
# Install dependencies
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
# Add HashiCorp's GPG key
wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
# Add the repository
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 Terraform
sudo apt-get update && sudo apt-get install terraformTo upgrade later, run sudo apt-get update && sudo apt-get upgrade terraform.
# Add the HashiCorp repo
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
# Install Terraform
sudo yum -y install terraform# Find the latest version at releases.hashicorp.com
TERRAFORM_VERSION="1.9.0"
wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip"
sudo mv terraform /usr/local/bin/
sudo chmod +x /usr/local/bin/terraformTip: Replace
linux_amd64withlinux_arm64if you are on an ARM machine such as a Raspberry Pi or AWS Graviton instance.
Windows 11 and recent Windows 10 builds include Winget:
winget install HashiCorp.Terraformchoco install terraformwindows_amd64.zipterraform.exe somewhere, for example C:\tools\terraform\PATH:
Path and click EditC:\tools\terraformIn production you often work across several projects that use different Terraform versions. tfenv is a version manager (similar to nvm for Node.js) that lets you switch between versions per project.
git clone --depth=1 https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc# List available versions
tfenv list-remote
# Install a specific version
tfenv install 1.9.0
# Use it
tfenv use 1.9.0Create a .terraform-version file in your project root to pin the version automatically:
echo "1.9.0" > .terraform-versionAnyone who clones the repo and has tfenv installed will automatically use the same version.
No matter how you installed it, confirm Terraform is available:
terraform -versionExpected output (version numbers will differ):
Terraform v1.9.0
on darwin_arm64
If you see command not found, the binary is not on your PATH. Double-check the installation steps above.
Terraform supports tab completion for commands and resource types. Enable it once:
terraform -install-autocompleteRestart your shell. Now you can type terraform pl<TAB> and it will complete to terraform plan.
Let us run a real Terraform configuration, one that does not require a cloud account. You will use the built-in local provider to create a text file on your computer.
mkdir ~/terraform-hello
cd ~/terraform-helloCreate a file called main.tf with this content:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.4"
}
}
required_terraform = {
version = ">= 1.5.0"
}
}
resource "local_file" "hello" {
filename = "${path.module}/hello.txt"
content = "Hello from Terraform! Built by DevOpsLesson."
}terraform initOutput:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.4"...
- Installing hashicorp/local v2.4.1...
Terraform has been successfully initialized!
terraform init downloaded the local provider plugin. You will see a new .terraform/ directory and a .terraform.lock.hcl file, commit the lock file to Git, and add .terraform/ to your .gitignore.
terraform planOutput:
Terraform will perform the following actions:
# local_file.hello will be created
+ resource "local_file" "hello" {
+ content = "Hello from Terraform! Built by DevOpsLesson."
+ filename = "/Users/you/terraform-hello/hello.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
+ means a resource will be created. Nothing has changed yet, this is just the preview.
terraform applyTerraform shows the plan again and prompts you:
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
Type yes and press Enter. Terraform creates hello.txt.
cat hello.txt
# Hello from Terraform! Built by DevOpsLesson.cat terraform.tfstateYou will see a JSON file describing the resource Terraform just created. This is the state file: Terraform's record of what it manages. Never edit it by hand.
terraform destroyTerraform will show what it plans to delete and ask for confirmation. Type yes. The hello.txt file is removed.
When you commit a Terraform project, include this in your .gitignore:
# Terraform directory (downloaded providers)
.terraform/
# State files, store these in a remote backend, not Git
*.tfstate
*.tfstate.backup
# Override files
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Variables file with secrets
*.auto.tfvars
terraform.tfvarsImportant: Never commit
terraform.tfstateorterraform.tfvars(which may contain secrets) to a public repository.
For a real project, a clean file layout looks like this:
my-project/
âââ main.tf # Core resources
âââ variables.tf # Input variable declarations
âââ outputs.tf # Output value declarations
âââ providers.tf # Provider configuration
âââ versions.tf # terraform{} block with version constraints
âââ terraform.tfvars # Variable values (gitignored if sensitive)
Splitting files by concern, rather than dumping everything in main.tf, makes large configurations much easier to navigate.
<MCQExercise exercise={{ id: 'tf-install-q1', title: 'Question 1: The .terraform Directory', description: 'After running terraform init, a .terraform/ directory appears. What does it contain?', options: [ { id: 'a', text: 'Your .tf configuration files, backed up by Terraform for safety' }, { id: 'b', text: 'Downloaded provider plugins and backend configuration' }, { id: 'c', text: 'The Terraform binary itself, installed locally for the project' }, { id: 'd', text: 'Encrypted versions of your terraform.tfvars secrets' }, ], correctId: 'b', explanation: 'terraform init downloads the provider plugins declared in your configuration (e.g., the AWS provider) and stores them in .terraform/providers/. It also stores backend configuration. The .terraform/ directory should be gitignored, it can be hundreds of megabytes and is always re-created by terraform init.', wrongExplanations: { a: '.tf files are never copied or backed up. They stay exactly where you put them.', c: 'The Terraform binary is installed globally on your machine, not per project.', d: 'Terraform does not encrypt or store your tfvars files. The .terraform directory is for providers and backend config only.', }, }} />
<MCQExercise exercise={{ id: 'tf-install-q2', title: 'Question 2: The Lock File', description: 'You find a .terraform.lock.hcl file in your project. What should you do with it?', options: [ { id: 'a', text: 'Delete it, it is a temporary file created during init' }, { id: 'b', text: 'Add it to .gitignore so it is not shared between team members' }, { id: 'c', text: 'Commit it to Git so your whole team uses the same provider versions' }, { id: 'd', text: 'Move it to the .terraform/ directory where it belongs' }, ], correctId: 'c', explanation: '.terraform.lock.hcl pins the exact versions of provider plugins Terraform downloaded. By committing it to Git, you ensure every team member and every CI pipeline uses exactly the same provider versions, preventing "works on my machine" problems caused by provider version mismatches.', wrongExplanations: { a: 'The lock file is important and intentional, it should be kept and committed.', b: 'The opposite is true. You should commit the lock file. The .terraform/ directory itself should be gitignored.', d: 'The lock file lives in your project root alongside your .tf files, not inside .terraform/.', }, }} />
<MCQExercise exercise={{ id: 'tf-install-q3', title: 'Question 3: terraform plan Output', description: 'You run terraform plan and see this line: "Plan: 1 to add, 0 to change, 0 to destroy." What does this mean?', options: [ { id: 'a', text: 'Terraform already applied the changes, 1 resource was added to your infrastructure' }, { id: 'b', text: 'Terraform will create 1 new resource the next time you run terraform apply' }, { id: 'c', text: 'You have 1 resource in your .tf file, 0 in state, and 0 planned for removal' }, { id: 'd', text: 'There was 1 error, 0 warnings, and 0 successful operations' }, ], correctId: 'b', explanation: 'terraform plan only previews changes, it never modifies infrastructure. "1 to add" means that when you run terraform apply, Terraform will create 1 new resource. Nothing has changed yet. The plan is purely informational.', wrongExplanations: { a: 'terraform plan never applies changes. Only terraform apply does that.', c: 'The numbers refer to planned actions (add, change, destroy), not counts of resources in different locations.', d: 'The plan summary describes infrastructure changes, not errors or warnings.', }, }} />
<MCQExercise exercise={{ id: 'tf-install-q4', title: 'Question 4: Gitignore Best Practice', description: 'Which of the following should you commit to a Git repository for a Terraform project?', options: [ { id: 'a', text: 'terraform.tfstate, so your team always has the latest state' }, { id: 'b', text: '.terraform/, so teammates do not need to run terraform init' }, { id: 'c', text: 'terraform.tfvars, so variable values are available to everyone' }, { id: 'd', text: '.terraform.lock.hcl, so your team uses consistent provider versions' }, ], correctId: 'd', explanation: 'Only .terraform.lock.hcl should be committed. The .terraform/ directory is large and regenerated by terraform init. terraform.tfstate belongs in a remote backend (not Git) because it may contain sensitive values and causes merge conflicts. terraform.tfvars often contains secrets and should be gitignored.', wrongExplanations: { a: 'State files must never be in Git, they contain sensitive data (passwords, IPs) and cause catastrophic merge conflicts. Use a remote backend like S3 or Terraform Cloud.', b: '.terraform/ can be hundreds of MB and is always regenerated by terraform init. It belongs in .gitignore.', c: 'terraform.tfvars often contains passwords and API keys. Gitignore it and use a secrets manager or CI/CD environment variables instead.', }, }} />
Use tfenv, which installs and switches between versions per project. Create a .terraform-version file in the repository and tfenv selects the matching version automatically, so everyone on the team and your CI pipeline run the same one. This matters because Terraform writes a version marker into state, and a newer version will upgrade the state format. After which older versions can no longer read it. Pinning avoids one person accidentally upgrading state for everybody.
At minimum .terraform/ (downloaded providers and modules, which are large and machine-specific), *.tfstate and *.tfstate.backup (state belongs in a remote backend, and contains secrets in plain text), crash.log, and any *.tfvars files holding real values. Do commit .terraform.lock.hcl, which pins provider versions and makes builds reproducible. It is frequently gitignored by mistake, which defeats its purpose entirely.
Yes. It records the exact provider versions and their checksums, so everyone on the team and your CI pipeline resolve identical providers rather than picking up a new minor release independently. It is the direct equivalent of a package-lock.json or Cargo.lock. Update it deliberately with terraform init -upgrade when you intend to move provider versions, and review the resulting diff like any other dependency change.
For anything small, a single directory with main.tf, variables.tf, outputs.tf and versions.tf is perfectly adequate. Splitting by resource type too early adds navigation cost with no benefit. As it grows, separate by environment with shared modules rather than copying configuration, so environments/production and environments/staging both call the same module with different values. Keep each environment's state separate, which prevents a staging mistake from touching production.
Only if the Business Source License is a problem for you. OpenTofu is a fork of the last MPL-licensed Terraform, now under the Linux Foundation, and is close to a drop-in replacement with the same configuration language and workflow. For ordinary infrastructure work either is fine. Check which your team's tooling, modules and CI integrations target, since a few vendor integrations still assume one or the other.
Understand the Terraform init, plan, apply and destroy loop: what each command does, how to read a plan and the mistakes that cause outages.
Learn what terraform init does: download providers, configure the backend, write the lock file, use -upgrade and -reconfigure and run it in CI/CD.
Read terraform plan symbols correctly, save a plan file with -out, target resources, detect drift and run plan in CI/CD without surprises on apply.