linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Введение
  • Уроки
  • How it works
  • База знаний
  • Шпаргалка
  • Capstone
  • Собеседование
home/terraform/lessons/tf-beginner-12-debugging

lesson ── terraform-beginner ── ~15 мин ── 5 шагов

Debugging. TF_LOG, the graph, reading someone else's error

Terraform is quiet by default. An apply fails with a short message. A plan looks odd, and you are left guessing. That is normal for a CLI, but behind it there are full diagnostic logs and tools that read them.

In this lesson you will work through three scenarios: a broken dependency (cycle), a confusing error from the provider (you need TF_LOG), and an unpredictable plan (you need graph). By the end you will know what to do when "something is not working and I do not understand what."

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Build a configuration with a cycle

    A cycle is when A depends on B and B depends on A. The Terraform graph is acyclic by contract, so this code fails at plan.

    Create a file main.tf in ~/tf-debug:

    hcl
    resource "aws_s3_bucket" "first" {
      bucket = "linuxlab-cycle-first-${random_id.suffix.hex}"
      tags = {
        PairedWith = aws_s3_bucket.second.bucket
      }
    }
    resource "aws_s3_bucket" "second" {
      bucket = "linuxlab-cycle-second-${random_id.suffix.hex}"
      tags = {
        PairedWith = aws_s3_bucket.first.bucket
      }
    }
    resource "random_id" "suffix" {
      byte_length = 4
    }

    first refers to second.bucket, and second refers to first.bucket. Which one do you create first? Nobody knows.

    Run plan:

    bash
    cd /home/student/tf-debug
    terraform init -input=false
    terraform plan

    You get:

    Error: Cycle: aws_s3_bucket.first, aws_s3_bucket.second

    This is protection, not a bug. Terraform does not guess, it refuses.

    подсказка

    If you do not get Cycle: check that both references are in place (grep PairedWith main.tf).

    ✓ Cycle caught. Now let us visualize the graph to see the problem.

  2. 02

    Look at the dependency graph

    Terraform can print the graph in Graphviz dot format:

    bash
    terraform graph

    That is text. To see the picture you need graphviz (the dot command). It is installed in the sandbox:

    bash
    terraform graph | dot -Tpng > /tmp/graph.png

    The file /tmp/graph.png holds the visualization. In our case you will see two arrows between aws_s3_bucket.first and aws_s3_bucket.second, one each way. That is the cycle.

    With TF 1.4+ there is cycle highlighting right in the command:

    bash
    terraform graph -draw-cycles

    Nodes in cycles are marked red in the dot output.

    More detail in tf-graph.

    подсказка

    If you get `dot: command not found`: graphviz is not installed. It should be there in the sandbox, but if it fails, skip this step and move on.

    ✓ Graph printed. Now let us fix the cycle.

  3. 03

    Break the cycle: add an intermediate resource

    The cleanest way to break a cycle is to move the dependent value into a third resource or into a local. Replace main.tf with:

    hcl
    resource "random_id" "suffix" {
      byte_length = 4
    }
    locals {
      first_name  = "linuxlab-cycle-first-${random_id.suffix.hex}"
      second_name = "linuxlab-cycle-second-${random_id.suffix.hex}"
    }
    resource "aws_s3_bucket" "first" {
      bucket = local.first_name
      tags = {
        PairedWith = local.second_name
      }
    }
    resource "aws_s3_bucket" "second" {
      bucket = local.second_name
      tags = {
        PairedWith = local.first_name
      }
    }

    Now both buckets depend on random_id, not on each other. The paired bucket names in the tags come from locals, which are just strings, not resource attributes.

    bash
    terraform plan
    terraform apply -auto-approve

    Plan shows 2 to add (random_id is already created, or will be), and apply goes through.

    подсказка

    Do not forget to remove the original cross references `aws_s3_bucket.X.bucket`: they were the source of the cycle.

    ✓ Cycle broken, both buckets created. This is the usual pattern: move the shared name into a local.

  4. 04

    Cause an error and catch it with TF_LOG

    Throw in a deliberate error: try to create a bucket with an invalid name. S3 does not allow uppercase letters or special characters.

    Create a file bad.tf alongside the others:

    hcl
    resource "aws_s3_bucket" "broken" {
      bucket = "LinuxLab-INVALID-${random_id.suffix.hex}"
      # ^^^^^^^^^^^^^^^^^^^^^^^^ uppercase letters are not allowed in S3
    }

    Run apply with the normal output:

    bash
    terraform apply -auto-approve

    You get a short error message from the provider, something like "InvalidBucketName" or "BucketAlreadyExists" (LocalStack may return something slightly different). What exactly is wrong is not obvious.

    Now the same thing, but with TF_LOG=DEBUG and a saved copy:

    bash
    TF_LOG=DEBUG terraform apply -auto-approve 2>&1 | tee /tmp/tf.log

    The file /tmp/tf.log holds the entire diagnostic output. Look for the HTTP response from the cloud:

    bash
    grep -A 5 'HTTP/1.1 4' /tmp/tf.log | head -30

    You will see the original message from the S3 API, usually with an XML block <Error><Code>...</Code><Message>...</Message></Error>. That Message is clearer than what Terraform showed in the normal output.

    See tf-log-debug for log levels and filtering.

    подсказка

    If grep finds nothing, TF_LOG did not take effect. Check that the variable is set: `echo $TF_LOG`. It should be `DEBUG`.

    ✓ TF_LOG turned on, the debug output is saved. In real work this is the first step when errors look strange.

    The same thing on OpenTofu

    OpenTofu keeps its CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. But on the first switch, back up your state and run on a feature branch, because the differences concentrate in newer features (variables in the backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  5. 05

    Remove the broken piece and leave a clean state

    Delete bad.tf:

    bash
    rm /home/student/tf-debug/bad.tf

    Apply. Terraform sees that broken is gone from the HCL and removes it from state (if it ever got there; on a failed create it usually does not):

    bash
    terraform apply -auto-approve
    terraform plan -detailed-exitcode
    echo "exit: $?"

    It should say exit: 0. This is the main invariant: after apply, a repeat plan is clean.

    To lock it in: every time apply fails or a plan is confusing, reach for TF_LOG=DEBUG, the graph, and a search for 4xx in the logs. This is routine, not heroics.

    подсказка

    If the plan is not clean, look at what changes it shows (`terraform plan` with no flags). Most likely something is left over from broken.

    ✓ State and HCL match. The beginner track is done. You can come back any time, or wait for intermediate.

    The full debugging workflow

    When something is not working and the reason is unclear:

    1. terraform validate, is it the syntax? A typo?
    2. terraform fmt, maybe the style is getting in the way (rare, but it happens).
    3. terraform plan -detailed-exitcode, are there any changes at all? Maybe you have already applied everything.
    4. terraform graph -draw-cycles, maybe a cycle?
    5. TF_LOG=DEBUG terraform apply 2>&1 | tee /tmp/tf.log, the full debug log.
    6. grep -A 5 'HTTP/1.1 4' /tmp/tf.log, what does the cloud say?
    7. terraform console, what are the real values of the variables and expressions?
    8. Search for the error in tf-common-errors, maybe it is a known one.

    This order goes from cheap to expensive. Do not jump straight into TF_LOG if the error is a simple typo that validate would have found in a second.

    • → TF_LOG in full
    • → Common errors
    • → How to read a plan

Что ты узнал

Three debugging tools. TF_LOG=DEBUG for confusing errors from the provider. terraform graph for working out dependencies and cycles. Reading the HTTP requests in the logs, for cases where the Terraform message hides the point while the cloud gives you the original error.

команды

  • TF_LOG=DEBUG terraform apply 2>&1 | tee tf.loglog plus a saved copy to dig through
  • terraform graph | dot -Tpng > graph.pngvisualize the dependency graph
  • terraform graph -draw-cyclesTF 1.4+: highlight cycles
  • grep -A 3 'HTTP/1.1 4' tf.log4xx responses from the cloud: usually clearer

концепции

  • · TF_LOG=DEBUG turns on diagnostics without the noise of TRACE
  • · A cycle in the graph: an invalid DAG, fixed by breaking the reference
  • · The cloud often speaks more clearly than Terraform: look for RESPONSE 4xx in the logs

← предыдущий

Utility providers: random, time, archive, external

следующий →

Multi-env: workspaces vs directories

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies