Terraform Lesson 2 of 57

The Terraform CLI, In Depth: Install, First Steps & the Complete Command Reference

Everything you ever do with Terraform, you do through one program: the terraform command-line interface. The HCL files describe what you want; the CLI is the engine that reads those files, talks to the cloud APIs, records what it built, and shows you the difference between what you asked for and what actually exists. Most engineers learn three or four of its subcommands — init, plan, apply, destroy — and stop there. That is enough to get going, but it leaves you helpless the day a state lock won’t release, a provider needs upgrading, a single resource must be replaced without touching the rest, or you need to read a value out of state for a script. This lesson is the complete tour: how to install the CLI properly (including version management, which you will need), the core workflow explained not as four magic words but in terms of exactly what each one does to your state file and to the real world, and then the entire command surface — every subcommand and its important flags, laid out in tables you can come back to.

I have written this to be the single page you keep open while you work. It is beginner-accessible — every term is defined the first time it appears — but it is deliberately exhaustive, because the whole point of a command reference is that nothing useful is missing. Where the open-source fork OpenTofu behaves identically (which is almost everywhere) I say so; you simply type tofu instead of terraform and everything below applies unchanged. By the end you will know how to get the tool onto any machine, pin its version so your team is reproducible, drive the full lifecycle of infrastructure, and reach for the right subcommand and flag in the moments — locks, upgrades, surgical replacements, imports — when knowing the CLI is the difference between a five-minute fix and a ruined afternoon.

Learning objectives

After working through this lesson you will be able to:

Prerequisites

You need very little: a terminal, administrative rights to install a program, and — for the parts that touch a cloud — a free-tier account or the entirely local providers we lean on first. No prior Terraform is assumed; if you have read Terraform Fundamentals: HCL, Providers, State & the Core Workflow you will already recognise the ideas of providers, state and the plan/apply model, and this lesson goes a level deeper on the tool that drives them. This is a Fundamentals lesson in the Terraform Zero-to-Hero ladder: the command muscle-memory you build here underpins every later lesson — HCL syntax, modules, state at scale, Terragrunt and CI pipelines all assume you can drive the CLI fluently.

Core concepts: what the CLI actually is

Before the commands, fix four mental models. They explain why the tool behaves the way it does and stop most beginner confusion at the source.

The CLI is a thin driver around three moving parts. When you run a command, Terraform loads your configuration (the .tf files in the working directory), reads your state (its private record of what it has already created), and uses providers (downloaded plugins that know how to call AWS, Azure, GCP, Kubernetes, and ~4,000 other APIs). Almost every subcommand is some combination of read config, read/refresh state, compute a difference, and act on the world. Once you see a command as “which of those four does it do?”, the whole surface becomes predictable.

A single binary, but version matters enormously. Terraform ships as one self-contained executable — no runtime to install, no dependencies. But the version of that binary is load-bearing: state files record the version that last wrote them and Terraform refuses to let an older binary operate on state written by a newer one, and HCL gains features over releases. That is why teams pin a version (via required_version in config and a version manager on each machine) — so everyone’s terraform is the same terraform. This is the single most common cause of “works on my laptop, breaks in CI”.

The working directory is the unit of operation. Terraform operates on the directory you are in (or the one named by -chdir). All .tf files in that directory are merged into one configuration; one state lives behind it; one .terraform/ cache sits inside it. There is no project file listing which files to include — the directory is the project. This is why “where am I running this?” is always the first debugging question.

Plan and apply are separate on purpose. Terraform never changes anything during plan; it only computes and shows the change. apply is what acts. This split — propose, review, then execute — is the safety property that made Terraform trustworthy in production, and it is why you can capture a plan to a file and apply exactly that, with no surprises between review and execution.

Key terms used throughout: configuration (your .tf files), state (Terraform’s record of reality, in terraform.tfstate or a remote backend), backend (where state is stored and locked), provider (a plugin for one API), resource address (how you name one object, e.g. aws_instance.web or module.net.aws_subnet.private[0]), and plan (the computed diff between desired and actual).

Installing the Terraform CLI

There is no single “installer” you must use — pick the route that fits your platform and how disciplined you need version control to be. The table below covers the mainstream options for Terraform; the OpenTofu equivalents follow.

Platform / method Command Notes
macOS — Homebrew brew tap hashicorp/tap && brew install hashicorp/tap/terraform The official tap; brew upgrade to update. Most common on Macs.
Windows — winget winget install Hashicorp.Terraform Built into modern Windows.
Windows — Chocolatey choco install terraform Popular community package manager.
Windows — Scoop scoop install terraform User-scoped, no admin needed.
Linux — Debian/Ubuntu (apt) Add the HashiCorp apt repo, then sudo apt-get install terraform Pin to the HashiCorp GPG-signed repo for updates.
Linux — RHEL/Fedora (dnf/yum) Add the HashiCorp repo, then sudo dnf install terraform Same idea on the RPM side.
Any — manual download Download the zip for your OS/arch from releases.hashicorp.com, unzip, put terraform on your PATH The most portable; exactly the binary you choose, no repo.
Any — version manager tfenv or tfswitch (see below) Best for teams — pins and switches versions per project.

After any method, verify:

terraform version
# Terraform v1.9.x
# on darwin_arm64

