Formatting, Validation, Linting, and Security Scanning

Purpose and Outcome

Formatting, validation, linting, and security scanning are the fast feedback layer of a Terraform delivery workflow. They answer different questions before a plan reaches review: is the configuration consistently written, is it valid Terraform for the selected providers, does it follow local conventions, and does it contain risky infrastructure patterns? By the end of this lesson, you should be able to place each check in the pipeline, explain what it can and cannot prove, interpret common failures, and build a small repeatable gate for a Terraform module.

In this course section on testing and delivery, these checks sit before plan approval and apply. They do not replace module tests, policy enforcement, manual review of destructive changes, or runtime monitoring. Their value is speed and specificity: they catch avoidable mistakes close to the author, using the same HCL files that Terraform will later load.

How the Checks Work Internally

terraform fmt parses HCL and rewrites it using Terraform language style rules. It normalizes indentation, alignment, blank lines, and expression layout without changing resource addresses, provider selections, variables, or values. Because it works from syntax, not provider APIs, it can run without credentials or a backend. A nonzero result from terraform fmt -check means at least one file would be rewritten.

terraform validate loads the root module, evaluates static expressions that can be checked without planning, reads provider schemas installed by terraform init, and verifies that blocks, attributes, types, references, variable validations, and provider requirements are coherent. It does not contact cloud APIs to prove that a subnet id exists, that a name is globally available, or that a quota is sufficient. Validation proves configuration shape, not deployability.

Linting is convention-aware static analysis. Tools such as TFLint parse Terraform configuration and apply rules that Terraform itself intentionally does not enforce, including missing version constraints, deprecated syntax, provider-specific footguns, naming conventions, or unused declarations. Linting can be local, team-specific, or provider-specific depending on enabled plugins and rules.

Security scanning inspects Terraform code for patterns associated with weak posture: public network exposure, disabled encryption, overly broad IAM policies, missing logging, public storage, or plaintext secrets. Scanners such as tfsec, Checkov, or Terrascan cannot know every business exception, but they provide structured findings with severity, resource address, and remediation guidance. The best pipelines separate findings that must block from findings that require documented review.

Command and Configuration Anatomy

The four layers are intentionally ordered from cheapest to most contextual. Run formatting first because it has deterministic output and removes style noise from reviews. Run validation after provider installation because provider schemas define which arguments are legal. Run linting after validation so rule output is not buried under parse errors. Run security scanning after the code can be parsed so findings map to real blocks and resource addresses.

Check Typical command What it proves What it cannot prove
fmt terraform fmt -check -recursive Files match canonical HCL formatting. Configuration is correct or safe.
validate terraform validate Terraform can load the module and schemas. Remote APIs will accept the plan.
tflint tflint --recursive Code satisfies selected lint rules. Every team policy is enforced.
tfsec tfsec . Known risky patterns are identified. All threats or approved exceptions are known.

Example 1: Formatting as a Deterministic Rewrite

Start with a syntactically valid file that is hard to review because everything is squeezed onto single lines. Terraform can still read it, but reviewers will waste time separating structure from substance.

# intentionally messy for fmt example
variable "name" {type=string}
resource "null_resource" "example" {triggers={name=var.name}}
output "name" {value=null_resource.example.triggers.name}

Running terraform fmt rewrites the same configuration into canonical shape. The resource address remains null_resource.example, and the output still returns the trigger value; only whitespace and layout change.

variable "name" {
  type = string
}

resource "null_resource" "example" {
  triggers = {
    name = var.name
  }
}

output "name" {
  value = null_resource.example.triggers.name
}

With terraform fmt -check, Terraform does not rewrite files. It exits nonzero and prints the names of files that need formatting. In a pre-commit hook or CI job, that deterministic failure tells the author to run terraform fmt -recursive locally and commit the resulting diff.

Example 2: Validation Catches Type and Rule Errors

