DEV Community

Oleksandr Kuryzhev
Oleksandr Kuryzhev

Posted on Originally published at kuryzhev.cloud

Pre-commit Hooks for Terraform and Python: 6 Tips That Stick

Originally published on kuryzhev.cloud


A pull request with forty comments that all say "run terraform fmt" or "remove the unused import" is a familiar sight. Pre-commit hooks move those checks to the developer's machine, before the commit exists. The tips below target repos that mix Terraform and Python, such as infrastructure code plus Lambda handlers or automation scripts. They are independent, so adopt them in any order.

Pin every hook revision and update on purpose

A hook pinned to a branch name is a hook that changes under your feet. The rev field should always be a tag or a commit SHA, never main. That keeps every laptop and the CI runner on the same linter version, so "passes locally, fails in CI" stops being a formatting argument.

Bump versions deliberately with pre-commit autoupdate and review the diff like any dependency update. The config below combines the community Terraform hooks with Ruff for Python. The tags shown are examples only, so run autoupdate to get current ones.

# .pre-commit-config.yaml
default_language_version:
  python: python3.12   # hooks run in their own isolated environments

repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.2        # example tag; run `pre-commit autoupdate`
    hooks:
      - id: terraform_fmt
      - id: terraform_tflint
      - id: terraform_docs

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0         # example tag; autoupdate replaces it
    hooks:
      - id: ruff        # newer releases also offer the id ruff-check
        args: [--fix]   # applies safe fixes automatically
      - id: ruff-format

Watch out for hook ids changing between releases. Ruff's lint hook was historically named ruff. Newer ruff-pre-commit releases add ruff-check and keep ruff as a legacy alias. Check the hook repo's README after an autoupdate if the run suddenly reports "hook id not found".

Start Terraform with fmt, tflint, and generated docs

Begin with the checks that are least likely to produce false positives. Formatting and README generation are deterministic, and lint rules rarely misfire. All three are cheap, and they remove the most common review noise in module repos.

terraform_fmt wraps the standard terraform fmt command. terraform_tflint catches unused declarations that validate misses. With a provider ruleset plugin enabled in .tflint.hcl, such as the AWS ruleset, it also catches things like invalid instance types. terraform_docs keeps the inputs and outputs table in a module README in sync with the code, so nobody edits it by hand. It writes between marker comments in the README, as described in the hook's documentation.

Add terraform_validate only after the basics are stable. It needs providers initialized, so the first run on a fresh clone downloads them and can feel slow. A typical failure is a stale .terraform directory causing odd provider errors. The hook collection documents a retry-with-cleanup option for this, so check its README for the exact flag in your version.

Gotcha: do not let a hook run terraform plan. It needs cloud credentials and network access. A hook that silently depends on either is likely to end up bypassed routinely. Keep plans in CI, where credentials are managed properly. For that side of the pipeline, browse the other infrastructure posts on kuryzhev.cloud.

Let Ruff replace the pile of Python linters

One tool for linting and formatting means one config section and one fast hook. Ruff covers what used to take several separate tools: flake8 plugins, isort, and a Black-compatible formatter. Configure the rules in pyproject.toml so editors, CI, and the hook all read the same settings.

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
# E/F = pycodestyle and pyflakes, I = import sorting,
# B = bugbear, UP = pyupgrade modernizations
select = ["E", "F", "I", "B", "UP"]

If you use mypy, be careful. The mypy hook runs in an isolated environment that does not contain your project's dependencies. Imports from third-party packages then appear as errors unless you list stubs and libraries under additional_dependencies. Running mypy in CI only, against the real project environment, is a common and legitimate alternative.

Watch out for auto-fixing hooks. When --fix or the formatter modifies files, the hook fails the commit on purpose. Stage the changes and commit again; this is the documented behavior, not a bug.

Keep pre-commit hooks fast or people will skip them

If the hook run takes more than a few seconds, --no-verify becomes a habit. Pre-commit only passes staged files to hooks, which helps, but some checks are inherently slow. Security scanners and full validation across many Terraform roots are the usual culprits.

Move those to the pre-push stage. They still run before code leaves the machine, but not on every small commit. Install both hook types so the push-stage hooks actually fire.

# top of .pre-commit-config.yaml
default_install_hook_types: [pre-commit, pre-push]

# inside a hook entry
      - id: terraform_validate
        stages: [pre-push]   # slower check, runs on push only

After adding or changing default_install_hook_types, run pre-commit install again. Hook types are installed into .git/hooks explicitly, and a stage with no installed hook script never runs. Verify with pre-commit run --hook-stage pre-push --all-files if you are unsure the stage is wired up.

Catch secrets before they reach history

A leaked key in a commit is expensive even if the next commit deletes it. Git history keeps it, forks and clones copy it, and rotating the credential becomes the only real fix. Blocking the commit is far cheaper than cleanup.

Two layers work well together. The detect-private-key and check-added-large-files hooks from the official pre-commit-hooks repo are nearly free. A dedicated scanner such as Gitleaks adds pattern matching for cloud keys and tokens. Both fit the same config file.

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0          # example tag
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
        args: [--maxkb=500]   # catches stray state files and archives
      - id: check-yaml

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2         # example tag; confirm the latest release
    hooks:
      - id: gitleaks

Gotcha: add *.tfstate to .gitignore as well, plus any .tfvars files that contain real values. Hooks are a safety net, not the primary control. Also expect occasional false positives on test fixtures. Handle them with the scanner's documented allowlist mechanism rather than disabling the hook.

Run the same hooks in CI, because local hooks are optional

Anyone can skip a local hook with git commit --no-verify, so CI has to be the enforcer. Running pre-commit run --all-files in the pipeline guarantees the rules apply to every change, including those from contributors who never installed anything. It also validates the config file itself.

A minimal GitHub Actions job looks like this. See the GitHub Actions documentation for caching and runner details.

# .github/workflows/lint.yml
name: lint
on: [pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pre-commit
      # --show-diff-on-failure prints what the auto-fixers changed
      - run: pre-commit run --all-files --show-diff-on-failure

The CI runner also needs the Terraform, TFLint, and terraform-docs binaries on its PATH for those hooks. The hooks call local executables rather than installing them. A typical failure is a job that passes on laptops and fails in CI with "command not found" for exactly this reason. Install the tools in an earlier step, using the vendors' setup actions or your base image.

Finally, make onboarding a single documented line, such as a make setup target that runs pip install pre-commit && pre-commit install. The install step is the one thing pre-commit cannot do for you, because Git does not copy hooks on clone.

Related

Top comments (0)