terraform version is also the command that warns you when a newer release is out and (after init) lists provider versions — more on it in the reference.

Tab completion is worth ten seconds: terraform -install-autocomplete adds completion to your shell profile (bash/zsh), so subcommands and flags tab-complete thereafter. terraform -uninstall-autocomplete removes it.

Version management with tfenv and tfswitch

A single globally-installed Terraform is fine for one person on one project. The moment you have several repos pinned to different versions — common in any real organisation — you need a version manager that installs many Terraform versions side by side and selects the right one per directory. Two dominate.

Tool What it does Pin file it reads Typical commands
tfenv Installs and switches Terraform versions; auto-selects per directory .terraform-version (a file containing e.g. 1.9.5) tfenv install 1.9.5, tfenv use 1.9.5, tfenv list, tfenv install latest, tfenv use latest
tfswitch Interactive/CLI version switcher; can read constraints from config .tfswitchrc, or the required_version in your .tf tfswitch (interactive menu), tfswitch 1.9.5, tfswitch -b ~/bin/terraform

The pattern that makes a team reproducible: commit a .terraform-version file (for tfenv) at the repo root containing the exact version, and declare required_version in a terraform {} block in your config. Now anyone who cds into the repo with tfenv installed gets the right binary automatically, and Terraform itself errors if someone runs the wrong one. tfenv with tfenv install reading a .terraform-version of latest:^1.9 will resolve to the newest 1.9.x.

# tfenv: install and pin a project to an exact version
tfenv install 1.9.5
echo "1.9.5" > .terraform-version   # committed to the repo
tfenv use 1.9.5
terraform version                    # confirms 1.9.5

Installing OpenTofu (the open fork)

OpenTofu is the MPL-licensed community fork of Terraform under the Linux Foundation. It is a near-drop-in replacement: same HCL, same state format lineage, the same subcommands and flags described in this entire lesson — you type tofu instead of terraform. Install it the same ways:

Method Command
macOS — Homebrew brew install opentofu
Windows — winget / Chocolatey / Scoop winget install OpenTofu.Tofu · choco install opentofu · scoop install opentofu
Linux OpenTofu’s official apt/dnf repos, or the standalone installer script
Version manager tofuenv (the tfenv equivalent), or tenv (a unified manager for both Terraform and OpenTofu)

Verify with tofu version. Everywhere below, mentally substitute tofu for terraform if that is your tool; I will flag the rare places they differ.

The core loop: init → plan → apply → destroy, in detail

These four commands are the heartbeat of Terraform. The trick to mastering them is to stop thinking “they do Terraform things” and start thinking precisely about what each one reads, what it writes to state, and what it changes in the real world. Here is that breakdown.

terraform init — prepare the working directory. This is always the first command in a new or freshly-cloned directory. It does not touch your infrastructure at all. It (1) reads your configuration to discover which providers and modules you need, (2) downloads those providers from the registry into the local .terraform/ directory, (3) downloads/gets modules, (4) configures the backend (where state lives — local file or remote), and (5) writes or verifies the dependency lock file .terraform.lock.hcl. Reads: config. Writes: .terraform/, the lock file, backend setup; not state contents. Real world: nothing. You re-run init whenever you add a provider/module or change the backend.

terraform plan — compute the difference, change nothing. Plan (1) reads your configuration, (2) refreshes state by querying the real provider APIs to see the current state of each tracked resource (unless told not to), (3) compares desired (config) against actual (refreshed state), and (4) prints a diff: resources to create (+), update in place (~), destroy (-), or replace (-/+). Reads: config + state + live APIs. Writes: nothing permanent (a refresh may update the in-memory state; with -refresh-only it can write refreshed values back). Real world: read-only. Plan is your safety net — always read it before applying.

terraform apply — make reality match the configuration. Apply (1) produces a plan (or loads a saved one), (2) — interactively — shows it and asks you to type yes, then (3) calls the provider APIs to create/update/destroy resources in dependency order, and (4) writes the new state recording exactly what now exists. Reads: config + state (or a saved plan). Writes: state — this is the command that mutates your state file. Real world: this is where things are actually created, changed and destroyed. Acquire a state lock for the duration so no one else applies at the same time.

terraform destroy — remove everything this configuration manages. Destroy is apply’s mirror image: it plans the deletion of every resource in state for this configuration and, after you confirm, deletes them via the APIs and empties them out of state. Reads: config + state. Writes: state (removing entries as resources are deleted). Real world: tears down infrastructure — irreversible, so it asks for confirmation. It is equivalent to terraform apply -destroy.

The table summarises the loop:

Command Reads Writes to state? Changes real infra? Asks to confirm? When you run it
init config No (sets up backend) No No First time, and after adding providers/modules or changing backend
validate config No No No Anytime, to check syntax/types (no creds needed)
plan config + state + live APIs No (refresh is in-memory) No (read-only) No Before every apply, to preview
apply config + state (or saved plan) Yes Yes Yes (unless -auto-approve/saved plan) To make the change
destroy config + state Yes (empties) Yes (deletes) Yes (unless -auto-approve) To tear it all down

A typical first session, end to end:

