Skip to content

Installing Terraform and Project Structure

Install Terraform through a version manager rather than a single pinned binary, write your first real GCP 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 and a single resource:

terraform {
required_version = ">= 1.11.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.0"
}
}
}
provider "google" {
project = "acme-app"
region = "us-central1"
}
resource "google_storage_bucket" "reports" {
name = "acme-monthly-reports"
location = "US"
}

That is a complete, applyable configuration: no VPC, no networking, no prerequisites beyond GCP credentials in your environment (Application Default Credentials, set up with gcloud auth application-default login). google_storage_bucket is a good first resource for exactly that reason — it does not depend on anything else existing first.

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

providers.tf
terraform {
required_version = ">= 1.11.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.0"
}
}
}
provider "google" {
project = "acme-app"
region = "us-central1"
}
variables.tf
variable "bucket_name" {
description = "Name of the storage bucket to create"
type = string
default = "acme-monthly-reports"
}
main.tf
resource "google_storage_bucket" "reports" {
name = var.bucket_name
location = "US"
}
outputs.tf
output "bucket_self_link" {
description = "Self link of the created storage bucket"
value = google_storage_bucket.reports.self_link
}

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

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 init
Initializing provider plugins...
- Finding hashicorp/google versions matching "~> 8.0"...
- Installing hashicorp/google v8.4.0...
- Installed hashicorp/google v8.4.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you 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/google" {
version = "8.4.0"
constraints = "~> 8.0"
hashes = [
"h1:vxMhPGBXNTrgoK8PpiDUHwboHUuiUhb2HPG8pYZpySE=",
"zh:0a5eb94e558ae40cc0d992ce1c9bd717decda1a109fd743c5e0872be858f2c7",
"zh:1b0c6708fadba06883f56e5de2b7c199450642a1d1af932d09a95983935f10b",
# ... one zh: hash per published platform (darwin_arm64, linux_amd64, ...)
]
}

version is the exact version Terraform resolved from the ~> 8.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 ~> 8.0 constraint, weeks apart, can legitimately resolve to two different 8.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/google ~> 8.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.

Terminal window
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
terraform init

Proof 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/google/8.4.0/
lrwxr-xr-x darwin_arm64 -> /Users/you/.terraform.d/plugin-cache/registry.terraform.io/hashicorp/google/8.4.0/darwin_arm64

A second project, pinning the same hashicorp/google ~> 8.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.

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 GCP.

Terminal window
terraform validate
Success! The configuration is valid.

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

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 GCP — 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.

ChoiceBenefitCost
Version manager (tenv) over a single global binaryDifferent projects can pin different Terraform/Terragrunt versions on one machine, switched automaticallyOne more tool to install and keep updated itself
Committing .terraform.lock.hclReproducible provider versions across machines and time, proven above with a real lock file’s hashes covering every platformMust be regenerated (terraform init -upgrade) deliberately when a real version bump is wanted — it does not silently track “latest”
TF_PLUGIN_CACHE_DIROne shared download instead of one copy per project, proven above with a real symlinkA shared, mutable directory across projects — a corrupted cache entry affects every project pointing at it, not just one
terraform fmt/validate before every commitCatches syntax and consistency mistakes for free, before any GCP API callNeither one catches anything that requires real GCP state — a clean validate is necessary, not sufficient
  • “The lock file just repeats the version constraint from required_providers.” Proven above: required_providers has version = "~> 8.0" (a constraint); .terraform.lock.hcl has version = "8.4.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 by init on any machine from the lock file, and with TF_PLUGIN_CACHE_DIR set it isn’t even a real copy, just a symlink into the shared cache.
  • “TF_PLUGIN_CACHE_DIR copies the provider binary into each project.” Proven above with a real ls -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 validate means terraform apply will succeed.” Proven above (and reiterated by the course’s own harness design): validate never contacts GCP, so a taken bucket name, a missing service account permission, or a hit quota only ever surfaces at plan or apply.
  • “Splitting a project into main.tf/variables.tf/outputs.tf changes how Terraform evaluates it.” It doesn’t — proven above, Terraform concatenates every .tf file in a directory into one configuration regardless of filename; the split is purely for humans reading the directory.
  • A .gitignore that 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 init on every CI run without TF_PLUGIN_CACHE_DIR set — proven above, this re-downloads the same provider binary every single run instead of reusing a shared cache.
  • Code or docs claiming terraform validate guarantees an apply will succeed — proven above, it is a purely local syntax/consistency check with zero GCP API calls.
  • A generated project with every resource crammed into one main.tf with no variables.tf/outputs.tf split 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 -upgrade used 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

.gitignore
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
Show 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 (8.4.0, not the ~> 8.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 8.x patch release of hashicorp/google — silently, with no error, since ~> 8.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.

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 GCP-side problem will not occur
What is the difference between the version in required_providers and the version in .terraform.lock.hcl?
Which file(s) should be committed to version control?
With TF_PLUGIN_CACHE_DIR set, what does the provider binary inside a project's own .terraform/providers/ directory actually contain?