Variable validation expresses constraints that Terraform can check before building a provider-backed plan. In this example, the environment is limited to named stages and replica count is bounded so accidental large deployments fail early.

variable "environment" {
  type        = string
  description = "Deployment target."

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be one of dev, stage, or prod."
  }
}

variable "replica_count" {
  type        = number
  description = "Number of service replicas."

  validation {
    condition     = var.replica_count >= 1 && var.replica_count <= 6
    error_message = "replica_count must be between 1 and 6."
  }
}

If a caller supplies environment = "production", validation reports the custom error message because that exact string is not in the allowed list. If replica_count = 0, Terraform rejects the input before it proposes resource changes. The deterministic behavior is refusal before persistent infrastructure state changes. The trade-off is that the validation must be maintained when the organization adds a new environment or changes capacity rules.

Example 3: Linting Adds Team and Provider Expectations

TFLint is configured separately from Terraform because its job is not to decide what Terraform language accepts. It decides which accepted patterns your team wants to allow. A minimal configuration can enable Terraform language rules that catch missing version declarations and deprecated interpolation syntax.

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

rule "terraform_required_version" {
  enabled = true
}

rule "terraform_required_providers" {
  enabled = true
}

rule "terraform_deprecated_interpolation" {
  enabled = true
}

After tflint --init, tflint --recursive scans modules and reports rule ids, file locations, and messages. Expected behavior for a root module without required_version is a lint finding rather than a Terraform validation error. Terraform may still be able to plan; the linter is enforcing repeatability across machines and pipelines.

Example 4: Security Scanning Reviews Infrastructure Shape

Security scanners inspect the declared shape of infrastructure. This security group permits inbound HTTPS only from an explicit variable and leaves outbound traffic open, a common application pattern that should still be reviewed in context.

