TerraformII · Terraform ArchitectureProduction Terraform
The CLI and Core: What Each Does
What you'll learn
- Distinguish the terraform CLI binary from the embedded Core engine
- Identify which CLI subcommands are read-only versus state-mutating
- Apply CLI exit codes correctly in CI/CD pipelines
- Use -json output for machine-readable plan and apply integration
- Recognise CLI-only flags versus provider-configuration-only settings
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
The terraform command is one binary. In production conversations
it is treated as two things: the CLI (the front-end that parses
arguments, prints output, and interacts with you) and Core (the
engine that parses HCL, builds the graph, and computes the plan).
The boundary matters because most production surprises come from
confusing which layer owns a piece of behaviour.
+-----------------------------------------------+
| |
| terraform CLI (the binary) |
| |
| - Parse command-line arguments |
| - Read environment variables |
| - Load configuration files |
| - Initialise backend + provider plugins |
| - Display plan output |
| - Prompt for approval |
| - Emit JSON output when -json is set |
| - Handle exit codes |
| |
+---------------------+-------------------------+
|
| in-process calls
v
+-----------------------------------------------+
| |
| Terraform Core (embedded in binary) |
| |
| - Parse HCL into a configuration graph |
| - Read state from backend |
| - Call provider ReadResource (refresh) |
| - Build resource dependency graph |
| - Call provider PlanResourceChange |
| - Compute the diff (plan) |
| - Call provider ApplyResourceChange (apply) |
| - Write state to backend |
| |
+---------------------+-------------------------+
|
| plugin protocol (gRPC over localhost)
v
+-----------------------------------------------+
| |
| Provider plugin (separate process) |
| |
| - Talk to real-world APIs |
| - Return resource attributes |
| - Translate API errors |
| |
+-----------------------------------------------+
The CLI and Core live in the same Go binary. They are not separate processes. Provider plugins are separate processes; the CLI spawns one per provider on demand and communicates with them over gRPC.
What the CLI is responsible for
- Argument parsing. Subcommand, flags, positional arguments.
- Environment variable processing.
TF_CLI_ARGS,TF_LOG,TF_INPUT,TF_VAR_*, and the rest. - Working directory management. The CLI resolves the working directory, the configuration files, the state file, and the plugin cache.
- Backend interaction. For remote backends, the CLI is the process that authenticates to S3, DynamoDB, GCS, etc.
- Provider lifecycle. The CLI downloads providers (during
init), starts them (duringplan/apply), and terminates them. - Output formatting. Human-readable text by default; JSON when
-jsonis set; machine-readable forterraform show -json,terraform state pull. - Exit codes. Critical for CI/CD.
What Core is responsible for
- HCL parsing. Configuration files become an internal graph representation.
- State read and write. Core loads state from the backend, deserialises it, and writes it back.
- Graph construction. Core walks the configuration, finds references, and builds the resource DAG.
- Refresh. Core calls provider
ReadResourcefor each resource. - Plan computation. Core calls provider
PlanResourceChangefor each resource and computes the diff. - Apply execution. Core walks the graph and calls provider
ApplyResourceChangefor each resource in topological order.
Core does not know how to talk to a real API. Core does not parse arguments. Core does not print output. Everything you see in the terminal is the CLI; everything Core decides is opaque to the operator unless surfaced through plan output.
Subcommands: read-only vs state-mutating
| Subcommand | Mutates state? | Mutates configuration? | Mutates infrastructure? |
|---|---|---|---|
terraform init | Sometimes (backend migration) | No | No |
terraform validate | No | No | No |
terraform fmt | No | Yes (writes .tf files) | No |
terraform plan | No | No | No |
terraform apply | Yes | No | Yes |
terraform destroy | Yes | No | Yes |
terraform output | No | No | No |
terraform state list/show | No | No | No |
terraform state mv/rm/import | Yes | No | No (with caveats) |
terraform refresh (removed in 1.3) | Yes | No | No |
terraform plan -refresh-only | No (plan only) | No | No |
terraform apply -refresh-only | Yes | No | No |
terraform console | No | No | No |
The “mutates configuration” column is important: terraform fmt
rewrites .tf files in place. It does not touch state or
infrastructure, but it does modify files that may be under version
control.
Exit codes: the CI/CD contract
$ terraform plan -input=false -no-color
# Exit code 0: success, no changes
# Exit code 0: success, changes (the plan output indicates "Plan: N to add")
# Exit code 1: error
# Exit code 2: success, changes (used by `apply -json` and some plan modes)
Three facts about exit codes that matter in CI/CD:
-
terraform planhistorically exited 0 for both “no changes” and “changes”. A CI pipeline that checks for “exit 0 means safe to deploy” is wrong if the plan proposed changes. The pipeline must inspect the plan output. -
terraform plan -detailed-exitcodereturns 2 when changes are proposed. This is the flag pipelines use to distinguish “no changes” (exit 0) from “changes proposed” (exit 2) from “error” (exit 1). Most production CI/CD uses this flag. -
terraform applyreturns 0 on success regardless of whether anything changed. A no-op apply is a success, not a failure.
# Production CI step
terraform plan -input=false -no-color -detailed-exitcode
case $? in
0) echo "No changes" ;;
1) echo "Error" >&2; exit 1 ;;
2) echo "Changes proposed; review the plan" ;;
esac
-json output: machine-readable plan and apply
terraform plan -input=false -json
terraform apply -input=false -json
terraform show -json tfplan
-json makes the CLI emit a stream of JSON messages instead of
human-readable text. Each message is a single JSON object on its
own line. The structure is documented and stable.
The two most common uses in production:
-
CI/CD plan artefacts.
terraform show -json tfplan > plan.jsonproduces a structured artefact that downstream tools (policy engines, cost estimators, PR bots) can parse without re-running the plan. -
Apply progress streaming.
terraform apply -jsonemits one JSON message per resource as it is created, updated, or destroyed. A CI pipeline can stream these to a log aggregator in real time.
The JSON format is stable across Terraform 1.x but the schema is not frozen: new fields may be added in minor releases. Code that parses plan JSON should ignore unknown fields.
CLI-only flags vs provider-configuration-only
Not all Terraform settings are set in the same place. The distinction matters when reading old configurations:
CLI-only flags (must be set on the command line or in
environment variables, not in .tf):
-parallelism=N-lock-timeout=10s-input=false-auto-approve-target=ADDRESS-refresh=false-var-file=FILE-json
Provider configuration only (set in .tf, not on command
line):
lifecycle { create_before_destroy = true }depends_on = [...]provider "aws" { region = "..." }resource "..." { ... }
Both (CLI flag overrides config block):
-var "name=value"overrides the value of a variable declared in a.tffile. The CLI flag wins.TF_VAR_nameenvironment variable overrides the same.
A common production mistake: trying to set -parallelism in the
configuration block. Terraform errors with Invalid argument.
The flag is CLI-only because the graph walker needs the value at
apply start time, not at configuration parse time.
Production failure modes
| # | Failure mode | Observable symptom | Recovery |
|---|---|---|---|
| 1 | CLI prompts for input in a non-interactive shell | Apply hangs until timeout | Set -input=false; supply -var for any required variables |
| 2 | CI misinterprets exit 0 from plan as “no changes” | Apply runs with no review | Use -detailed-exitcode; inspect plan output before apply |
| 3 | terraform show -json piped through jq with no schema check | Downstream tool breaks on Terraform minor upgrade | Add unknown-field tolerance in your JSON parser; pin the Terraform version in CI |
| 4 | TF_CLI_ARGS env var silently ignored | Operators expect a flag to take effect but it does not | TF_CLI_ARGS does not propagate to all subcommands; use explicit -flag on the command line |
| 5 | CLI working directory mismatch (running from a different cwd) | Error: No configuration files in working directory | Set TF_DATA_DIR or always run from the working directory that contains the .tf files |
| 6 | -json output piped through a logger that expects one JSON document | Logger treats each message as malformed JSON | Use a JSON-line aware log handler; do not pretty-print the JSON |
Security implications
- CLI exposes plan output to anyone who can read the terminal.
Plan output contains resource attributes after the change.
Sensitive attributes (marked
sensitive = true) are masked in output but not in the underlying plan file. Useterraform show -json | jqand inspect carefully before publishing plan artefacts. - Environment variables are visible in process listings. On a
shared host,
ps -efshows another user’sTF_VAR_*andAWS_*variables. The course treats this in the secrets chapter. - JSON output may contain secrets in clear text. The JSON plan includes the full resource attribute set, including arguments marked sensitive. Archive plan JSON with the same access controls as state.
Performance implications
- CLI parses the entire configuration on every command. For
a 5,000-resource configuration,
terraform planparses all 5,000 resources’ HCL twice (once for graph construction, once for diff). Modules add an overhead factor. - Provider plugin startup is per command. The CLI spawns a
provider process at the start of each
plan/apply. A provider that takes 5 seconds to start adds 5 seconds to the wall-clock time of every command. - JSON output is larger than text output. For a 5,000-resource plan, the JSON is several megabytes. The CLI still produces it; downstream tools pay the parsing cost.
Production guidance
- Always use
-input=falsein CI/CD. Apply hangs in non-interactive shells otherwise. - Use
-detailed-exitcodefor plan steps in CI/CD. Do not rely on exit 0 to mean “safe”. - Always use
-jsonwhen piping plan output to downstream tools. Do not parse human-readable plan output. - Pin the Terraform version in CI. JSON schema and CLI behaviour can change across minor releases.
- Treat saved plan files and plan JSON as production artefacts. They contain sensitive data and have audit value.
Verification
- What is the responsibility boundary between the terraform CLI binary and the Core engine?
- Why does
terraform planhistorically exit 0 even when changes are proposed, and what flag changes this? - When is
-jsonthe right choice, and when is human-readable output the right choice? - Which Terraform settings must be set on the CLI and which must
be set in
.tfconfiguration? - Why does a partial apply (one provider error among many) not cause the CLI to lose the state lock?
Knowledge check · 7 questions
Q1. What is the relationship between the terraform CLI and Terraform Core?
Q2. Which exit code from terraform plan -detailed-exitcode means the plan proposed changes?
Q3. terraform fmt modifies state or infrastructure.
Q4. Which of the following flags must be set on the command line and cannot be set in a .tf configuration block?
Q5. Which of the following CLI behaviours are appropriate for production CI/CD? (Select all that apply.)
Q6. What does the -json flag produce?
Q7. A CI pipeline runs terraform plan and exits 0. The pipeline then runs terraform apply without reviewing the plan output. The apply runs in production. What is the most likely root cause?
Passing score: 75%. Answers are checked in this browser.