terraform init      # download providers, set up backend, write lock file
terraform fmt       # tidy your .tf files (optional but good habit)
terraform validate  # catch syntax/type errors early (no cloud calls)
terraform plan      # read the proposed changes
terraform apply     # type 'yes' to create it for real
# ... use your infrastructure ...
terraform destroy   # type 'yes' to remove it and stop paying

The complete command reference

Below is the full subcommand surface. Run terraform -help for the list and terraform <cmd> -help for any command’s flags — but this section is the curated, exhaustive reference with the flags that matter and what they do.

Lifecycle commands

These are the commands you run constantly.

terraform init

Prepares the directory. Important flags:

Flag What it does
-upgrade Re-checks the registry and upgrades providers/modules to the newest versions allowed by your constraints, then updates .terraform.lock.hcl. Without it, init honours the existing lock.
-backend=false Skip backend initialisation (e.g. when you only want to install providers/modules).
-backend-config=KEY=VALUE / -backend-config=FILE Supply backend settings (bucket, key, region, etc.) on the command line or from a *.tfbackend file instead of hard-coding them in the backend block — the partial configuration pattern, ideal for keeping secrets and per-env values out of code.
-reconfigure Reinitialise the backend from scratch, ignoring any saved backend state and without offering to migrate existing state. Use when you are pointing at a deliberately different/empty backend.
-migrate-state Reinitialise and copy existing state to the newly-configured backend (e.g. local → S3, or one key → another). Terraform prompts before moving state.
-get=false Do not download modules.
-input=false Never prompt interactively (fail instead) — for automation.
-lock=false / -lock-timeout=DURATION Disable state locking, or wait up to a duration (e.g. 60s) to acquire the lock.
-json Machine-readable output.
-no-color Strip ANSI colour (useful in logs/CI).

The crucial distinction beginners trip on: -reconfigure discards backend state and does not migrate; -migrate-state moves your state to the new backend. Use -migrate-state when relocating state you want to keep; -reconfigure when you intend to start clean against a different backend.

terraform validate

Checks that the configuration is syntactically valid and internally consistent (correct types, references resolve, required arguments present). It needs no credentials and no backend — it does not touch state or any API — so it is the ideal first gate in CI. Flags: -json (structured results), -no-color. Run init first so providers are available for schema validation.

terraform fmt

Rewrites .tf and .tfvars files to the canonical HashiCorp style (consistent indentation, alignment, spacing). Flags:

Flag What it does
(no flags) Format files in the current directory in place.
-recursive Also format files in subdirectories.
-check Do not write; exit non-zero if any file would change — the CI gate to enforce formatting.
-diff Show the diffs of what would change.
-list=false Don’t list the files that were formatted.
-write=false Don’t write changes (combine with -diff/-check).

A common CI step is terraform fmt -check -recursive to fail the build on unformatted code.

terraform plan

Computes and shows the diff. The most flag-rich command you use daily:

Flag What it does
-out=FILE Save the plan to a file (e.g. plan.tfplan). You then apply that exact file — the gold-standard for reviewable, no-surprise changes in pipelines.
-destroy Plan the destruction of everything (same as what destroy previews).
-refresh-only Plan only the state refresh — show drift between state and reality without proposing config-driven changes. Apply it to write refreshed values back to state.
-refresh=false Skip querying live APIs; plan against state as-is (faster, but may miss drift).
-target=ADDRESS Restrict the plan to a specific resource/module and its dependencies. Repeatable. An escape hatch for recovery — not for routine use (it leads to partial, drifting state).
-replace=ADDRESS Force this resource to be destroyed and recreated even though config didn’t change — the modern replacement for taint. Repeatable.
-var 'NAME=VALUE' Set one input variable on the command line. Repeatable.
-var-file=FILE Load variables from a .tfvars/.tfvars.json file. Repeatable; later files win.
-input=false Don’t prompt for missing variables (fail instead) — automation.
-lock=false / -lock-timeout=DURATION Disable or wait for the state lock.
-parallelism=N Number of concurrent resource operations (default 10). Lower it to be gentle on rate-limited APIs.
-compact-warnings Summarise warnings instead of printing them in full.
-detailed-exitcode Exit 0 = no changes, 1 = error, 2 = changes present. Lets scripts detect drift.
-json / -no-color Machine-readable / colourless output.

terraform apply

Applies a plan. It accepts all the planning flags above (because, without a saved plan, it plans first), plus:

Flag What it does
APPLY a saved plan terraform apply plan.tfplan — apply exactly the saved plan; Terraform does not re-plan or prompt, so review happened at plan time.
-auto-approve Skip the interactive yes prompt. Safe in CI only when applying a reviewed saved plan or when you accept the risk; dangerous interactively.
-target / -replace / -var / -var-file / -refresh=false / -parallelism / -lock* As for plan (ignored if you pass a saved plan, which already baked them in).
-input=false No interactive prompts.
-json / -no-color / -compact-warnings Output controls.

The professional pattern is terraform plan -out=tfplan → human/PR review → terraform apply tfplan. Passing a saved plan to apply is the only way to guarantee what you reviewed is what runs.

terraform destroy

Convenience alias for terraform apply -destroy. Accepts the same flags as apply, including -target (destroy a subset — careful), -auto-approve, -var/-var-file, -lock*, -parallelism. It will list everything to be deleted and ask you to type yes.

