Installing Terraform and Project Structure
The idea in one sentence
Section titled “The idea in one sentence”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).
# install tenv (macOS, via Homebrew)brew install tenv
# install and pin a specific Terraform version for the current directorytenv tf install 1.9.0tenv tf use 1.9.0
# tenv reads a .terraform-version file (or the terraform block's# required_version constraint) to pick the right version automaticallyterraform versionWhichever 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 minimal real Azure project
Section titled “A minimal real Azure project”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.11.0"
required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 5.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.
Conventional file layout
Section titled “Conventional file layout”Real projects split that single file into several, by convention:
terraform { required_version = ">= 1.11.0"
required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 5.0" } }}
provider "azurerm" { features {}}variable "storage_account_name" { description = "Globally unique name of the storage account to create" type = string default = "acmemonthlyreports001"}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"}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 init, real provider install output, and the lock file
Section titled “terraform init, real provider install output, and the lock file”terraform init is the command that turns the minimal project above into something you can actually plan. Real output, against the exact terraform/provider block shown earlier:
$ terraform initInitializing provider plugins...- Finding hashicorp/azurerm versions matching "~> 5.0"...- Installing hashicorp/azurerm v5.7.0...- Installed hashicorp/azurerm v5.7.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou run "terraform init" in the future.Two real, persistent things come out of that: a .terraform directory holding the downloaded provider binary, and a .terraform.lock.hcl file — the real content, trimmed to a few hashes for length:
# .terraform.lock.hcl (real, trimmed)# This file is maintained automatically by "terraform init".# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azurerm" { version = "5.7.0" constraints = "~> 5.0" hashes = [ "h1:ZWNc2pM6kUJtdekpy3Z3rfbJSw9KgY+qcQRLBJHAUJ0=", "zh:186b696f83510c6e69a4fc924469179cd39bf3dbb7dbb5d9e3fdd6255413a8c", "zh:2273d01bb3d3310467e35644c569736462030b41fed91b2a4043ab9cedf9b4", # ... one zh: hash per published platform (darwin_arm64, linux_amd64, ...) ]}version is the exact version Terraform resolved from the ~> 5.0 constraint — not the constraint itself. hashes is a checksum list covering every platform the provider publishes a binary for, not just yours; this is what lets terraform init on a teammate’s Linux machine (or a Linux CI runner) verify the same provider release you resolved on macOS, byte-for-byte, without re-resolving the version constraint. Commit .terraform.lock.hcl to version control. Without it, two runs of terraform init against the same ~> 5.0 constraint, weeks apart, can legitimately resolve to two different 5.x patch releases — the lock file is what makes provider versions reproducible across machines and time, the same job package-lock.json or Cargo.lock does in other ecosystems. Do not commit .terraform/, the directory the provider binary itself was downloaded into — that is a local cache, regenerated by init on any machine from the lock file.
TF_PLUGIN_CACHE_DIR: one shared download, many projects
Section titled “TF_PLUGIN_CACHE_DIR: one shared download, many projects”By default, every project’s .terraform/providers/ directory gets its own full copy of every provider binary it uses — harmless for one project, wasteful (and slow) once you have a dozen repositories all pinning hashicorp/azurerm ~> 5.0. Setting the TF_PLUGIN_CACHE_DIR environment variable to one shared directory fixes this: Terraform downloads a given provider version into that shared cache exactly once, and every subsequent init — in any project on the machine — links to it instead of re-downloading.
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"mkdir -p "$TF_PLUGIN_CACHE_DIR"terraform initProof this is a real link, not a second copy — after init with TF_PLUGIN_CACHE_DIR set, the provider binary inside the project’s own .terraform/providers/ is a symlink into the shared cache directory:
$ ls -la .terraform/providers/registry.terraform.io/hashicorp/azurerm/5.7.0/lrwxr-xr-x darwin_arm64 -> /Users/you/.terraform.d/plugin-cache/registry.terraform.io/hashicorp/azurerm/5.7.0/darwin_arm64A second project, pinning the same hashicorp/azurerm ~> 5.0 constraint, with the same TF_PLUGIN_CACHE_DIR set, resolves init without a fresh download at all — Terraform finds the version already present in the cache and links to it immediately. This is exactly what CI pipelines and this course’s own verification harness use to avoid re-downloading the same provider binary for every single lesson.
Day-1 hygiene: fmt and validate
Section titled “Day-1 hygiene: fmt and validate”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.
terraform fmtterraform 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.
terraform validateSuccess! 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.
Mental Model
Section titled “Mental Model”Everything in this lesson is about making a project’s inputs reproducible before anything gets created: the Terraform version (a version manager or required_version), the provider version and its exact binary (required_providers plus the lock file), and the syntax itself (fmt/validate). None of these four things ever talks to Azure — they are all purely local, deterministic, and about making sure the same configuration produces the same plan on any machine, which is the precondition for everything the rest of this course does.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Version manager (tenv) over a single global binary | Different projects can pin different Terraform/Terragrunt versions on one machine, switched automatically | One more tool to install and keep updated itself |
Committing .terraform.lock.hcl | Reproducible provider versions across machines and time, proven above with a real lock file’s hashes covering every platform | Must be regenerated (terraform init -upgrade) deliberately when a real version bump is wanted — it does not silently track “latest” |
TF_PLUGIN_CACHE_DIR | One shared download instead of one copy per project, proven above with a real symlink | A shared, mutable directory across projects — a corrupted cache entry affects every project pointing at it, not just one |
terraform fmt/validate before every commit | Catches syntax and consistency mistakes for free, before any Azure API call | Neither one catches anything that requires real Azure state — a clean validate is necessary, not sufficient |
Common Misconceptions
Section titled “Common Misconceptions”- “The lock file just repeats the version constraint from
required_providers.” Proven above:required_providershasversion = "~> 5.0"(a constraint);.terraform.lock.hclhasversion = "5.7.0"(the one exact version actually resolved) plus a full hash list for every platform, not just yours. - “
.terraform/(the downloaded provider binary) should be committed alongside the lock file.” It shouldn’t — proven above, it’s regenerated byiniton any machine from the lock file, and withTF_PLUGIN_CACHE_DIRset it isn’t even a real copy, just a symlink into the shared cache. - “
TF_PLUGIN_CACHE_DIRcopies the provider binary into each project.” Proven above with a realls -la: the file inside the project’s own.terraform/providers/is a symlink to the shared cache directory, not a second copy. - “A clean
terraform validatemeansterraform applywill succeed.” Proven above (and reiterated by the course’s own harness design):validatenever contacts Azure, so a taken storage account name, a missing permission, or a hit quota only ever surfaces atplanorapply. - “Splitting a project into
main.tf/variables.tf/outputs.tfchanges how Terraform evaluates it.” It doesn’t — proven above, Terraform concatenates every.tffile in a directory into one configuration regardless of filename; the split is purely for humans reading the directory.
Reading AI-generated code
Section titled “Reading AI-generated code”- A
.gitignorethat excludes.terraform.lock.hcl(sometimes bundled in a generic.terraform*ignore pattern) — proven above, this is the one file in.terraform*that should always be committed; only the.terraform/directory itself should be ignored. - A generated setup script that deletes and re-runs
terraform initon every CI run withoutTF_PLUGIN_CACHE_DIRset — proven above, this re-downloads the same provider binary every single run instead of reusing a shared cache. - Code or docs claiming
terraform validateguarantees anapplywill succeed — proven above, it is a purely local syntax/consistency check with zero Azure API calls. - A generated project with every resource crammed into one
main.tfwith novariables.tf/outputs.tfsplit at all — not wrong (Terraform doesn’t care), but worth flagging as a readability regression once a config grows past a handful of resources. terraform init -upgradeused routinely (e.g. in a cron job or every CI run) instead of only when a version bump is actually wanted — this defeats the reproducibility the lock file exists to provide, silently drifting provider versions over time.
🐛 Spot the bug
.terraform/.terraform.lock.hcl*.tfstate*.tfstate.backupShow the answer
.terraform.lock.hcl should not be in .gitignore — it is the one artifact from terraform init that exists specifically to be committed. Proven above: it pins the exact resolved provider version (5.7.0, not the ~> 5.0 constraint) plus checksums for every platform, which is what makes terraform init reproducible across machines and over time. Ignoring it means every teammate’s init, and every CI run, can legitimately resolve a different 5.x patch release of hashicorp/azurerm — silently, with no error, since ~> 5.0 is satisfied either way. Only .terraform/ (the downloaded binary, regenerated from the lock file by any init) and the *.tfstate* entries belong in .gitignore; the fix is simply removing the .terraform.lock.hcl line.