Skip to main content
RunBook Academy

TerraformII · Terraform ArchitectureProduction Terraform

The CLI and Core: What Each Does

Foundation⏱ ~12 minbash

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

Not yet marked complete on this device.

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 (during plan/apply), and terminates them.
  • Output formatting. Human-readable text by default; JSON when -json is set; machine-readable for terraform 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 ReadResource for each resource.
  • Plan computation. Core calls provider PlanResourceChange for each resource and computes the diff.
  • Apply execution. Core walks the graph and calls provider ApplyResourceChange for 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

SubcommandMutates state?Mutates configuration?Mutates infrastructure?
terraform initSometimes (backend migration)NoNo
terraform validateNoNoNo
terraform fmtNoYes (writes .tf files)No
terraform planNoNoNo
terraform applyYesNoYes
terraform destroyYesNoYes
terraform outputNoNoNo
terraform state list/showNoNoNo
terraform state mv/rm/importYesNoNo (with caveats)
terraform refresh (removed in 1.3)YesNoNo
terraform plan -refresh-onlyNo (plan only)NoNo
terraform apply -refresh-onlyYesNoNo
terraform consoleNoNoNo

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:

  1. terraform plan historically 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.

  2. terraform plan -detailed-exitcode returns 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.

  3. terraform apply returns 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:

  1. CI/CD plan artefacts. terraform show -json tfplan > plan.json produces a structured artefact that downstream tools (policy engines, cost estimators, PR bots) can parse without re-running the plan.

  2. Apply progress streaming. terraform apply -json emits 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 .tf file. The CLI flag wins.
  • TF_VAR_name environment 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 modeObservable symptomRecovery
1CLI prompts for input in a non-interactive shellApply hangs until timeoutSet -input=false; supply -var for any required variables
2CI misinterprets exit 0 from plan as “no changes”Apply runs with no reviewUse -detailed-exitcode; inspect plan output before apply
3terraform show -json piped through jq with no schema checkDownstream tool breaks on Terraform minor upgradeAdd unknown-field tolerance in your JSON parser; pin the Terraform version in CI
4TF_CLI_ARGS env var silently ignoredOperators expect a flag to take effect but it does notTF_CLI_ARGS does not propagate to all subcommands; use explicit -flag on the command line
5CLI working directory mismatch (running from a different cwd)Error: No configuration files in working directorySet 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 documentLogger treats each message as malformed JSONUse 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. Use terraform show -json | jq and inspect carefully before publishing plan artefacts.
  • Environment variables are visible in process listings. On a shared host, ps -ef shows another user’s TF_VAR_* and AWS_* 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 plan parses 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

  1. Always use -input=false in CI/CD. Apply hangs in non-interactive shells otherwise.
  2. Use -detailed-exitcode for plan steps in CI/CD. Do not rely on exit 0 to mean “safe”.
  3. Always use -json when piping plan output to downstream tools. Do not parse human-readable plan output.
  4. Pin the Terraform version in CI. JSON schema and CLI behaviour can change across minor releases.
  5. 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 plan historically exit 0 even when changes are proposed, and what flag changes this?
  • When is -json the 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 .tf configuration?
  • Why does a partial apply (one provider error among many) not cause the CLI to lose the state lock?

Knowledge check · 7 questions

  1. Q1. What is the relationship between the terraform CLI and Terraform Core?

  2. Q2. Which exit code from terraform plan -detailed-exitcode means the plan proposed changes?

  3. Q3. terraform fmt modifies state or infrastructure.

  4. Q4. Which of the following flags must be set on the command line and cannot be set in a .tf configuration block?

  5. Q5. Which of the following CLI behaviours are appropriate for production CI/CD? (Select all that apply.)

  6. Q6. What does the -json flag produce?

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