resource "aws_security_group" "web" {
  name        = "example-web"
  description = "Allow HTTPS only"
  vpc_id      = var.vpc_id

  ingress {
    description = "HTTPS from approved CIDR ranges"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = var.allowed_cidr_blocks
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

A scanner should not flag world-open inbound SSH because there is no port 22 rule. It may still produce informational or medium findings about unrestricted egress, depending on its rule set. The expected output is not a clean report in every organization; the useful output is a resource-specific finding that can be accepted, fixed, or suppressed with a documented reason.

Design Choices and Trade-offs

Decide where each check runs. Running all checks locally gives immediate feedback, but local machines drift in tool versions and plugins. Running checks only in CI gives consistency, but authors discover simple problems later. Most teams use both: local pre-commit hooks for speed and CI jobs as the authoritative gate.

Decide whether security findings block automatically. Blocking high-severity issues is useful when the rule is reliable and the remediation is clear, such as rejecting public storage buckets. Blocking every low-severity result can train teams to ignore the scanner or add careless suppressions. A practical model is to fail on selected severities, require inline justification for suppressions, and periodically review exceptions.

Decide how much provider-specific linting to enable. Provider plugins catch cloud-specific mistakes that generic Terraform cannot see, but they add installation time and rule churn. Pin linter plugin versions when reproducibility matters, and document which warnings are advisory versus mandatory.

Failure Modes and Troubleshooting

Symptom: terraform validate says a provider type or argument is unknown. Cause: the working directory was not initialized, the lock file selected an unexpected provider version, or a module still uses an argument removed from the current provider schema. Diagnose: run terraform init -backend=false, inspect .terraform.lock.hcl, and check the resource address named in the error. Correct: initialize providers, adjust the version constraint deliberately, or update the resource arguments.

Symptom: lint passes locally but fails in CI. Cause: different TFLint versions, missing plugins, or a CI job running from a different directory. Diagnose: print tool versions in both environments, run tflint --init, and compare the module paths being scanned. Correct: pin tool versions in the CI image and run commands from the repository root or a documented module list.

Symptom: a security scanner reports a false positive or an accepted exception. Cause: static analysis cannot infer compensating controls, private routing, or environment-specific policy. Diagnose: locate the exact resource address, read the rule documentation, and confirm whether the scanner sees the same variable defaults used by CI. Correct: fix the risky configuration when possible; otherwise add the narrowest suppression with an expiration or ticket reference.

Symptom: terraform fmt -check fails after generated files are committed. Cause: the generator emits valid but noncanonical HCL. Diagnose: run terraform fmt -recursive and inspect whether only generated files changed. Correct: format as part of generation or exclude generated fixtures only when they are intentionally used to test invalid formatting.

Security, Performance, and Reliability Implications

These checks reduce review risk, but they also create supply-chain and availability considerations. Linter and scanner plugins are executable software in the delivery path, so install them from trusted sources and pin versions in reproducible CI images. Scanners may read all Terraform files, variable defaults, and sometimes plan JSON, so avoid committing secrets and be careful with logs and uploaded artifacts.

Performance matters in large repositories. Recursive scans across examples, vendored modules, and generated test data can slow every pull request. Use explicit module directories, cache plugin downloads, and keep a slower full scan on a scheduled job if pull-request latency becomes excessive. Reliability improves when the same commands run in the same order everywhere.

Hands-on Lab: Build a Local Quality Gate

Prerequisites: Terraform CLI, TFLint, a Terraform security scanner such as tfsec, and a small Terraform module in a throwaway branch. Use terraform init -backend=false so the lab installs provider schemas without touching remote state. If your module requires cloud credentials only for planning, validation should still run without those credentials.

repos:
  - repo: local
    hooks:
      - id: terraform-fmt
        name: terraform fmt
        entry: terraform fmt -check -recursive
        language: system
        pass_filenames: false
      - id: terraform-validate
        name: terraform validate
        entry: terraform validate
        language: system
        pass_filenames: false
      - id: tflint
        name: tflint
        entry: tflint --recursive
        language: system
        pass_filenames: false
      - id: tfsec
        name: tfsec
        entry: tfsec .
        language: system
        pass_filenames: false

Step 1: add the pre-commit configuration shown above or translate the commands into your CI system. Step 2: run terraform fmt -recursive once so the starting tree is clean. Step 3: initialize with the backend disabled. Step 4: run the complete gate manually from the repository root.

terraform fmt -check -recursive
terraform init -backend=false
terraform validate
tflint --init
tflint --recursive
tfsec .

Verification: a clean module should produce no formatting diff, pass validation, return no mandatory lint errors, and produce either no security findings or documented findings with accepted severity. Introduce one formatting error and confirm terraform fmt -check -recursive fails. Set an invalid variable default and confirm terraform validate fails before any plan or apply. Add a deliberately broad inbound rule in a scratch file and confirm the scanner identifies the resource address. Cleanup: remove the scratch rule, rerun formatting if needed, delete local .terraform directories if you do not want cached providers, and discard the throwaway branch.

Assessment Exercises

  1. A module passes terraform validate but creates a public S3 bucket in the plan. Which check should have caught that earlier, and what evidence would you expect in its output?
  2. Your team wants to block missing provider version constraints. Should that live in Terraform validation, TFLint, or a security scanner? Explain the boundary.
  3. A scanner flags unrestricted egress on a security group that is intentionally used by a NAT instance. What information should be included in an acceptable suppression?
  4. Design a CI order for a monorepo with twenty Terraform modules. How would you keep feedback fast without skipping the authoritative gate?
  5. Why is terraform init -backend=false useful in a validation job, and what class of errors will it still not detect?

Summary

Formatting makes HCL reviewable, validation proves Terraform and provider schemas can load the module, linting enforces conventions Terraform does not own, and security scanning detects risky infrastructure patterns. Used together, they form an early quality gate for Terraform delivery. The gate is strongest when commands are ordered deliberately, tool versions are reproducible, findings map to resource addresses, exceptions are documented, and the workflow still proceeds to plan review and environment-specific testing before apply.