DEV Community

Cover image for Catching config drift before it breaks prod
SrijanCodes
SrijanCodes

Posted on Originally published at github.com

Catching config drift before it breaks prod

It's 5pm on a Friday. CI is green, tests pass, the image is built. You deploy — and the app
crashes on boot because DATABASE_URL isn't set in the environment. It was in the .env on
your machine. It just never made it into the Kubernetes deployment. Nobody notices, because
nothing checks that the three places agree.

This is config drift, and it's one of the most common causes of "it worked on my machine."

Why .env linters don't catch this

Most env-var tooling validates a .env file against a schema you write by hand. That
schema drifts too, and — crucially — it doesn't know what your code actually reads.
If someone deletes a key your app requires, or mistypes DB_HOST as DBHOST, the linter is
happy because the file matches the schema.

The source of truth is your code. So I built a linter that reads it.

What conflint does

conflint parses your source to find every env var /
config key your app actually reads, then reconciles that set against every place the value
should exist — .env, docker-compose, GitHub Actions, Kubernetes and Terraform.

It knows:

Rule Meaning
CL001 read in code, defined nowhere
CL002 defined for real, never read by code
CL003 undefined name is one edit away from a known one (typo)
CL004 high-entropy secret committed in a config source
CL005 documented in .env.example but no real source provides it (drift)
CL006 required value defined but empty
CL007 defined twice / conflicting across sources
CL008 secret equals a weak/default placeholder
CL009 used or provided, but undocumented in the example file

Scanners today: Python (AST), JavaScript/TypeScript, Go, Ruby.

confl check finding a committed secret, a missing variable and a typo

A concrete example

Say your app looks like this:

# app.py
import os

DATABASE_URL = os.environ["DATABASE_URL"]
API_KEY = os.getenv("API_KEY")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
Enter fullscreen mode Exit fullscreen mode

and your .env looks like this:

API_KEY=sk-live-4f9a2b7c8d1e6f3a5b0c9d8e7f6a5b4c
DATABSE_URL=postgres://localhost:5432/app
Enter fullscreen mode Exit fullscreen mode

Running confl check gives you:

conflint checked 2 files (3 config reads, 2 definitions) in 7ms

.env:1:1
  CL004 error: 'API_KEY' looks like a committed secret in dotenv:1
    hint: Move real secrets to a secrets manager; keep placeholders in .env.example

app.py:3:16
  CL001 error: 'DATABASE_URL' is read in code but not defined in any configuration source:3
    hint: Add it to your .env (then run `confl sync` to refresh .env.example)

app.py:5:13
  CL001 warning: 'REDIS_URL' is read in code but not defined in any configuration source:5
    hint: Add it to your .env (then run `confl sync` to refresh .env.example)

2 files checked - 2 errors, 3 warnings, 0 infos
Enter fullscreen mode Exit fullscreen mode

Three real problems caught in 7 milliseconds: a committed secret, a missing required variable,
and (implicitly) a typo — note DATABSE_URL is defined but never read, which is why
DATABASE_URL is "missing."

Quickstart

pip install conflint
confl check            # lint the whole project
confl sync             # regenerate an accurate .env.example
confl explain DATABASE_URL   # who reads it? where is it defined?
Enter fullscreen mode Exit fullscreen mode

Wire it into CI and pre-commit

GitHub Action (inline PR annotations):

- name: Lint configuration
  uses: conflint/conflint@v0.1.2
  with:
    reporter: github
    fail-level: error
Enter fullscreen mode Exit fullscreen mode

Or a pre-commit hook:

- repo: https://github.com/conflint/conflint
  rev: v0.1.2
  hooks:
    - id: conflint
Enter fullscreen mode Exit fullscreen mode

Output formats: text, --json (stable schema), --sarif (GitHub/GitLab/Azure code scanning),
--github (workflow annotations), and JUnit XML for Jenkins.

Honest limitations

  • It's early (v0.1.2, MIT). The rule set and source coverage are opinionated and evolving.
  • Static analysis means it infers reads; dynamically constructed keys (e.g. os.environ[prefix + x]) can't be resolved and may need ignore-names.
  • It's not a secret scanner replacement — it catches secrets specifically in config sources, not across full git history (use gitleaks/trufflehog for that).

Try it / feedback

I'd love feedback on the rule set and which scanners/sources to add next. If it's useful,
a ⭐ helps others find it.

Top comments (0)