Inspection & utility commands

These read information; most change nothing.

terraform show

Renders state or a saved plan in human-readable or JSON form. terraform show prints the current state; terraform show plan.tfplan prints a saved plan. Flags: -json (machine-readable — the canonical way to feed plan/state into policy tools like OPA/Conftest or scripts), -no-color.

terraform output

Prints the output values from the root module’s state — handy for feeding values into scripts or other tools.

Flag What it does
(no args) Print all outputs in HCL-ish form.
NAME Print just the named output.
-json Print outputs as JSON (preserves types; the way to consume them programmatically).
-raw NAME Print a single output as a raw string with no quotes/formatting — ideal for $(terraform output -raw db_endpoint) in shell. Only works for string-convertible values.
-no-color Colourless.

terraform console

Opens an interactive expression console bound to your config and state. Type any HCL expression — function calls, references to resources/outputs/variables — and see the result. Indispensable for learning functions and debugging expressions without an apply.

echo 'max(5, 12, 9)' | terraform console        # 12
echo 'cidrsubnet("10.0.0.0/16", 8, 2)' | terraform console   # 10.0.2.0/24

Flags: -var/-var-file (so variable references resolve), -state (point at a specific state file). It is read-only — it never changes state or infrastructure.

terraform graph

Emits the dependency graph in DOT format, which you can render with Graphviz to see the order Terraform will operate in. terraform graph | dot -Tsvg > graph.svg. Flags: -type=plan|apply|... (which graph), -draw-cycles (highlight dependency cycles — useful when Terraform complains about a cycle).

terraform providers

Inspects provider requirements and usage. Subcommands:

Command What it does
terraform providers List the providers required by the configuration and where each requirement comes from.
terraform providers schema -json Dump the full schema (every resource, data source and its attributes) of the configured providers as JSON — great for tooling and discovering attribute names.
terraform providers lock Pre-populate or update .terraform.lock.hcl with hashes for multiple platforms (e.g. -platform=linux_amd64 -platform=darwin_arm64) so the lock works for everyone on the team regardless of OS/arch.
terraform providers mirror DIR Copy the providers needed by the config into a local directory to serve from an offline/internal mirror.

State management commands

The terraform state subcommands manipulate Terraform’s record of reality. They are powerful and occasionally dangerous — they change what Terraform thinks exists without (mostly) changing the cloud. Always have a backup; terraform state pull first is a good habit. (A deeper treatment lives in the dedicated state lesson; here is the command surface.)

Command What it does Touches real infra?
terraform state list List the resource addresses tracked in state (optionally filtered by address/-id). No
terraform state show ADDRESS Print all attributes of one resource as recorded in state. No
terraform state mv SRC DST Rename/move an item in state — e.g. after refactoring aws_instance.aaws_instance.b, or moving a resource into a module — so Terraform tracks it under the new address instead of destroying and recreating it. (Prefer moved blocks in config for the same effect, reviewably.) No
terraform state rm ADDRESS Forget a resource — remove it from state so Terraform stops managing it. The real object is left running. Used to hand a resource to another config/module, or before re-importing it. No (object survives)
terraform state pull Fetch and print the raw state (JSON) from the backend — for backups or inspection. No
terraform state push FILE Overwrite the backend state with a local file. Last-resort surgery; mismatched lineage/serial can corrupt — push only when you know exactly why. No
terraform state replace-provider OLD NEW Rewrite the provider source for resources in state (e.g. registry.terraform.io/-/awsregistry.terraform.io/hashicorp/aws), used during provider migrations including Terraform→OpenTofu. No

A worked example of the safe refactor pattern:

terraform state list                              # find the current address
terraform state mv aws_instance.web aws_instance.app   # rename without recreate
terraform plan                                    # should now show no changes

Resource-targeting & import commands

terraform taint and terraform untaint (deprecated)

terraform taint ADDRESS marks a resource as degraded so the next apply replaces it; terraform untaint ADDRESS reverses that. Both are deprecated. The modern, plan-visible way to force a replacement is the -replace flag, which shows the replacement in the plan before you apply it rather than mutating state out of band:

# Old way (deprecated):
terraform taint aws_instance.web

# Modern way — visible in the plan, no hidden state change:
terraform plan  -replace="aws_instance.web"
terraform apply -replace="aws_instance.web"

Prefer -replace everywhere; taint/untaint remain only for backward compatibility.

terraform import

Brings an existing real resource (one created outside Terraform, or by another stack) under management by writing it into state. You must already have a matching resource block in your config; import then binds the real object’s ID to that address.

terraform import aws_instance.web i-0abcd1234ef567890
Flag What it does
ADDRESS ID The resource address in your config and the provider-specific object ID.
-var / -var-file Provide variables the config needs to evaluate.
-input=false No prompts.
-lock / -lock-timeout State-lock controls.

Import only writes state — it does not generate the HCL for you (though modern Terraform also supports declarative import {} blocks in config, which can generate configuration with -generate-config-out=FILE; that is covered in the refactoring lesson). After import, run plan and reconcile your HCL until it shows no changes.

terraform refresh (deprecated alias)

