Skip to content

Installing Terraform and Project Structure

Install Terraform through a version manager rather than a single pinned binary, write your first real Azure resource, and split it across a few conventionally named files — none of which Terraform actually requires.

Installing Terraform (and why a version manager beats a single binary)

Section titled “Installing Terraform (and why a version manager beats a single binary)”

You can download a single Terraform binary from HashiCorp and put it on your PATH, and that works fine for a solo experiment. The moment you work on more than one project, though, that approach breaks down: one repository was written against Terraform 1.5, another needs a 1.9 feature, and a third has already migrated to OpenTofu. Keeping one global binary in sync with whichever project you happen to be in is a constant, error-prone chore.

A version manager solves this by installing multiple versions side by side and switching between them per project, usually driven by a version file committed to the repository. tenv is a good choice specifically because it is not tied to a single tool: it manages Terraform, OpenTofu, Terragrunt, and Atmos versions all from one CLI, which matters once your team is running Terragrunt alongside Terraform (covered later in this course).

Terminal window
# install tenv (macOS, via Homebrew)
brew install tenv
# install and pin a specific Terraform version for the current directory
tenv tf install 1.9.0
tenv tf use 1.9.0
# tenv reads a .terraform-version file (or the terraform block's
# required_version constraint) to pick the right version automatically
terraform version

Whichever installation method you use, the day-to-day commands you run against the terraform binary itself are identical — a version manager only changes how that binary gets onto your machine.

A Terraform project is just a directory containing one or more .tf files. The smallest useful one declares a provider, a resource group, and a single resource inside it:

terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_storage_account" "reports" {
name = "acmemonthlyreports001"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}

That is a complete, applyable configuration: no virtual network, no compute, no prerequisites beyond Azure credentials in your environment. azurerm_storage_account is a good first resource for exactly that reason — it does not depend on anything else existing first, other than its resource group. One quirk worth flagging immediately: the name of a storage account must be globally unique across all of Azure, not just within your subscription or resource group — lowercase letters and numbers only, no dashes, which is why real projects usually generate a random or hashed suffix rather than hardcoding a name like the one above.

Real projects split that single file into several, by convention:

providers.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
}
variables.tf
variable "storage_account_name" {
description = "Globally unique name of the storage account to create"
type = string
default = "acmemonthlyreports001"
}
main.tf
resource "azurerm_resource_group" "main" {
name = "example-resources"
location = "East US"
}
resource "azurerm_storage_account" "reports" {
name = var.storage_account_name
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}
outputs.tf
output "storage_account_id" {
description = "Resource ID of the created storage account"
value = azurerm_storage_account.reports.id
}

The names main.tf, variables.tf, outputs.tf, providers.tf, and versions.tf are a convention, not a Terraform requirement. Terraform does not parse filenames at all: it loads every .tf file in a directory, concatenates them into one combined configuration, and does not care whether you called the file main.tf, zzz.tf, or whatever.tf. A resource in outputs.tf and a variable it references in main.tf work exactly the same as if everything lived in one file — Terraform builds the same dependency graph either way. Splitting by concern (providers, variables, resources, outputs) exists purely so a human opening the directory knows where to look, not because Terraform needs the boundary.

flowchart TB
  subgraph dir["Project directory"]
    main["main.tf"]
    vars["variables.tf"]
    outs["outputs.tf"]
    prov["providers.tf"]
    ver["versions.tf"]
  end
  main --> combined["One combined Terraform configuration"]
  vars --> combined
  outs --> combined
  prov --> combined
  ver --> combined
Terraform loads every .tf file in a directory into one combined configuration

Two commands are worth running before every commit, and cost nothing to run constantly.

terraform fmt rewrites files in place to Terraform’s canonical formatting — consistent indentation, aligned = signs, consistent spacing. It fixes style, not logic.

Terminal window
terraform fmt

terraform validate checks that the configuration is internally consistent: valid HCL syntax, correctly typed arguments, referenced variables that actually exist. It catches typos and structural mistakes before you even talk to Azure.

Terminal window
terraform validate
Success! The configuration is valid.

What validate does not do is check anything against the real Azure API. It has no idea whether your subscription has hit a service quota, whether the storage account name you chose is already taken globally, or whether your credentials actually have permission to create it — all of that only surfaces when you run terraform plan, which is the first command that talks to Azure at all. A configuration can pass validate cleanly and still fail at plan or apply time.

Does Terraform care whether a resource is defined in main.tf versus some other filename
What does terraform fmt do
What does terraform validate check
Why is passing terraform validate not enough to guarantee a real Azure-side problem will not occur