pre-commit hooks for Terraform and infrastructure code Jump to heading

Infrastructure code fails in expensive places. A Terraform syntax error is discovered in the plan job ten minutes into a pipeline; a Kubernetes manifest with a typo in an indentation level is accepted by the API server and silently ignores a field; a Dockerfile runs as root because nobody noticed the missing USER line. Most of these problems are cheap to catch locally: formatters, validators, linters and policy scanners exist for every common infrastructure format, and the pre-commit framework can run them on just the files being committed. The trick is choosing which checks belong at commit time and which belong in CI, because some β€” terraform validate with providers, full security scans β€” are too slow or need network access. This page builds a practical hook set, within the pre-commit framework for polyglot repos.

When to use this approach Jump to heading

  • Your repository contains Terraform or OpenTofu, Kubernetes manifests, Helm charts or Dockerfiles.
  • Infrastructure mistakes are usually found in CI plan jobs or, worse, after apply.
  • You already use the pre-commit framework for application code and want the same for infrastructure.
  • Hook versions are pinned centrally, as in pinning and autoupdating pre-commit hook versions.

Step 1 β€” Start with formatting and syntax Jump to heading

Formatting and syntax checks are fast, offline and unambiguous. Add them first.

# .pre-commit-config.yaml (excerpt)
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
        args: [--hook-config=--retry-once-with-cleanup=true]
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: check-yaml
        args: [--allow-multiple-documents]
        exclude: ^charts/.*/templates/

Versions are shown as tags for readability; in a real configuration, freeze them to commit hashes with pre-commit autoupdate --freeze. check-yaml must skip Helm templates, which are Go templates rather than valid YAML until rendered. Excluding them avoids false failures.

Infrastructure checks by costFormatting and YAML syntax checks are instant and run on every commit. Terraform validate and linters take a few seconds once providers are cached. Security and policy scanners take longer and need rule bundles. Plans need credentials and belong only in CI.cheapest at the top β€” run those locallyfmt, check-yamlinstant, offlinevalidate, tflint, hadolintseconds, cached providerscheckov / trivy configslower, rule bundlesterraform plancredentials, network β€” CI onlya commit hook that needs cloud credentials will be bypassed
# Verification: run just these hooks across the infrastructure directory
pre-commit run terraform_fmt --files $(git ls-files 'infra/*.tf')

Step 2 β€” Validate Terraform without credentials Jump to heading

terraform validate checks configuration against provider schemas, which needs providers initialised β€” but not credentials. Initialise without a backend so the hook never reaches remote state.

# What the hook effectively does per module directory
terraform -chdir=infra/network init -backend=false -input=false >/dev/null
terraform -chdir=infra/network validate

Provider downloads are slow the first time. Cache them in a shared plugin directory so every module and every commit reuses them.

mkdir -p ~/.terraform.d/plugin-cache
printf 'plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"\n' >> ~/.terraformrc

Step 3 β€” Lint for mistakes the validator accepts Jump to heading

Validators check that configuration is well formed. Linters check that it is sensible: deprecated syntax, unused variables, invalid instance types, Dockerfile anti-patterns, Kubernetes resources missing limits.

  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1
    hooks:
      - id: terraform_tflint
        args: [--args=--config=__GIT_WORKING_DIR__/.tflint.hcl]
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint
  - repo: https://github.com/yannh/kubeconform
    rev: v0.6.7
    hooks:
      - id: kubeconform
        args: [-strict, -ignore-missing-schemas]
        files: ^k8s/.*\.ya?ml$
Which tool for which fileTerraform files get fmt, validate and tflint. Dockerfiles get hadolint. Kubernetes manifests get kubeconform against the API schemas. Helm charts are linted after rendering, because their templates are not valid YAML on their own.*.tffmt, validatetflintDockerfilehadolintk8s/*.yamlkubeconform -strictcharts/helm lintrender, then check-strict makes kubeconform reject unknown fields β€” the silent-typo case

kubeconform -strict is the check that catches a field indented one level too deep: the API server would accept and ignore it; strict schema validation rejects it.

Step 4 β€” Add a fast policy scan, and leave the full scan to CI Jump to heading

Security and policy scanners such as Checkov or Trivy’s configuration scanning find public buckets, open security groups and privileged containers. They are slower and noisier, so run a narrow set of high-severity rules locally and the full scan in CI.

  - repo: https://github.com/bridgecrewio/checkov
    rev: 3.2.255
    hooks:
      - id: checkov
        args: [--quiet, --compact, --check, "CKV_AWS_20,CKV_AWS_57,CKV_K8S_16,CKV_DOCKER_3"]

The CI job runs the scanner without the --check filter, reports everything, and blocks on agreed severities.

Step 5 β€” Keep the hooks fast as infrastructure grows Jump to heading

Infrastructure repositories with dozens of modules make validate and tflint slow if they run on every module for every commit. The framework passes only changed files; make sure the hooks map those to their module directories rather than scanning everything.

# Measure hook time on a typical infrastructure commit
git add infra/network/main.tf
time pre-commit run
Hook time on a one-module changeAn illustrative infrastructure repository with forty modules. Running validate across every module on each commit takes most of a minute. Limiting it to the changed module and using a shared provider cache brings a typical commit down to a few seconds.seconds for a commit touching one module (illustrative)all modules, no cache52 schanged module, no cache14 schanged module, plugin cache3 sthe plugin cache matters more than any other single setting

If a check still takes more than a few seconds, move it to the pre-push stage, as described in commit-msg and pre-push stages in pre-commit.

Step 6 β€” Mirror everything in CI, plus the plan Jump to heading

CI runs the same hooks on changed files, as in running pre-commit in CI on changed files, then adds what cannot run locally: the full policy scan and terraform plan with credentials, posted to the pull request.

      - run: pre-commit run --from-ref "$BASE" --to-ref HEAD
      - run: checkov -d infra/ --compact --soft-fail-on LOW,MEDIUM
      - run: terraform -chdir=infra/network plan -no-color -out=plan.out

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should terraform plan ever run in a commit hook? Jump to heading

No. It needs credentials, network access and often minutes. Hooks that need cloud access get bypassed, and credentials in developer hooks are a risk of their own. Plans belong in CI.

How do we lint Helm charts? Jump to heading

Render them first: helm template with representative values, piped into kubeconform. A local hook can do this for changed charts; it is slower than linting plain manifests, so many teams keep it in CI.

What about OpenTofu? Jump to heading

The same hooks work with tofu in place of terraform; the pre-commit-terraform hooks detect it or accept a hook config option to choose the binary.