Updates state to match the real-world status of resources without changing config-driven settings. It is deprecated as a standalone command in favour of terraform apply -refresh-only (and plan -refresh-only to preview the drift first), which make the state write explicit and reviewable. Behaviour is otherwise the same: query the APIs, record current values into state.

Workspace commands

terraform workspace manages CLI workspaces — multiple, named, separate state instances behind the same configuration directory (covered in depth in its own section below).

Command What it does
terraform workspace list List workspaces; a * marks the current one.
terraform workspace show Print the current workspace name.
terraform workspace new NAME Create and switch to a new workspace (a fresh, empty state).
terraform workspace select NAME Switch to an existing workspace.
terraform workspace delete NAME Delete a workspace (must be empty / not current).

The active workspace is exposed in config as terraform.workspace, so you can vary names/sizes by environment.

Authentication & maintenance commands

terraform login / terraform logout

terraform login [HOST] obtains and stores an API token for HCP Terraform (formerly Terraform Cloud) or a private registry/host, saving it to the CLI config credentials file. terraform logout [HOST] removes it. You need these when using a remote backend/registry hosted on app.terraform.io or an enterprise host. (OpenTofu has equivalent tofu login/logout for its registries/hosts.)

terraform force-unlock

If an apply crashes or a network blip leaves the state lock stuck, no one else can run against that state. terraform force-unlock LOCK_ID releases it. Get the LOCK_ID from the error message Terraform printed when it failed to acquire the lock.

terraform force-unlock 1a2b3c4d-5e6f-7890-abcd-ef1234567890
Flag What it does
LOCK_ID The lock identifier to release (from the lock error).
-force Skip the confirmation prompt.

Use with care: only force-unlock when you are certain no apply is actually running, otherwise two writers can corrupt state.

terraform version

Prints the CLI version, the platform, the versions of installed providers (after init), and whether a newer Terraform release is available. Flag: -json for machine-readable output. The first command to run when filing a bug or debugging version-skew.

Other utility commands

Command What it does
terraform get Download/update the modules referenced by the config (a subset of what init does). -update refreshes to newest allowed.
terraform test Run Terraform’s native test framework (*.tftest.hcl files) — spins up real or mocked resources to assert behaviour.
terraform metadata functions -json Dump the signatures of all built-in functions as JSON (tooling/IDE support).
terraform -install-autocomplete / -uninstall-autocomplete Add/remove shell tab-completion.
terraform fmt / validate / console / graph (Covered above.)

Global flags and environment variables

Some options apply to every command, set either as a flag or via an environment variable. These are the levers you reach for in CI, debugging and scripting.

Flag / variable What it does
-chdir=DIR Run as if Terraform had been started in DIR (placed before the subcommand: terraform -chdir=envs/prod plan). The clean way to operate on another directory without cd.
-help / -version Show help / print the version (top-level conveniences).
-no-color Strip ANSI colour from output — set this in CI logs so they stay readable.
-json (Per-command) machine-readable output, supported by init, validate, plan, apply, show, output, providers schema, version.

The environment variables you will actually use:

Variable What it does
TF_LOG Enable internal logging at a level: TRACE (most verbose), DEBUG, INFO, WARN, ERROR. TF_LOG=DEBUG terraform apply is the first move when something behaves mysteriously.
TF_LOG_PATH Write the logs to a file instead of stderr (e.g. TF_LOG_PATH=./tf.log). Often paired with TF_LOG.
TF_LOG_CORE / TF_LOG_PROVIDER Set log level separately for Terraform’s core vs the provider plugins, to isolate where a problem lives.
TF_VAR_name Set input variable name from the environment (e.g. export TF_VAR_region=ap-south-1). The standard way to pass secrets/values to automation without -var on the command line.
TF_CLI_ARGS Append default arguments to every Terraform command (e.g. TF_CLI_ARGS="-no-color").
TF_CLI_ARGS_name Append default arguments to one command only (e.g. TF_CLI_ARGS_plan="-refresh=false" adds that flag to every plan).
TF_DATA_DIR Override the .terraform directory location (default .terraform) — useful in CI to relocate the cache.
TF_INPUT Set to 0/false to disable all interactive prompts globally (equivalent to -input=false everywhere).
TF_WORKSPACE Select the workspace via the environment instead of workspace select — handy in pipelines.
TF_IN_AUTOMATION Any non-empty value tweaks output to be friendlier for CI (e.g. omits “run terraform plan next” hints).
TF_PLUGIN_CACHE_DIR A shared directory where providers are cached across projects, so init doesn’t re-download the same provider for every repo — a big speed-up on CI and laptops.

OpenTofu honours the same variable names with a TOFU_-prefixed alias in some cases, but the TF_-prefixed forms generally work for both; check the OpenTofu docs for the rare differences.

The .terraform/ directory and the lock file

After terraform init, two important things appear in your working directory. Knowing what they are — and which to commit to Git — prevents a whole class of confusion.

.terraform/ is a local cache and working area, not something you commit. Inside it you’ll find the downloaded provider plugins (under .terraform/providers/...), downloaded modules (.terraform/modules/), and a record of the backend configuration Terraform initialised (.terraform/terraform.tfstate is backend metadata, not your real state, when using a remote backend). It is machine- and platform-specific and can always be regenerated by re-running init, so it belongs in .gitignore.

