Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXLIX · Infrastructure CIInfrastructure CI

Lint and static analysis — tflint, ansible-lint, kubeconform, conftest, OPA

Intermediate⏱ ~24 mingit

What you'll learn

  • Distinguish lint from format by the kind of analysis each performs
  • Identify the canonical linter for Terraform, Ansible, and Kubernetes manifests
  • Explain how conftest and OPA extend static analysis to custom policy
  • Recognise the blind spots lint cannot cover and which stage covers them

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

The lint stage is the second gate in the pipeline. Where the format stage asks “is the file written consistently?”, the lint stage asks “is the file correct?”. The two questions sound similar but they are not: a file can be perfectly formatted and still be wrong, and a file can be messy and still be correct. The format stage cannot tell the difference; the lint stage can, within limits.

What lint catches

A linter reads the source files, builds a structured model of the configuration, and applies a set of rules to that model. The rules cover the kinds of mistakes that produce review-time debate rather than runtime errors:

  • Unused variables and dead code. A variable that no resource references is a variable that does not need to exist. A resource that no module references is a resource that will be deleted on the next terraform apply.
  • Deprecated resources and arguments. Cloud providers retire resources regularly. A linter with up-to-date provider plugins flags uses of deprecated resource types or arguments before the next terraform plan fails on them.
  • Naming and tagging conventions. Most organisations require every resource to carry tags for cost allocation, environment, and owner. The lint stage enforces the conventions that the format stage cannot.
  • Cross-resource consistency. A resource that references a security group ID in another module can be checked at lint time: does the referenced ID exist? Is the reference type-correct? These checks do not require contacting the cloud provider.
flowchart LR
    A[Source files] --> B[Structured model]
    B --> C[Built-in rules]
    B --> D[Provider rules]
    B --> E[Project rules]
    C --> F[Verdict]
    D --> F
    E --> F

The structured model is what distinguishes lint from format. A formatter reads tokens; a linter parses the file into an abstract syntax tree and walks that tree applying rules. The walk is more expensive than a token reformat — typically tens of seconds for a large Terraform module — but it can answer questions a formatter cannot.

The canonical linters

For Terraform the canonical linter is tflint. TFLint ships with built-in rules for general Terraform hygiene plus plugin rules for AWS, Azure, GCP, and Kubernetes providers. The plugin rules are what make TFLint valuable: a rule can check that an aws_instance has an instance_type from the allowed list, that an aws_s3_bucket has a logging configuration, or that a kubernetes_pod security context matches the project standard.

For Ansible the canonical linter is ansible-lint. ansible-lint is a rules engine with hundreds of built-in rules covering playbook structure, variable usage, module selection, and idempotency hints. Rules can be skipped with inline comments and configured per-project via .ansible-lint.

For Kubernetes manifests the canonical linter is kubeconform, which validates against the Kubernetes OpenAPI schema. Kubeconform is both a format tool and a linter: it parses the YAML, looks up each resource kind in the schema, and reports fields that do not exist or have wrong types. A separate tool, kube-score, performs schema-aware lint with project-specific rules about probes, resource limits, and security context.

Policy as lint: conftest and OPA

When the project’s rules exceed what a built-in linter can express — “every S3 bucket must have a CostCenter tag matching the regex ^CC-[0-9]{4}$”, “no module may depend on a module in a higher-cost environment” — the lint stage delegates to a policy engine.

OPA (Open Policy Agent) is a policy engine that evaluates structured documents against rules written in Rego. Conftest is a thin wrapper that runs OPA against configuration files. A typical pipeline runs conftest test --policy policies/ *.tf after TFLint and fails the job on any policy violation.

package main
deny[msg] {
  input.resource_type == "aws_s3_bucket"
  not input.tags.CostCenter
  msg := sprintf("bucket %s has no CostCenter tag", [input.address])
}

OPA and conftest are not Terraform-specific. They work against any structured document: Kubernetes manifests, Ansible playbooks, OpenAPI specs, Dockerfile metadata. The same policy can be enforced across multiple IaC languages by writing the rule against the parsed document rather than the source syntax.

What lint cannot catch

The lint stage has blind spots that are deliberate. It cannot catch:

  • Runtime behaviour. A linter cannot tell whether a load balancer will accept traffic on the configured port, whether an IAM policy grants the right permissions, or whether a Kubernetes deployment will schedule. These require the plan stage or the test stage.
  • Security misconfigurations that depend on values. A linter can check that an S3 bucket has a public_access block defined; it cannot check that the block’s value is correct without evaluating it. Security scanning with value-aware rules belongs to the security stage.
  • Cross-module or cross-repository references. A linter sees the current repository. A module that references a remote module version can check syntax but cannot check the remote module’s rules until that module is fetched.

Production discipline

Three rules govern the lint stage:

  1. Lint rules are versioned in the repository. A lint rule that lives in an out-of-tree configuration is a rule that the next person cannot audit. The linter version, the plugin set, and the rule list are all committed.
  2. Lint failures are pipeline failures. A rule marked as warn in the linter is a rule the team has decided not to enforce. The decision is recorded in the configuration; an accidental warn is a regression.
  3. The lint stage is fast enough to run on every commit. If the lint stage is slow enough to warrant skipping on feature branches, the rule set is too aggressive or the runner is too small. Fix the pipeline, not the policy.

Cross-course references

  • Terraform for Production Sysadmins — Part XXV (QualityGates) covers the full lint-stage toolchain and the rules that ship with TFLint.
  • Ansible for Production Sysadmins — Part XXXVII (RepoArch) discusses ansible-lint configuration and the rule set most teams start from.
  • OPA in Production — Parts IV-VI cover Rego patterns for cloud policy; the same patterns appear in the conftest integration.

Quiz

Knowledge check · 4 questions

  1. Q1. A team uses TFLint with the AWS plugin to flag S3 buckets missing server-side encryption. The lint stage fails the build on findings. Which class of error is being caught?

  2. Q2. A lint rule that reads the current state of an AWS account is not necessarily acceptable just because the rule is fast.

  3. Q3. Name three linters for Terraform, Ansible, and Kubernetes, and state one rule each catches that the format stage cannot.

  4. Q4. Diagnose a pipeline where a custom OPA rule for cost-centre tags produces a different result on the contributor's laptop than in CI.

    The team has a conftest rule that requires every `aws_s3_bucket` to have a `CostCenter` tag matching the regex `^CC-[0-9]{4}$`. On the contributor's laptop the rule passes; in CI the rule fails on three buckets. The source files are identical because the PR is built from the same commit.

Passing score: 75%. Answers are checked in this browser.