CLI ยท commit ad8f003

envsync

.env schema validation, drift detection & secret hygiene across environments

Download envsync.zip
Quickstart
unzip envsync.zip && cd envsync
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

envsync

envsync keeps .env, .env.example, staging and prod env files, docker-compose, GitHub Actions and Kubernetes ConfigMaps/Secrets from drifting apart. You describe your variables once in envsync.toml; envsync validates every environment against it, shows drift with secrets masked, regenerates .env.example, lints for hygiene problems, and generates typed settings loaders for Python and TypeScript.

Features

Install

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'

Python 3.11+. Dependencies: click, pyyaml.

Quickstart

cd examples/app
envsync check          # exit 1 if any environment has errors
envsync matrix
envsync diff staging prod
envsync lint
envsync sync-example --check

envsync reads envsync.toml from the current directory (-c PATH to override). Environment file paths inside it are relative to the schema file.

Usage

The examples/app directory is a shop app with dev (.env.dev + docker-compose), staging (dotenv), prod (ConfigMap + Secret) and ci (GitHub Actions), with planted drift. All output below is real.

check

$ envsync check --env staging --env prod
staging  [FAIL]  env/staging.env
  error WORKERS: int: must be an integer (got 'abc')  (env/staging.env:8)
  error ADMIN_EMAIL: email: must be an email address (got 'ops@shop')  (env/staging.env:10)
  error SENTRY_DSN: required variable is missing
prod  [FAIL]  k8s/prod-configmap.yaml, k8s/prod-secret.yaml
  error REQUEST_TIMEOUT: duration: must be a duration like 500ms, 30s, 5m, 1h30m, 2d (got '30')  (k8s/prod-configmap.yaml)
  error SMTP_PASSWORD: required variable is missing

5 error(s), 0 warning(s) across 2 environment(s)

Options: --env NAME (repeatable), --strict (unknown vars become errors), --format json. Exit codes: 0 clean, 1 findings, 2 usage/schema error. Values of secret variables never appear in messages.

matrix

$ envsync matrix
variable         dev         staging  prod     ci
---------------  ----------  -------  -------  ----------
APP_ENV          ok          ok       ok       ok
DEBUG            ok          ok       ok       -
PORT             ok          -        -        -
PUBLIC_BASE_URL  -           ok       ok       -
DATABASE_URL     ok          ok       ok       ok
REDIS_URL        ok          ok       ok       ok
REDIS_HOST       deprecated  -        -        deprecated
SECRET_KEY       ok          ok       ok       ok
LOG_LEVEL        ok          ok       ok       ok
REQUEST_TIMEOUT  ok          -        INVALID  -
WORKERS          ok          INVALID  ok       ok
ADMIN_EMAIL      -           INVALID  ok       -
SENTRY_DSN       -           MISSING  ok       -
SMTP_PASSWORD    ok          ok       MISSING  -
FEATURE_FLAGS    ok          -        ok       -
DEBUG_TOOLBAR    extra       -        -        -

--format md|json also available.

diff

diff A B takes schema environment names or file paths. Exit code 1 when they differ.

$ envsync diff dev ci
--- dev
+++ ci
- DEBUG=true   (only in dev)
- DEBUG_TOOLBAR=1   (only in dev)
- FEATURE_FLAGS={"new_checkout": true}   (only in dev)
- PORT=8000   (only in dev)
- REQUEST_TIMEOUT=5s   (only in dev)
- SMTP_PASSWORD=********   (only in dev)
~ APP_ENV: dev -> ci
~ DATABASE_URL: ******** -> ********   (secret values differ)
~ LOG_LEVEL: debug -> warning
~ REDIS_URL: redis://localhost:6379/0 -> redis://localhost:6379/1
~ SECRET_KEY: ******** -> ********   (secret values differ)
~ WORKERS: 2 -> 1

6 only in dev, 0 only in ci, 6 different, 1 identical

lint

Checks dotenv files from the schema plus the example file (or the files you pass). Rules: duplicate-key, trailing-space, unquoted-space, lowercase-key, real-secret (example files only: known token prefixes such as AKIA, ghp_, sk_live_, or a secret-looking name with a high-entropy value that is not an obvious placeholder), plus parse errors and undefined-ref.

