Git, CI/CD & GitOpsXLIX · Infrastructure CIInfrastructure CI
Lint and static analysis — tflint, ansible-lint, kubeconform, conftest, OPA
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
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 planfails 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_accessblock 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:
- 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.
- Lint failures are pipeline failures. A rule marked
as
warnin the linter is a rule the team has decided not to enforce. The decision is recorded in the configuration; an accidentalwarnis a regression. - 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-lintconfiguration 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
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?
Q2. A lint rule that reads the current state of an AWS account is not necessarily acceptable just because the rule is fast.
Q3. Name three linters for Terraform, Ansible, and Kubernetes, and state one rule each catches that the format stage cannot.
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.