๐Ÿ’ก Why toInfrastructureIntermediateโœจ AI-assisted

Why Your CI Should Run OpenTofu Lint Rules Before Plan

WittyTech Dailyยทยท4 min read
#opentofu#linting#ci#iac

Your coding agent just refactored a module, tofu plan came back clean, and the diff still carries three variables nobody reads and a local value that points at nothing. Plan was never going to mention it. The OpenTofu lint rules added in 1.13 will, and they run before plan with one flag.

Linting is opt-in and experimental: pass -lint to validate, plan, apply or refresh and findings come back as warnings. This is the case for wiring it into CI anyway, and an honest look at where it falls short.

Why OpenTofu lint rules belong before plan

All four rules in 1.13 run at the validate phase, so there are no cloud credentials and no state lock: tofu init -backend=false then tofu validate is enough. Plan is the expensive gate, so the cheap checks go first.

The four rules, per the OpenTofu linting docs:

  • core:no-type-variable flags a variable with no type. An untyped variable accepts anything, so bad input surfaces as a confusing error deep inside a resource.
  • core:unused-variable flags an input nobody reads. Someone sets aws_region in a tfvars file, nothing changes, and everyone assumes it worked.
  • core:unused-local flags locals nothing references. Pure debris.
  • core:count-instead-enabled flags count = var.x ? 1 : 0 and suggests enabled instead.

Why coding agents make an OpenTofu unused variable check worth a gate

An agent iterates: it tries a design, abandons it, edits again. The abandoned pieces stay behind, and validate has no opinion about them. The unused-variable and unused-local rules are aimed at exactly those leftovers.

The count rule catches a different habit: the enabled argument in a lifecycle block arrived in OpenTofu 1.11, and the older count idiom is still everywhere in existing code.

# flagged by core:count-instead-enabled
resource "aws_s3_bucket" "logs" {
  count  = var.create_logs ? 1 : 0
  bucket = "my-logs"
}

# preferred, OpenTofu 1.11 and later
resource "aws_s3_bucket" "logs" {
  bucket = "my-logs"

  lifecycle {
    enabled = var.create_logs
  }
}

Migrating touches every reference: the resource loses its index (aws_s3_bucket.logs, not aws_s3_bucket.logs[0]) and evaluates to null when disabled. If sensitive values make the migration error, the docs say to wrap the expression in nonsensitive.

Add tofu validate -lint to your CI pipeline

This GitHub Actions job runs the lint rules first. Make your plan job depend on it with needs: lint.

name: tofu-lint
on: pull_request

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
        with:
          tofu_version: 1.13.0
      - name: Init without a backend
        run: tofu init -backend=false
      - name: Lint before plan
        run: |
          tofu validate -no-color -lint='all,!core:unused-local' > lint.txt 2>&1 || { cat lint.txt; exit 1; }
          cat lint.txt
          if grep -Eq '\(core:[a-z-]+\)' lint.txt; then
            echo "::error::OpenTofu lint findings, see the log above"
            exit 1
          fi

Findings are warnings, so tofu validate still exits 0 and a bare step would never fail the build. The grep is your gate: it matches rule IDs rather than counting warnings, because OpenTofu prints a warning whenever linting is on.

Given this file:

variable "aws_region" {
  default = "us-east-1"
}

the log shows two findings, in the format the docs describe:

โ”‚ Warning: Variable with no type (core:no-type-variable)
โ”‚   on main.tf line 1:
โ”‚    1: variable "aws_region" {
โ”‚ Variable "aws_region" has no type specified.

โ”‚ Warning: Input variable not used (core:unused-variable)
โ”‚   on main.tf line 1:
โ”‚    1: variable "aws_region" {
โ”‚ Found no usage of the variable "aws_region".

The ! prefix excludes a rule, so all,!core:unused-local runs everything except the locals check. If all is too loud on day one, start narrow with -lint='core:no-type-variable,core:unused-variable'.

Where OpenTofu linting falls short

The strongest objection is that this is a stopgap. The OpenTofu team says as much: the flag is temporary and a configuration-file approach is the long-term plan. Know the limits before you depend on it:

  • Experimental. Behavior may change even in minor releases. Pin the OpenTofu version, as the job above does, and keep the lint in its own job so it is easy to remove.
  • Root module only. Child modules are not linted, so for a library of modules run the step in each module directory.
  • Silently off for destroy. tofu destroy and -destroy plans skip linting without telling you.
  • Warnings only. There is no built-in way to fail the build, so the gate is yours, and it parses output text that may change. Keep a fixture with one unused variable to prove your grep still fires.
  • Typos pass quietly. Unknown identifiers are ignored and identifiers are case-sensitive, so core:unused-locals runs nothing and goes green.
  • No inline suppression yet. The docs say a nolint directive is planned, so today you can only exclude a rule everywhere.

If you already run TFLint, its Terraform ruleset has comparable unused-declaration and typed-variable checks. The built-in rules still earn a slot because they ship in the binary you already install and know count-instead-enabled.

Run it once before you gate anything

Try it locally on your noisiest root module:

tofu init -backend=false
tofu validate -lint=all

Count the findings, fix what an agent left behind, exclude any rule that is mostly noise for you, then add the CI step. A cheap, credential-free check that catches the debris agents leave means a human reviewer spends their time on the infrastructure, not on dead variables.

โ† More in Infrastructure