$ envsync lint
.env.example:3: lowercase-key debug should be UPPER_SNAKE_CASE
.env.example:5: duplicate-key PORT already defined on line 4
.env.example:6: trailing-space trailing whitespace
.env.example:7: unquoted-space ADMIN_NAME has whitespace in an unquoted value; wrap it in quotes
.env.example:8: real-secret SECRET_KEY looks like a real secret (entropy 5.0); use a placeholder
.env.example:9: real-secret STRIPE_API_KEY looks like a real secret (entropy 4.5); use a placeholder

6 issue(s)

sync-example

Regenerates the example file from the schema (descriptions, type/requirement comments, defaults, example values; secrets stay empty; deprecated vars omitted). It is a pure function of the schema, so running it twice changes nothing. --check exits 1 if the file is stale (use it in CI); -o PATH writes elsewhere.

$ envsync sync-example --check
.env.example is out of date; run `envsync sync-example`
$ envsync sync-example
wrote .env.example

Generated content:

# Generated by `envsync sync-example` from envsync.toml. Do not edit by hand.

# Which deployment this process belongs to
# (enum: dev|staging|prod|ci, required)
APP_ENV=

# Enable debug pages and verbose errors; never true in prod
# (bool, optional, default: false)
DEBUG=false

export

envsync export TARGET --format json|shell|docker|k8s where TARGET is an environment or a file. k8s emits a ConfigMap plus a Secret (base64) for secret variables; --name and --namespace set metadata. Export prints real values, so treat the output as sensitive.

$ envsync export staging --format shell
export APP_ENV=staging
export DEBUG=false
export PUBLIC_BASE_URL=https://staging.shop.example.com
...

doc

envsync doc [-o FILE] prints a Markdown table of every variable:

| Variable | Type | Required | Default | Secret | Description |
|---|---|---|---|---|---|
| `APP_ENV` | enum: dev\|staging\|prod\|ci | yes |  |  | Which deployment this process belongs to |
| `PUBLIC_BASE_URL` | url | staging, prod |  |  | Externally visible base URL used in emails and redirects |
| `DATABASE_URL` | url | yes |  | yes | Primary database connection string |

codegen

envsync codegen --lang py|ts [-o FILE] writes a standalone typed loader (no envsync dependency). Python gives a frozen dataclass Settings and load(env=os.environ); TypeScript gives an interface Settings and loadSettings(env = process.env). Both collect every problem into one SettingsError. Durations become seconds (float/number); variables that are only required in some environments are optional in the type. The Python output passes mypy --strict; the TypeScript output runs under Node's type stripping.

from settings import load
settings = load()
settings.workers       # int
settings.request_timeout  # float seconds

infer

Drafts a schema from existing files (types guessed from values, required from presence across environments, secrets from names and URL passwords):

$ envsync infer dev=.env.dev staging=env/staging.env
[environments]
dev = ".env.dev"
staging = "env/staging.env"

[vars.DEBUG]
type = "bool"
required = true

[vars.PORT]
type = "port"
required = ["dev"]

[vars.DATABASE_URL]
type = "url"
required = true
secret = true

Join several files for one environment with commas: dev=.env.dev,docker-compose.yml. Review the draft, then add descriptions and tighten it.

Configuration

See docs/schema.md for the full envsync.toml reference and docs/design.md for how parsing, validation and drift detection work.

Architecture

Module Role
parser.py Dotenv dialect parser, interpolation, line errors
sources.py Loads dotenv, compose, GitHub Actions and k8s files into one LoadedEnv
validators.py One validator per type, value-free error messages
schema.py envsync.toml loading and schema validation
analysis.py check, diff, matrix, secret masking
lint.py Hygiene rules and entropy-based secret detection
render.py sync-example, doc, export
codegen.py Python and TypeScript loader generation
infer.py Schema drafting
cli.py Click commands

Tests

.venv/bin/pytest

106 tests cover parser edge cases, interpolation, every validator, inference, diff/matrix, sync idempotency, generated code (imported, mypy --strict, executed under Node) and the CLI end to end.

Limitations