.terraform.lock.hcl — the dependency lock file — is the opposite: you must commit it. It pins the exact provider versions and cryptographic hashes that init selected, so every teammate and every CI run uses byte-for-byte the same provider binaries. Its anatomy:

provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.60.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abc123...",          # the trusted hash(es)
    "zh:def456...",
  ]
}

Key facts about the lock file, in a table:

Aspect Detail
Purpose Pin exact provider versions + hashes for reproducibility and supply-chain integrity.
Created/updated by terraform init (creates/respects it); terraform init -upgrade (bumps within constraints and rewrites it); terraform providers lock (adds hashes for more platforms).
Commit it? Yes — to version control, always. It is part of your reproducible build.
version vs constraints constraints is your stated range (from required_providers); version is the single version Terraform actually selected within it.
hashes Checksums Terraform verifies on download; mismatches fail init (tamper/corruption protection).
Multi-platform gotcha Hashes are platform-specific. If you lock on a Mac and CI runs Linux, run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 ... so the lock covers both, or CI’s init will error on a missing hash.

A sensible .gitignore for a Terraform project:

# Local cache — regenerated by `terraform init`
.terraform/

# Local state and backups — never commit (secrets in plaintext)
*.tfstate
*.tfstate.*

# Crash logs and local var files that may hold secrets
crash.log
*.tfvars        # commit shared, non-secret tfvars deliberately if you want
override.tf
override.tf.json

# DO commit: .terraform.lock.hcl  (do NOT ignore it)

Workspaces: CLI workspaces vs separate directories

A workspace in the CLI sense is a named, separate instance of state sitting behind the same configuration. Switch workspace and you switch which state file you’re operating on, while the .tf code stays identical. The default workspace is called default.

terraform workspace new staging      # creates + switches to 'staging'
terraform workspace new prod
terraform workspace list             # default, staging, * prod
terraform apply                      # applies into the 'prod' state
terraform workspace select staging   # now operate on 'staging' state

You can read the current workspace in config and use it to vary names or sizes:

resource "aws_instance" "web" {
  tags = { Name = "web-${terraform.workspace}" }
  instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}

So when do you use CLI workspaces, and when do you use separate directories (a prod/ folder and a staging/ folder, each with its own backend/state)? This is a real architectural decision, and the honest answer is most production setups prefer separate directories. The trade-offs:

Dimension CLI workspaces Separate directories
Code duplication None — one set of .tf files Some — code/config per environment (mitigated by modules/Terragrunt)
State isolation Same backend, different state keys Fully separate backends/keys — strongest isolation
Blast radius / safety One wrong select and you apply to prod — easy to mistake the active workspace Hard to confuse — prod is literally a different directory
Per-env differences Awkward — must encode every difference via terraform.workspace conditionals Natural — each dir can differ freely (different backends, providers, versions)
Backend/provider config per env Shared (can’t easily differ per workspace) Independent per directory
Best for Short-lived, near-identical parallel copies (feature/ephemeral test environments, per-developer sandboxes) Long-lived prod/staging/dev with meaningful differences (the common case)

The rule of thumb: use CLI workspaces for ephemeral, identical, throwaway copies (a quick test environment, a per-PR preview, a developer’s personal sandbox) and use separate directories for durable environments where prod and staging genuinely differ and where the safety of “you cannot accidentally point at prod” is worth a little duplication. Note that HCP Terraform / Terraform Cloud “workspaces” are a different, richer concept (each is effectively its own managed root with its own variables and runs) — don’t conflate them with these CLI workspaces.

Architecture at a glance

The diagram below traces a single command through the CLI: how terraform loads your configuration, consults the dependency lock file, pulls providers and modules into .terraform/, reads and locks state through the backend, refreshes against the live cloud APIs to compute a plan, and — on apply — calls those APIs and writes the new state back.

How the Terraform CLI drives a run: configuration plus the dependency lock file feed init which populates the .terraform directory with providers and modules; plan reads state from the backend and refreshes against cloud APIs to compute a diff; apply executes the diff against the APIs and writes new state back under a lock

Keep this picture in mind: every subcommand is just a different path through these same boxes — config, lock file, .terraform/, state/backend, and the provider APIs.

Hands-on lab

This lab uses only local providers — no cloud account, no credentials, no cost — so you can exercise the entire CLI surface safely. You’ll install (or confirm) the CLI, scaffold a tiny configuration, and run init, fmt, validate, plan, apply, show, output, state, console, a forced replace, workspaces and destroy.

Step 0 — Confirm the CLI is installed.

terraform version    # or: tofu version

If it’s missing, install it via the table above (e.g. brew install hashicorp/tap/terraform).

Step 1 — Create a working directory and a minimal config. We use the random and local providers — both run entirely on your machine.

mkdir tf-cli-lab && cd tf-cli-lab
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.5"
  required_providers {
    random = { source = "hashicorp/random", version = "~> 3.6" }
    local  = { source = "hashicorp/local",  version = "~> 2.5" }
  }
}

variable "name" {
  type    = string
  default = "lab"
}

resource "random_pet" "this" {
  length = 2
}

resource "local_file" "greeting" {
  filename = "${path.module}/hello.txt"
  content  = "Hello from ${var.name} — id ${random_pet.this.id}\n"
}

output "pet_name" { value = random_pet.this.id }
output "file_path" { value = local_file.greeting.filename }
EOF

Step 2 — Initialise. Watch it download the two providers and write the lock file.

terraform init
ls -a            # note: .terraform/  .terraform.lock.hcl  main.tf
cat .terraform.lock.hcl   # see the pinned versions + hashes

Expected: “Terraform has been successfully initialized!” and a .terraform/ directory plus a .terraform.lock.hcl containing both providers.

Step 3 — Format and validate (no cloud, no state changes).

terraform fmt -diff      # tidies main.tf, shows the diff
terraform validate       # "Success! The configuration is valid."

Step 4 — Plan, saving the plan to a file.

terraform plan -out=tfplan

Expected: a plan showing 2 to add, 0 to change, 0 to destroy (the random_pet and the local_file).

Step 5 — Apply exactly the saved plan (no re-prompt).

terraform apply tfplan
cat hello.txt            # the file your config created

Expected: “Apply complete! Resources: 2 added” and a hello.txt containing a random pet name.

Step 6 — Inspect state and outputs.

terraform show           # full human-readable state
terraform state list     # random_pet.this  local_file.greeting
terraform output                       # all outputs
terraform output -raw pet_name         # just the pet id, unquoted

Step 7 — Use the console to evaluate expressions.

echo 'upper(random_pet.this.id)' | terraform console
echo 'length(var.name)' | terraform console

Expected: the uppercased pet name, then 3.

Step 8 — Force a replacement (modern, plan-visible).

terraform plan  -replace="random_pet.this"   # shows -/+ replace
terraform apply -replace="random_pet.this" -auto-approve
cat hello.txt                                 # new pet name, file rewritten

Step 9 — Try a workspace.

terraform workspace new sandbox     # fresh, empty state
terraform workspace list            # default  * sandbox
terraform apply -auto-approve       # creates a SECOND, separate set in 'sandbox' state
terraform workspace select default  # back to the original state

Step 10 — Validation checklist. You have: installed/verified the CLI, initialised a directory (seen .terraform/ + lock file), formatted and validated, produced and applied a saved plan, inspected state and outputs, evaluated expressions in the console, forced a replacement with -replace, and created a second workspace with its own state.

Cleanup. Destroy each workspace’s resources, then remove the directory.

terraform workspace select sandbox
terraform destroy -auto-approve
terraform workspace select default
terraform destroy -auto-approve
terraform workspace delete sandbox
cd .. && rm -rf tf-cli-lab

Cost note. Zero. The random and local providers create nothing in any cloud — everything happens on your machine, so there are no charges to worry about and nothing to leak.

Common mistakes & troubleshooting

Symptom Likely cause Fix
Error: Inconsistent dependency lock file / missing provider hash Lock file was generated on a different OS/arch than the runner (e.g. Mac → Linux CI) terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 <provider> and commit, or terraform init -upgrade
Error acquiring the state lock that never clears A previous apply crashed and left the lock held Confirm nothing is running, then terraform force-unlock <LOCK_ID> (ID is in the error)
Providers re-download on every CI run (slow) No shared plugin cache Set TF_PLUGIN_CACHE_DIR to a persisted directory
terraform plan shows changes you didn’t make Drift — something changed the resource outside Terraform terraform plan -refresh-only to see it; reconcile config or apply -refresh-only
Ran the wrong environment Wrong workspace selected, or wrong directory terraform workspace show / check your path before applying; prefer separate dirs for prod
state mv/rm then plan wants to recreate everything Moved/removed the wrong address, or address typo Restore from terraform state pull backup; use exact addresses from state list
Error: Backend initialization required Backend config changed since last init Re-run terraform init (add -reconfigure or -migrate-state as appropriate)
terraform: command not found after install Binary not on PATH, or shell not reloaded Add the install dir to PATH; open a new shell; which terraform to confirm
Version error: “state was created by a newer version” An older CLI is operating on state written by a newer one Install the matching/newer version (use tfenv); honour required_version

Best practices

Security notes

Interview & exam questions

1. What does terraform init do, and does it change any infrastructure? It prepares the working directory: downloads providers and modules into .terraform/, configures the backend, and writes/verifies .terraform.lock.hcl. It does not touch infrastructure or state contents — it’s safe to run anytime, and you must run it after cloning or after adding a provider/module/backend change.

2. Explain the difference between terraform plan and terraform apply. plan reads config, refreshes state against live APIs, and shows the diff — it changes nothing. apply executes that diff: it creates/updates/destroys resources via the APIs and writes the new state. The deliberate separation lets you review before acting; you can plan -out=file then apply file to run exactly what you reviewed.

3. How do you force one resource to be recreated, and why is taint discouraged? Use terraform apply -replace="ADDRESS". It’s preferred over the deprecated terraform taint because the replacement appears in the plan output before you apply, rather than mutating state out of band where it’s easy to forget.

4. -reconfigure vs -migrate-state on init — what’s the difference? Both reinitialise the backend. -migrate-state copies your existing state to the newly-configured backend (use when relocating state you want to keep). -reconfigure ignores saved backend state and does not migrate — use when intentionally pointing at a different/empty backend.

