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
- Dotenv parser with exact line errors: quotes, escapes, multiline values,
export, comments,${VAR},$VAR,${VAR:-default},${VAR-default}. - Extra sources: docker-compose
environment:, GitHub Actionsenv:(workflow, job, step), Kubernetes ConfigMap/Secret (base64 decoded). Several files can be layered per environment. - Typed schema:
str int float bool url email enum duration port json regex, per-environmentrequired, defaults, secret flag, deprecation andrenamed_to. - Commands:
check,diff,matrix,sync-example,lint,export(json/shell/docker/k8s),doc,codegen(py/ts),infer. - Secrets never printed: masked by schema flag, by name heuristics, by k8s
Secretorigin, and URL passwords are redacted.
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
- Compose and GitHub Actions values are taken literally; compose
${VAR}interpolation is not resolved. GitHub${{ ... }}expressions are accepted without type checking. - For compose files, variables from all services are merged (last wins); env-file references (
env_file:) are not followed. - Dotenv
${VAR}resolves against earlier lines in the same file only, not the process environment. - YAML scalars are read as strings, so
trueand0644are never reinterpreted. - Entropy-based secret detection is a heuristic; it errs towards flagging only secret-looking names.