| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1kreuzwerker/docker 3.9.0 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform and Docker — Terraform lab environment on Ubuntu |
| Privilege | Normal user with access to the Docker socket |
| Scope | Terraform diagnostic logging — enabling TF_LOG, comparing log levels, persisting output with TF_LOG_PATH, separating Terraform Core from provider logs, filtering a real failure down to the relevant lines, keeping credentials out of shared logs, and switching logging back off. Does not cover a general Terraform troubleshooting catalogue, provider plugin development, or attaching a debugger to a crashed process. |
| Related guides | terraform plan terraform apply terraform init Terraform providers Terraform sensitive data |
Terraform's normal error output is written for humans: it tells you which resource failed and roughly why. When that is not enough, you need the diagnostic stream underneath it. A provider that swallows the real API response, a plan that reads the wrong file, an init that talks to an unexpected registry: none of those explain themselves in six lines. That stream is off by default and you turn it on with a single environment variable.
Work under ~/terraform-labs/terraform-debug-logging/ so nothing here collides with your other exercises.
Start from a failure that the normal output cannot explain
Everything below is easier to follow with a failure in front of you, so start by building one on purpose. Create the lab directory:
mkdir -p ~/terraform-labs/terraform-debug-logging && cd ~/terraform-labs/terraform-debug-loggingWrite a root module that asks the Docker provider for an image tag that does not exist anywhere. The container resource downstream never gets a chance to run:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {
host = "unix:///var/run/docker.sock"
}
resource "docker_image" "web" {
name = "golinuxcloud/no-such-image:9.9.9"
}
resource "docker_container" "web" {
name = "tf-debug-log-web"
image = docker_image.web.image_id
}
EOFInstall the provider plugin so the working directory is ready:
terraform init -input=false -no-colorSample output:
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.Now apply and let it fail. This is the baseline you will compare every log against:
terraform apply -auto-approve -no-colorSample output:
Plan: 2 to add, 0 to change, 0 to destroy.
docker_image.web: Creating...
Error: Unable to read Docker image into resource: unable to pull image golinuxcloud/no-such-image:9.9.9: error pulling image golinuxcloud/no-such-image:9.9.9: Error response from daemon: pull access denied for golinuxcloud/no-such-image, repository does not exist or may require 'docker login'
with docker_image.web,
on main.tf line 15, in resource "docker_image" "web":
15: resource "docker_image" "web" {That message is good. It names the resource, the file, the line, and the daemon response. It still leaves questions open: did Terraform Core reject the plan, or did the provider report the failure? Which provider RPC was in flight? Was the daemon even contacted? Rerun the same command with logging switched on and those answers appear:
TF_LOG=DEBUG terraform apply -auto-approve -no-color 2>&1 >/dev/null | wc -lSample output:
146One hundred and forty-six lines of diagnostics accompanied an error that printed six. The rest of this guide is about turning that flood into an answer.
Enable Terraform debug logs with TF_LOG
TF_LOG is the master switch. Set it to a level and Terraform writes timestamped log records to stderr, alongside the usual output on stdout. Setting it inline applies to a single command:
TF_LOG=INFO terraform plan -no-color 2>&1 >/dev/null | head -4Sample output:
2026-08-12T11:03:24.639+0530 [INFO] Terraform version: 1.15.8
2026-08-12T11:03:24.639+0530 [INFO] Go runtime version: go1.25.10
2026-08-12T11:03:24.639+0530 [INFO] CLI args: []string{"terraform", "plan", "-no-color"}
2026-08-12T11:03:24.639+0530 [INFO] Loading CLI configuration from /root/.terraformrcEven at INFO the opening lines are worth knowing about: the Terraform version, the Go runtime, the exact arguments the binary received, and which CLI configuration file it read. Those four facts settle a surprising number of "it works on my machine" arguments.
Log levels and how much they cost you
Terraform 1.15.8 accepts these values, ordered from most to least verbose:
TRACE— every internal step, graph walk, and RPC boundaryDEBUG— the level you should reach for firstINFO— startup facts, plugin lifecycle, operation boundariesWARN— deprecations and environment problems onlyERROR— failures onlyOFF— logging disabled for that logger
Volume is the real difference between them, and it is easy to measure. Loop over each level and count the lines that reach stderr for the same plan:
for LEVEL in ERROR WARN INFO DEBUG TRACE; do
TF_LOG=$LEVEL terraform plan -no-color 2>/tmp/tf-$LEVEL.log >/dev/null
printf '%-6s %4s lines\n' "$LEVEL" "$(wc -l < /tmp/tf-$LEVEL.log)"
doneSample output:
ERROR 0 lines
WARN 20 lines
INFO 37 lines
DEBUG 108 lines
TRACE 741 linesA successful plan produces no ERROR records at all, which is why TF_LOG=ERROR is close to useless for investigation. By the time something is logged at that level, Terraform has already printed it to your terminal. DEBUG roughly triples INFO, and TRACE is another seven times larger again.
What happens when the level is misspelled
Terraform does not reject an unknown level. It warns and then picks the loudest option, which catches people out:
TF_LOG=VERBOSE terraform plan -no-color 2>&1 >/dev/null | head -1Sample output:
[WARN] Invalid log level: "VERBOSE". Defaulting to level: TRACE. Valid levels are: [TRACE DEBUG INFO WARN ERROR OFF]2026-08-12T11:03:24.809+0530 [INFO] Terraform version: 1.15.8The warning is emitted before the log stream is formatted, which is why the first real record runs on from the end of it without a line break. If your terminal suddenly fills with thousands of lines you did not ask for, check the spelling of the level before blaming Terraform. That warning also doubles as the authoritative list of levels your binary supports, which beats trusting any blog post, including this one.
Turn it off again
An exported TF_LOG survives for the rest of your shell session and slows down every later command. Clear it as soon as you are done:
unset TF_LOGunset prints nothing when it succeeds. Verify with env | grep '^TF_LOG', which returns no lines once the variable is gone.
One more format: JSON
JSON is a formatting choice rather than a severity level. Setting TF_LOG=JSON selects machine-readable output at TRACE verbosity, one JSON object per line, which suits piping into jq or a log shipper rather than reading by eye:
TF_LOG=JSON terraform plan -no-color 2>&1 >/dev/null | head -1Sample output:
{"@level":"info","@message":"Terraform version: 1.15.8","@timestamp":"2026-08-12T10:56:29.682193+05:30"}HashiCorp explicitly documents this encoding as unstable, so parse it for your own throwaway tooling and never build a dashboard that depends on the field names surviving an upgrade.
Save Terraform logs with TF_LOG_PATH
Scrolling a terminal buffer is a poor way to read several hundred log lines, and piping stderr through tail throws away the beginning of the run, which is usually the part that matters. TF_LOG_PATH sends the diagnostic stream to a file instead and leaves your terminal readable.
Set both variables for the session, since you are about to run several commands against the same log:
export TF_LOG=DEBUG
export TF_LOG_PATH="$PWD/terraform-debug.log"Neither export prints anything. Now rerun the failing apply and watch what stays on screen:
terraform apply -auto-approve -no-colorSample output:
docker_image.web: Creating...
Error: Unable to read Docker image into resource: unable to pull image golinuxcloud/no-such-image:9.9.9: error pulling image golinuxcloud/no-such-image:9.9.9: Error response from daemon: pull access denied for golinuxcloud/no-such-image, repository does not exist or may require 'docker login'
with docker_image.web,
on main.tf line 15, in resource "docker_image" "web":
15: resource "docker_image" "web" {This is the combination worth memorising: the console shows exactly the clean error a human wants, while the full diagnostic stream lands in the file. Check that the file arrived and how big it is:
ls -lh terraform-debug.logSample output:
-rw-r--r-- 1 root root 18K Aug 12 10:57 terraform-debug.logEighteen kilobytes for one failed apply is small enough to read and far too much to paste into a chat message. Look at the last few records to confirm the run reached its end rather than being cut off:
tail -3 terraform-debug.logSample output:
2026-08-12T10:57:44.396+0530 [INFO] provider: plugin process exited: plugin=.terraform/providers/registry.terraform.io/kreuzwerker/docker/3.9.0/linux_amd64/terraform-provider-docker_v3.9.0 id=211878
2026-08-12T10:57:44.396+0530 [DEBUG] provider.stdio: received EOF, stopping recv loop: err="rpc error: code = Canceled desc = context canceled"
2026-08-12T10:57:44.396+0530 [DEBUG] provider: plugin exitedA normal CLI run commonly ends with provider-plugin shutdown records. If a log stops abruptly in the middle of an RPC, investigate whether Terraform or the provider process was interrupted, killed, or crashed.
Terraform appends to the log file
The next behaviour surprises people mid-investigation. Run any other command with logging still enabled:
terraform plan -no-color >/dev/null 2>&1The plan is silent because you redirected it, but the log file grew. Count the lines and the run markers:
wc -l terraform-debug.log && grep -c 'Terraform version' terraform-debug.logSample output:
247 terraform-debug.log
2Terraform appends rather than truncating, so the file now holds two runs stacked end to end and two Terraform version banners mark where each one started. That is convenient when you deliberately want a whole reproduction in one artifact, and dangerous when you forget about it. An error from ten minutes ago reads exactly like the one you are chasing. Delete the file before each fresh reproduction, or point TF_LOG_PATH at a new filename per attempt.
TF_LOG_PATH on its own does nothing
TF_LOG_PATH chooses a destination; it does not enable logging. Prove it with the level unset:
unset TF_LOG && TF_LOG_PATH="$PWD/path-only.log" terraform plan -no-color >/dev/null 2>&1The plan runs normally. Ask the filesystem what Terraform actually wrote:
ls -l path-only.logSample output:
-rw-r--r-- 1 root root 0 Aug 12 10:58 path-only.logZero bytes. The file exists, which is exactly why this trips people up: a created file looks like success. No level was set, so nothing was written to it. Always pair TF_LOG_PATH with TF_LOG, TF_LOG_CORE, or TF_LOG_PROVIDER.
Separate Terraform Core from provider logs
The most useful question a log can answer is whose fault is this? Terraform Core builds the graph, evaluates expressions, and manages state. Providers translate resources into API calls. Two extra variables let you listen to one side at a time, and both accept the same levels as TF_LOG.
| Variable | Logs written by | Use it when |
|---|---|---|
TF_LOG |
Everything — Core, SDKs, and providers | You do not yet know where the problem is |
TF_LOG_CORE |
The Terraform binary only | Config loading, graph, state, or backend behaviour looks wrong |
TF_LOG_PROVIDER |
Provider plugins and their SDKs | A resource fails while the plan itself looked correct |
Start with the provider side, since the Docker failure happens during resource creation. Point the log at its own file so you can compare the two halves later:
unset TF_LOG && export TF_LOG_PROVIDER=DEBUG && export TF_LOG_PATH="$PWD/provider-only.log"Run the failing apply again to populate that file:
terraform apply -auto-approve -no-color >/dev/null 2>&1The output is suppressed because you already know what the error says. The log is what you are after. Count what the provider alone produced:
wc -l provider-only.logSample output:
42 provider-only.logForty-two lines, against the hundred and thirty-nine the same apply produced with TF_LOG=DEBUG. Look at how the file opens:
head -3 provider-only.log | cut -c1-130Sample output:
2026-08-12T10:58:07.429+0530 [INFO] provider: configuring client automatic mTLS
2026-08-12T10:58:07.444+0530 [DEBUG] provider: starting plugin: path=.terraform/providers/registry.terraform.io/kreuzwerker/docker
2026-08-12T10:58:07.445+0530 [DEBUG] provider: plugin started: path=.terraform/providers/registry.terraform.io/kreuzwerker/dockercut trims each line to 130 characters so the paths stay readable here; drop it when you are reading the file yourself. Every record concerns the plugin process: starting it, securing the channel, and talking to it. There is no graph walk and no state handling in sight.
Now swap to the other side and write it somewhere separate:
unset TF_LOG_PROVIDER && export TF_LOG_CORE=DEBUG && export TF_LOG_PATH="$PWD/core-only.log"Run the same apply once more so both files describe the same failure:
terraform apply -auto-approve -no-color >/dev/null 2>&1Compare the size of this half against the provider half:
wc -l core-only.logSample output:
97 core-only.logCore is the noisier of the two on this run. Confirm the split is genuine rather than approximate by looking for plugin chatter in the Core log:
grep -c 'provider:' core-only.logSample output:
0Not one line. The two logs partition the run cleanly, which is what makes the technique worth the extra variables: if the error diagnostic appears in provider-only.log and not in core-only.log, the provider reported it and Terraform Core merely passed it along.
Avoid mixing global and scoped logging variables
HashiCorp documents TF_LOG as overriding the scoped logging variables. In practice, mixing global and scoped settings also makes troubleshooting harder, because Terraform Core, provider SDKs, and provider-specific loggers can each produce different classes of records. Pick one mode per investigation: TF_LOG for the combined stream, or TF_LOG unset and a scoped variable when you want one side on its own.
Switching modes cleanly means clearing everything the previous section exported, including the log path:
unset TF_LOG TF_LOG_PATH TF_LOG_CORE TF_LOG_PROVIDERunset prints nothing. With the environment clear, ask for provider records only and send them to a file of your choosing:
TF_LOG_PROVIDER=DEBUG terraform plan -no-color 2>/tmp/scoped-prov.log >/dev/nullCount what landed in that file:
wc -l < /tmp/scoped-prov.logSample output:
30Thirty lines, all of them about the plugin process. Swapping to the Core side is the same shape, with TF_LOG still unset:
TF_LOG_CORE=DEBUG terraform plan -no-color 2>/tmp/scoped-core.log >/dev/nullCount that half as well:
wc -l < /tmp/scoped-core.logSample output:
78Seventy-eight lines of configuration loading, graph construction, and state handling, with no plugin chatter mixed in. The rule worth remembering: unset TF_LOG whenever you intentionally switch to scoped logging, so the level you set is the level you get.
Narrow a provider failure down to the evidence
You now have a log that contains the answer and several hundred lines that do not. Filtering is the whole skill. Point the variables back at the combined log so Core and provider records sit in one timeline:
export TF_LOG=DEBUG && unset TF_LOG_CORE TF_LOG_PROVIDER && export TF_LOG_PATH="$PWD/terraform-debug.log"Start broad and find where severity changes:
grep -n '\[ERROR\]' terraform-debug.log | cut -c1-100Sample output:
134:2026-08-12T10:57:44.352+0530 [ERROR] provider.terraform-provider-docker_v3.9.0: Response contain
136:2026-08-12T10:57:44.362+0530 [ERROR] vertex "docker_image.web" error: Unable to read Docker imagTwo records, and their prefixes already answer the ownership question: line 134 comes from provider.terraform-provider-docker, line 136 from Terraform Core's graph walk. The provider spoke first and Core reported the failed vertex afterwards, so this is a provider or API problem rather than a Core bug.
A looser pattern gives you a sense of how much noise a keyword search brings back:
grep -icE 'error|failed' terraform-debug.logSample output:
13Thirteen matches for two real errors. The rest are field names, RPC descriptions, and cancelled contexts during shutdown. That ratio is normal, and it is why you read the context around a hit rather than trusting the count.
Provider records are structured as key-value pairs, so you can pull out just the fields that identify the failing call:
grep -oE 'tf_rpc=[A-Za-z]+|tf_resource_type=[a-z_]+|tf_req_id=[a-z0-9-]+' terraform-debug.logSample output:
tf_rpc=ApplyResourceChange
tf_req_id=286e2400-35d9-6e93-533a-9ce71bd7ef54
tf_resource_type=docker_imageThis is the sharpest evidence in the whole log. The failure happened inside the ApplyResourceChange RPC for a docker_image, and tf_req_id gives you a token to grep for when a busy run interleaves several requests. Read the message the provider attached to that request:
grep -o 'diagnostic_summary="[^"]*"' terraform-debug.log | cut -c1-120Sample output:
diagnostic_summary="Unable to read Docker image into resource: unable to pull image golinuxcloud/no-such-image:9.9.9: erThe summary matches the console error word for word, which confirms Terraform passed the provider's diagnostic through unchanged instead of rewriting it. For a failure you do not already understand, open the file in a pager and jump between hits rather than piping through more filters. less -S terraform-debug.log keeps long records on one line, and typing /\[ERROR\] inside it walks you from match to match with full surrounding context.
Two habits make this work on logs far larger than the lab's. Always widen from a hit to its neighbours, because the request that failed is usually described a few lines above the error. And always start from the timestamp of the failure rather than the end of the file, because with appended logs the last line may belong to an entirely different run.
Where logging helps at init, plan, and apply
Each stage of the workflow fails differently, so the log lines worth grepping for change too.
| Command | What the logs expose | Grep for |
|---|---|---|
| terraform init | Registry service discovery, provider downloads, module fetches, backend setup | registry, GET https, backend |
| terraform plan | Config loading, graph construction, state reads, provider schema and read calls | ReferenceTransformer, tf_rpc=ReadResource |
| terraform apply | Resource create, update and delete calls, provider diagnostics, state persistence | tf_rpc=ApplyResourceChange, [ERROR] |
Initialization is the stage most often blamed on "the network", and the log settles it quickly. Rerun init with logging into a file of its own:
TF_LOG=DEBUG TF_LOG_PATH="$PWD/init-debug.log" terraform init -no-color >/dev/null 2>&1Then pull out the lines that show which hosts Terraform contacted:
grep -iE 'registry|discovery' init-debug.log | cut -c1-125Sample output:
2026-08-12T10:58:38.298+0530 [DEBUG] Service discovery for registry.terraform.io at https://registry.terraform.io/.well-known
2026-08-12T10:58:39.831+0530 [DEBUG] GET https://registry.terraform.io/v1/providers/kreuzwerker/docker/versionsTwo lines tell you the discovery document was fetched and the exact version-listing URL that followed. When init hangs or fails behind a proxy or a mirror, this is where you learn whether Terraform reached the host you expected or something else intercepted it.
Protect sensitive data in Terraform logs
Debug logs are written for developers, not for publication. Depending on the run they can carry command line arguments, absolute paths, hostnames and usernames, environment details, resource attributes, and fragments of provider requests and responses. Treat every log as untrusted output until you have read it.
The most reliable leak is also the least obvious, and you can reproduce it in a directory that needs no providers at all. Create a second lab so the demonstration stays out of the Docker working directory:
mkdir -p ~/terraform-labs/terraform-debug-logging-secrets && cd ~/terraform-labs/terraform-debug-logging-secretsDeclare a variable that is explicitly marked sensitive and feed it into a resource:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
variable "db_password" {
type = string
sensitive = true
}
resource "terraform_data" "app_env" {
input = {
db_password = var.db_password
endpoint = "https://api.example.com/v1"
}
}
EOFterraform_data is built in, so initialization needs no downloads:
terraform init -no-color >/dev/nullRun a plan with logging on and pass the fake password the way many pipelines do, on the command line:
TF_LOG=DEBUG TF_LOG_PATH="$PWD/secrets-demo.log" terraform plan -no-color -var='db_password=FAKE-PASSWORD-9f8e7d' >/dev/null 2>&1The plan succeeds quietly. Ask the log whether the value survived:
grep -n 'FAKE-PASSWORD-9f8e7d' secrets-demo.logSample output:
7:2026-08-12T10:58:53.096+0530 [INFO] CLI args: []string{"terraform", "plan", "-no-color", "-var=db_password=FAKE-PASSWORD-9f8e7d"}
17:2026-08-12T10:58:53.100+0530 [INFO] CLI command args: []string{"plan", "-no-color", "-var=db_password=FAKE-PASSWORD-9f8e7d"}The password is in the log twice, in clear text, at INFO level. That is the second-quietest level that produces anything useful. sensitive = true redacts a value from plan output and state display, but it cannot redact the argument list Terraform was launched with, because that is captured before the configuration is even parsed.
Scan the log before you share it
A keyword scan is the right first move on any log you are about to attach to an issue:
grep -inE 'token|password|secret|key' secrets-demo.log | head -5 | cut -c1-135Sample output:
7:2026-08-12T10:58:53.096+0530 [INFO] CLI args: []string{"terraform", "plan", "-no-color", "-var=db_password=FAKE-PASSWORD-9f8e7d"}
17:2026-08-12T10:58:53.100+0530 [INFO] CLI command args: []string{"plan", "-no-color", "-var=db_password=FAKE-PASSWORD-9f8e7d"}
21:2026-08-12T10:58:53.110+0530 [DEBUG] ReferenceTransformer: "var.db_password" references: []
26:2026-08-12T10:58:53.112+0530 [DEBUG] ReferenceTransformer: "terraform_data.app_env" references: [var.db_password]
27:2026-08-12T10:58:53.112+0530 [DEBUG] ReferenceTransformer: "var.db_password" references: []That pattern matched ten lines in this file, of which two carried the actual value and the rest were the variable's name appearing in graph records. Both halves of that ratio matter. Most hits are harmless, so a non-zero count is not a reason to panic; and the pattern only finds words you thought to include, so a zero count is not a clearance either. A bearer token, a connection string, an internal hostname, or a signed URL will all sail past token|password|secret|key.
Work through these before a log leaves your machine:
- Scan with a keyword pattern, then read the surrounding lines of every hit rather than the match alone
- Skim the first twenty lines, where CLI arguments and environment details are recorded
- Redact by replacing values with a marker such as
REDACTED, not by deleting whole lines that provide context - Regenerate the failure with fake credentials when you can, instead of sanitising a production log
- Never publish a raw log because it "looked fine"; looking fine and being reviewed are different things
Keep the value out of the log in the first place
Review catches leaks; passing secrets differently prevents them. The environment is not part of the argument list, so a TF_VAR_ variable never reaches the CLI args record. Regenerate the log that way:
rm -f secrets-demo.log && TF_VAR_db_password='FAKE-PASSWORD-9f8e7d' TF_LOG=DEBUG TF_LOG_PATH="$PWD/secrets-demo.log" terraform plan -no-color >/dev/null 2>&1Search the fresh log for the same string:
grep -c 'FAKE-PASSWORD-9f8e7d' secrets-demo.logSample output:
0No hits. Terraform 1.15.8 does not log the environment, so a TF_VAR_ variable or a .tfvars file is a safer habit than -var on any run that might be logged. It is still a smaller guarantee than it looks: providers are free to log whatever they like, and a provider that traces HTTP requests can print a credential it received regardless of how you supplied it.
When should you reach for TRACE?
DEBUG should be your default. It names the failing RPC, the resource type, the provider, and the diagnostic, which is enough for most investigations. TRACE exists for the cases DEBUG cannot reach: evaluation order questions, graph construction, state upgrade paths, and bug reports that Terraform maintainers ask you to attach.
The cost is not subtle, and the fairest way to see it is one log per level from the same failure. The secrets demo left you in a different directory, so return to the Docker lab first:
cd ~/terraform-labs/terraform-debug-loggingClear any log files left over from earlier sections so the comparison measures one run each:
rm -f debug-apply.log trace-apply.logCapture the DEBUG baseline of the failing image pull into a file of its own:
TF_LOG=DEBUG TF_LOG_PATH="$PWD/debug-apply.log" terraform apply -auto-approve -no-color >/dev/null 2>&1Now repeat the identical apply at TRACE, writing to a second file:
TF_LOG=TRACE TF_LOG_PATH="$PWD/trace-apply.log" terraform apply -auto-approve -no-color >/dev/null 2>&1Both files now describe the same failure against the same configuration and provider. Count them together:
wc -l debug-apply.log trace-apply.logSample output:
139 debug-apply.log
996 trace-apply.log
1135 totalIn this lab TRACE produced several times more records than DEBUG: seven times the lines and roughly eight times the bytes on disk. Treat that as one measured sample rather than a ratio Terraform guarantees, because the multiplier depends on your configuration, the providers involved, and the Terraform release. The direction is what generalises, and on a real stack with dozens of resources the difference is measured in hundreds of megabytes, most of it graph vertices you will never read. More output also means more surface area for something sensitive to appear, which is a second reason not to leave TRACE on by habit.
Escalate to TRACE when DEBUG has genuinely run out: when the provider looks innocent and you need to see how Terraform evaluated the configuration, or when someone triaging your bug report asks for it. Escalating on the first error just buys you a larger haystack.
Common Terraform logging mistakes
| Mistake | What you see | Fix |
|---|---|---|
TF_LOG_PATH set without a level |
An empty log file and no diagnostics | Set TF_LOG=DEBUG as well; the path alone only chooses a destination |
TF_LOG left exported |
Every later command is slow and noisy | unset TF_LOG when the investigation ends; check with env | grep '^TF_LOG' |
| Reusing one log file across attempts | Old errors read like current ones | Delete the file or use a new filename per run — Terraform appends |
Mixing TF_LOG with a scoped variable |
Uncertainty about which level actually applied | Unset TF_LOG before using TF_LOG_CORE or TF_LOG_PROVIDER |
| Reading only the last line | The error without the request that caused it | Grep for [ERROR], then read the lines above the hit |
| Blaming Terraform Core for a provider error | Time spent on the wrong component | Check the record prefix: provider.terraform-provider-* means the plugin reported it |
Starting at TRACE |
Hundreds of megabytes and no more answers | Start at DEBUG and escalate only when it falls short |
| Sharing a log unreviewed | Credentials in a public issue | Scan for keywords, read the hits in context, redact, then share |
Turn logging off and clean up
Verbose logging is a mode you enter deliberately and should leave the same way. Clear all four variables in one command:
unset TF_LOG TF_LOG_PATH TF_LOG_CORE TF_LOG_PROVIDERNothing is printed on success, so confirm the environment is clean:
env | grep -c '^TF_LOG'Sample output:
0No variables remain. Prove that Terraform is quiet again by running a plan and counting what reaches stderr:
terraform plan -no-color 2>&1 >/dev/null | wc -lSample output:
0Zero lines of diagnostics, so you are back to normal output. Finally, delete the log files you generated, since they are the artifacts most likely to be committed by accident:
rm -f ~/terraform-labs/terraform-debug-logging/*.log ~/terraform-labs/terraform-debug-logging-secrets/*.logAdd *.log to .gitignore in any repository where you debug regularly. Nothing was created in Docker during this lab, because the image pull failed every time, so there are no containers to remove.
References
- Enable logs to debug Terraform — Terraform documentation
- Managing log output — Terraform plugin development
- Environment variables — Terraform CLI documentation
- Docker provider — Terraform Registry
Summary
Terraform's diagnostic logging is one environment variable away, and the discipline is knowing when to switch it on and when to switch it off. TF_LOG=DEBUG turns a six-line error into roughly a hundred and forty lines of context that name the RPC, the resource type, and the component that actually failed. TF_LOG_PATH keeps that flood out of your terminal while leaving the clean human-readable error on screen, which is the combination worth building into muscle memory.
Two behaviours cause most of the confusion. TF_LOG_PATH on its own creates an empty file and logs nothing, so a level must always accompany it. Terraform also appends rather than truncates, which means a file you forgot to delete will mix an old failure into your current investigation. On the scoping side, HashiCorp documents TF_LOG as overriding TF_LOG_CORE and TF_LOG_PROVIDER, so unset the global variable before you reach for a scoped one instead of setting both and reasoning about which wins.
Treat logs as sensitive by default. A value passed with -var lands in the logged CLI arguments at INFO level even when the variable is declared sensitive, which is exactly the kind of leak a quick keyword grep will find and a careless copy-paste will publish. Scan, read the hits in context, redact, and prefer TF_VAR_ environment variables when a run might be logged.
For your own stacks, reproduce the failure at DEBUG into a fresh file, grep for [ERROR] to find where severity changes, read the record prefix to decide whether Core or the provider owns the problem, and only escalate to TRACE when that answer is genuinely missing. Then unset every TF_LOG* variable so the next command runs at normal speed.