5. What is .terraform.lock.hcl, and do you commit it? It’s the dependency lock file, pinning the exact provider versions and cryptographic hashes init selected. Yes, commit it — it makes builds reproducible and protects against tampered/corrupt provider downloads. Update it deliberately with terraform init -upgrade.

6. What’s in the .terraform/ directory and should it be in Git? It’s a local cache: downloaded provider plugins, fetched modules, and backend metadata. No — git-ignore it. It’s machine-specific and fully regenerated by terraform init.

7. When would you use terraform state mv and terraform state rm? state mv renames/moves an item in state (e.g. after refactoring a resource’s address or moving it into a module) so Terraform tracks it under the new address without destroy/recreate. state rm makes Terraform forget a resource (the real object keeps running) — to hand it to another config or before re-importing. Prefer moved/import config blocks where possible.

8. How do you read an output value into a shell script? terraform output -raw NAME prints a single value with no quotes/formatting, e.g. EP=$(terraform output -raw db_endpoint). For structured data use terraform output -json and parse it.

9. A teammate’s apply crashed and now everyone gets “Error acquiring the state lock”. What do you do? Confirm no apply is actually running anywhere, then terraform force-unlock <LOCK_ID> using the ID from the error message. Be careful — force-unlocking a genuinely active lock can corrupt state.

10. CLI workspaces vs separate directories — when do you choose each? CLI workspaces give multiple separate states behind one config — good for ephemeral, near-identical copies (PR previews, dev sandboxes). Separate directories give full isolation (independent backends, providers, versions) and you can’t accidentally apply to prod — preferred for durable, differing environments like prod/staging.

11. How do you make a plan/apply non-interactive and safe in CI? Produce a saved plan (plan -out=tfplan -input=false), review it (often via a terraform show -json policy check), then apply tfplan (no re-prompt). Use -input=false to prevent hangs, -no-color for clean logs, and apply the saved plan rather than -auto-approve on a fresh plan.

12. How do you pin a Terraform version per project across a team? Declare required_version in a terraform {} block (Terraform errors on a mismatch) and commit a .terraform-version file read by tfenv so each machine auto-selects the right binary. This eliminates version-skew between laptops and CI.

13. What does terraform providers lock -platform=... solve? Provider hashes in the lock file are platform-specific. If you lock on macOS but CI runs Linux, init fails on a missing hash. terraform providers lock adds hashes for all the platforms you name, so one committed lock file works for the whole team.

14. Name three things TF_LOG and friends do. TF_LOG sets internal log verbosity (TRACE/DEBUG/INFO/WARN/ERROR) — the first debugging move; TF_LOG_PATH writes those logs to a file; TF_LOG_CORE/TF_LOG_PROVIDER split the level between Terraform’s core and the provider plugins to isolate the source of a problem.

Quick check

  1. You’ve just cloned a Terraform repo. Which command must you run first, and why — and does it change any cloud resources?
  2. You need to read one output as a plain unquoted string into a bash variable. Which command and flag?
  3. True or false: you should commit .terraform/ to Git but ignore .terraform.lock.hcl.
  4. A resource must be recreated even though the config hasn’t changed. What’s the modern, plan-visible way to do it, and which older command does it replace?
  5. You want one set of .tf files but a separate, throwaway state for a per-PR preview environment. CLI workspace or separate directory — and what’s the safety risk of the option you didn’t choose?

Answers

  1. terraform init. It downloads providers/modules, configures the backend, and writes/checks the lock file. It does not change cloud resources — it only prepares the working directory.
  2. terraform output -raw NAME (e.g. EP=$(terraform output -raw db_endpoint)). The -raw flag strips quotes/formatting for a single string-convertible value.
  3. False — it’s the reverse. Commit .terraform.lock.hcl (reproducibility + integrity) and git-ignore .terraform/ (a regenerable local cache).
  4. Use terraform apply -replace="ADDRESS" (preview with terraform plan -replace=...). It replaces the deprecated terraform taint, and is better because the replacement shows up in the plan before you apply.
  5. CLI workspace — ideal for ephemeral, near-identical copies. The option you didn’t choose (separate directories) is safer against applying to prod by accident; the workspace risk is exactly that — one wrong terraform workspace select and you apply to the wrong state, so always check terraform workspace show.

Exercise

In a fresh directory, write a config using the random and local providers (as in the lab). Then, without applying anything to a cloud: (a) pin the project with a .terraform-version file and a required_version constraint, and confirm terraform version matches; (b) run terraform init and open .terraform.lock.hcl — identify the version, constraints and hashes fields for each provider; © produce a saved plan with terraform plan -out=tfplan, inspect it with terraform show -json tfplan | head, then apply it with terraform apply tfplan; (d) rename one resource in your .tf and use terraform state mv so plan shows no changes afterwards; (e) create a staging workspace, apply into it, and prove with terraform state list that it has its own separate state from default; (f) clean up by destroying both workspaces and deleting the directory. Bonus: add terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 and observe the lock file gain hashes for both platforms.

Certification mapping

Glossary

Next steps

You can now install the CLI, pin its version, drive the full lifecycle, and reach for the right subcommand and flag in the moments that matter. The natural next move is to go deep on the language those commands operate on — the syntax of every block, expression and type in HCL:

TerraformCLIOpenTofuWorkspacesStateIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments