Skip to main content

EnvShield 🛡️

CI PyPI version License: MIT Downloads Website Stars

Your .env file, but it's a contract.

EnvShield turns your project's environment variables into a single, version-controlled schema — env.schema.toml — that describes every variable your app needs: its type, whether it's secret, what it defaults to, and when it's required. EnvShield then uses that one file to do everything that used to be manual, scattered, or forgotten:

  • Onboard a new developer in minutes with an interactive wizard, instead of a stale wiki page.
  • Catch a missing or malformed environment variable before it breaks staging — locally, in a pre-commit hook, or in CI.
  • Generate real, typed config code (pydantic-settings for Python, zod for TypeScript) instead of untyped os.getenv() calls.
  • Validate the docker-compose file or Kubernetes manifest that actually deploys your service, not just your local .env.
  • Scan for hardcoded secrets before they're committed.

It works the same way whether you have one repo with one .env file, or a monorepo with a dozen services. Everything below is free, open source (MIT), and runs entirely on your machine — EnvShield never sends your configuration or secrets anywhere.

📚 Full Documentation · 🌐 Website · 🐙 GitHub


Table of contents


The problem

If you've worked on more than one real project, this is probably familiar:

  • .env.example is two years out of date. It's missing three variables the API actually needs and still lists two nobody's used since 2024.
  • A new developer spends their first afternoon guessing. "What env vars do I need? Which ones are secret? What's a reasonable default for API_PORT?"
  • A typo in os.getenv("DATABSE_URL") returns None, and you find out at runtime, in whatever environment happens to hit that code path first.
  • Config drifts silently between local, staging, and prod. Someone adds STRIPE_API_KEY to the API service and forgets the worker also needs it. Nobody notices until a job fails.
  • A real secret gets committed because the pre-commit hook (if there is one) doesn't understand the difference between a genuine leak and the 15 intentionally-fake values already sitting in a test fixture.

None of these are exotic problems. They're the default state of a project's configuration once more than one person, one environment, or one service is involved — which is almost immediately.

EnvShield's answer: stop treating configuration as a pile of loose files that happen to agree with each other (or don't). Declare it once, as a schema, and let every other command — onboarding, validation, code generation, deployment checks, secret scanning — be driven by that one source of truth.


Installation

pip install envshield

Requires Python 3.10+. EnvShield is a standalone CLI — it doesn't need to be added to your project's own dependencies (requirements.txt, pyproject.toml, package.json, etc.) unless you want it pinned for your team; a global pip install (or pipx install envshield, if you prefer isolated CLI tools) is enough.

Verify it installed:

envshield --version

Quick start

A single service

The common case: one repo, one .env file. This is the whole workflow — nothing else in this README is required to get full value out of EnvShield.

cd my-project
envshield init                    # Detects your framework and builds env.schema.toml from your real config
envshield setup                   # Interactive wizard: fills in .env from the schema
envshield generate --lang python  # Generates a typed config.py (or config.ts for TypeScript)

init looks for a real config source first — an existing .env, .env.example, or a recognizable Python config module (config/settings.py and similar) — and builds the schema from its actual variables, classifying each as secret or not, with a suggested default and, where the value's shape is unambiguous, an inferred type. Only a genuinely fresh project with nothing to read yet falls back to a generic framework template:

Found config/settings.py -- building your schema from its real variables.
✓ Created/updated schema: env.schema.toml

init also offers to install a git pre-commit hook (secret scanning) and a post-merge hook (drift check after every git pull) — say yes unless you already have your own hook-management tooling (Husky, pre-commit, etc.), in which case EnvShield will detect it and won't clobber it (see Secret scanning and git hooks).

envshield import <file> does the same real-variable analysis init runs automatically, as its own command — reach for it later, when you want to re-import after adding new variables to your code, point at a file init wouldn't have found on its own, or add --interactive to confirm each classification by hand instead of accepting the automatic guess:

envshield import .env --interactive
Analyzing variables...

✓ Analysis complete!
- Processed 12 variables.
- Marked 4 variable(s) as secrets.
- Suggested 6 default value(s).
- Inferred a type (int/port/bool/url/email) for 3 variable(s).

Everything above is the complete single-service story. Everything below this point — multiple services, schema composition, deployment manifests with more than one container — is opt-in, and only relevant once your repo actually has more than one service. Skip straight to Core concept: the schema is the contract if that's not you yet.

A monorepo with multiple services

(Optional — skip this if you only have one service.)

If your repo has more than one service (an API, a web frontend, a worker), run service discover at the repo root instead of init:

envshield service discover
                         Discovered Services
┏━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓
┃ Name   ┃ Directory      ┃ Format ┃ Config File           ┃ Deployment Manifest  ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩
│ api    │ services/api   │ dotenv │ (default .env)        │ docker-compose.yml   │
│ web    │ services/web   │ dotenv │ (default .env)        │ docker-compose.yml   │
└────────┴────────────────┴────────┴───────────────────────┴──────────────────────┘
? Add these services to envshield.yml? Yes
✓ Registered api → services/api/env.schema.toml
✓ Registered web → services/web/env.schema.toml

One command scans for service-like directories (anything with a real .env-style file or a recognizable Python config module), registers each one in envshield.yml, seeds each schema from that service's actual current values, and — if it finds a docker-compose.yml in the service's directory or the project root — registers that too. It's additive: run it again later and it only picks up what's new, leaving already-configured services untouched.

Every command below is service-aware once you have more than one:

envshield scan --service api              # Scan API's code for undeclared vars
envshield setup --service web             # Onboard into the web service
envshield setup                           # No service given, more than one configured → "Which service? (api / web / all)"

--service is optional whenever there's exactly one service configured (it's used automatically), and prompts you to choose — or run against every service at once via "All services" — whenever there's more than one and you didn't specify.

Prefer to register a service by hand instead of relying on auto-discovery?

envshield service add api services/api --import services/api/.env
envshield service list

Core concept: the schema is the contract

Everything in EnvShield is driven by env.schema.toml — one per service, or one at the repo root for a single-service project. It's plain TOML, meant to be read and hand-edited, and it's meant to be committed to git (it declares shape, never secret values).

[DATABASE_URL]
description = "PostgreSQL connection string for the API"
secret = true

[API_PORT]
description = "Port the API listens on"
type = "port"
defaultValue = "5000"

[LOG_LEVEL]
description = "Log verbosity"
enum = ["debug", "info", "warn", "error"]
defaultValue = "info"

[ADMIN_EMAIL]
description = "Where alerts get sent"
type = "email"

[FEATURE_X_ENABLED]
description = "Toggles the new billing flow"
type = "bool"
defaultValue = "false"

[FEATURE_X_API_KEY]
description = "Only needed once feature X is turned on"
secret = true
requiredIf = { var = "FEATURE_X_ENABLED", equals = "true" }

That's the whole contract. Every command below — check, doctor, setup, generate, scan — reads this file and nothing else to know what your project's configuration is supposed to look like.

Every field a variable can have

Field Type Meaning
description string Shown during setup, and copied into generated code as documentation. Not required, but import-generated schemas leave "TODO: Add description." as a nudge to fill it in.
secret boolean Marks the variable as sensitive. Secrets are prompted as hidden input in setup, masked in generated code (SecretStr in Python, a private-field Secret<T> wrapper in TypeScript that survives console.log/JSON.stringify), and never inferred a type during import.
defaultValue string A fallback value. A variable without defaultValue is treated as required — check/doctor flag it as missing if it's absent from your local file, and setup will prompt for it.
type string One of string (the default — no shape constraint beyond pattern), int, float, bool, port (an int, 1–65535), url, email. Enforced by check/doctor/setup, and drives the type of the field in generated code.
enum list of strings The variable's value must be one of these. Implies a type of enum regardless of whatever type is also set. setup presents these as a picker instead of free text, so an invalid value can't even be typed in.
pattern string (regex) An additional constraint checked on top of whatever type is set — e.g. pattern = "^v\\d+\\.\\d+\\.\\d+$" to require a semver-shaped string.
requiredIf table { var = "OTHER_VAR", equals = "some value" } — this variable is required only when OTHER_VAR's current local value equals "some value". Without requiredIf, "required" just means "no defaultValue," exactly as before this existed.

A variable with no type/enum/pattern at all behaves exactly as it always has: an unconstrained string, required unless it has a default. Every schema written before these fields existed is still valid — nothing here is a breaking change.

A worked example — running envshield check against a .env that violates several of the constraints above:

$ envshield check
Validating .env against schema...

┌────────────────────┬──────────────────────┬────────────────────────────────────┐
│ Status               Variable Name         Source                             │
├────────────────────┼──────────────────────┼────────────────────────────────────┤
│ Missing in Local     DATABASE_URL          env.schema.toml (Required)         │
│ Invalid Value         API_PORT              must be a port number from 1-65535 │
│                                             (got '99999')                      │
│ Invalid Value         LOG_LEVEL             must be one of: debug, info, warn,  │
│                                             error (got 'verbose')              │
│ Extra in Local        OLD_UNUSED_FLAG       .env                                │
└────────────────────┴──────────────────────┴────────────────────────────────────┘

Suggestion: Please update your local file to match the schema contract.

Conditional requirements (requiredIf)

Real schemas usually have a handful of variables that are only relevant behind a feature flag or a specific mode. Marking them as required unconditionally means every developer has to fill in a value they don't need yet; marking them optional means nobody gets warned when the flag flips on in an environment that's missing the value.

requiredIf splits the difference:

[FEATURE_X_ENABLED]
type = "bool"
defaultValue = "false"

[FEATURE_X_API_KEY]
secret = true
requiredIf = { var = "FEATURE_X_ENABLED", equals = "true" }

With FEATURE_X_ENABLED=false, FEATURE_X_API_KEY is optional — setup won't prompt for it, and check/doctor won't flag it missing. Flip FEATURE_X_ENABLED=true in any environment, and it immediately becomes required there.

Where this doesn't reach (by design): generated config code can't evaluate requiredIf ahead of time — it doesn't know what FEATURE_X_ENABLED will be at runtime when it's generated. A requiredIf field with no default is typed as optional (Optional[SecretStr] = None in Python, .optional() in the zod schema); the real, conditional enforcement happens in check/doctor/setup against your project's actual local values, not in the generated code itself.

Sharing variables across services (extends)

(Monorepo-only — skip this if you have one service.)

A monorepo with ten services usually has five or six variables every single one of them needs — LOG_LEVEL, SENTRY_DSN, DATADOG_API_KEY — and copy-pasting the same [LOG_LEVEL] block into ten schema files is exactly the kind of drift EnvShield exists to prevent.

Factor them into a shared base schema, and have each service extend it:

# shared/base.schema.toml
[LOG_LEVEL]
description = "Log verbosity, shared across every service"
enum = ["debug", "info", "warn", "error"]
defaultValue = "info"

[SENTRY_DSN]
description = "Error tracking"
secret = true
# services/api/env.schema.toml
extends = "../../shared/base.schema.toml"

[DATABASE_URL]
description = "API-specific"
secret = true

Loading services/api/env.schema.toml now transparently gives you LOG_LEVEL, SENTRY_DSN, and DATABASE_URL — every command (check, doctor, setup, generate, scan) just sees the merged result, no extra flag needed. A few things worth knowing:

  • extends accepts a list, for more than one base: extends = ["../../shared/base.schema.toml", "../../shared/observability.schema.toml"].
  • Chains work: a schema can extend a base that itself extends another base. A circular chain (A extends B extends A) is detected and rejected with a clear error rather than hanging.
  • The child always wins. If both the base and the service redeclare LOG_LEVEL, the service's own definition is used in full — fields aren't merged individually. If you override a shared variable, redeclare every field you want it to have, not just the one you're changing.
  • Paths are local, not remote (for now). extends resolves a path relative to the schema file's own directory, and only within your project — it does not fetch a schema from a git URL or a package registry. Sharing a base schema across separate repositories isn't supported yet; see Roadmap.

Command reference

Core commands

Everything a single-service project ever needs. --service shows up on most of these for the multi-service case (see below), but it's entirely optional until you actually have more than one service.

Command What it does
envshield init [--force/-f] Detects your framework and builds env.schema.toml from a real config source if it finds one, otherwise a framework-aware template. Also scaffolds envshield.yml, updates .gitignore, and offers to install git hooks. Auto-registers a root-level docker-compose.yml as the project's deployment manifest if it finds one. --force re-runs on a project that already has a config (with a confirmation before overwriting).
envshield import <file> [--output/-o PATH] [--force/-f] [--interactive] [--service NAME] Runs the same real-variable analysis init does automatically, as its own command — for re-importing after your code gains new variables, pointing at a file init wouldn't have found, or adding --interactive to confirm each secret/type classification by hand instead of accepting the automatic guess. --output changes where the schema is written (defaults to env.schema.toml, or the target service's schema path with --service).
envshield check [file] [--service NAME] [--container NAME] Validates a local file (or, if omitted, the project's/service's default local file and its registered deployment manifest, if any) against the schema. file can be a plain .env, a Python config module, a docker-compose file, or a Kubernetes manifest. --container picks which service/container to check in a manifest that declares more than one (tried against --service's name automatically first). Exits non-zero on any drift — safe to use as a CI gate.
envshield doctor [--fix] [--service NAME] Runs every health check at once (see below) and reports a summary. --fix interactively offers to fix whatever it can — re-running init, regenerating the template, installing the git hook, or running setup to fill in missing/invalid local values. Exits non-zero if anything's still broken afterward.
envshield setup [output_file] [--service NAME] Interactive onboarding wizard: walks through every variable that's missing, blank, or has an existing value the schema no longer allows, prompting with the variable's description, masking secret input, and offering a picker for enum fields. Leaves everything already correct untouched.
envshield schema sync [--service NAME] Regenerates .env.example from the schema (a dotenv project), or patches a Python-module local file in place to declare any schema variable it's missing (never rewrites it wholesale — only appends/patches the specific lines it owns). import already calls this automatically for you when it changes a project's/service's real schema, so you'll rarely need to run it by hand except after a manual schema edit.
envshield generate [output_file] [--lang/-l python|typescript] [--force/-f] [--service NAME] Compiles the schema into a typed, validated config module. --lang is auto-detected from your project (Next.js/Vite/Node.js → TypeScript, everything else → Python) if omitted. Defaults to writing config.py/config.ts; --force overwrites an existing output file. See Typed config code generation.
envshield scan [paths...] [--staged] [--config/-c PATH] [--exclude/-e PATTERN] [--service NAME] Scans code for hardcoded secrets and for env vars used in code (os.getenv, os.environ.get, process.env.X) but never declared in the schema. --staged scans only what's staged for the next commit (what the pre-commit hook runs); --exclude (repeatable) adds glob patterns to skip, on top of whatever secret_scanning.exclude_files is set in envshield.yml. See Secret scanning and git hooks.
envshield install-hook Installs both git hooks by hand, without going through init/setup/service discover's interactive prompt.
envshield --version / -v Prints the installed version and exits.

Not using a .env file at all? Some projects (a Flask app whose local config is a checked-in Python module, for example) don't use dotenv at all. Point local_file at it instead, and EnvShield reads and writes it as source code, not as a dotenv file — appending or patching only the specific assignments it owns, never touching anything else in the file:

services:
  athena:
    path: athena/env.schema.toml
    local_file: athena/config/env_config.local.py

Monorepo: managing multiple services

(Only relevant once your repo has more than one service — see A monorepo with multiple services.) Every core command above already accepts --service: automatically, if there's only one service configured; interactively (or against every service at once, via "All services"), if there's more than one.

Command What it does
envshield service list Lists every service currently configured in envshield.yml, with its schema and local file paths.
envshield service add <name> <directory> [--local-file PATH] [--example-file PATH] [--description/-d TEXT] [--schema PATH] [--import FILE] [--deployment-manifest PATH] [--container NAME] Registers one service by hand. --local-file is required when the service's real config isn't a dotenv file (e.g. a Python module) — see above. --import seeds the new service's schema from an existing config file in one step. --deployment-manifest is auto-detected (a compose file in the given directory or the project root that actually declares this service) if not given explicitly.
envshield service discover [root] [--yes/-y] Scans for service-like directories not already registered, and offers to add them — see Quick start. --yes skips the interactive confirmation, for CI/scripting.

Typed config code generation

Stop writing os.getenv("DATABASE_URL") — untyped, unvalidated, and silently None on a typo — and generate a real module instead:

envshield generate --lang python
"""
AUTO-GENERATED by `envshield generate` — do not edit by hand.
Source of truth: env.schema.toml. Regenerate with: envshield generate

Requires: pip install pydantic pydantic-settings
"""
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Typed, validated access to this project's environment variables."""

    model_config = SettingsConfigDict(
        env_file=".env", populate_by_name=True, extra="ignore"
    )

    database_url: SecretStr = Field(
        ..., description="PostgreSQL connection string for the API", alias="DATABASE_URL"
    )

    api_port: int = Field(
        '5000', description="Port the API listens on", alias="API_PORT", ge=1, le=65535
    )


settings = Settings()
from config import settings

db = psycopg2.connect(settings.database_url.get_secret_value())  # SecretStr — masked in logs/reprs
port = settings.api_port  # a real int, validated 1-65535 on startup — not a string

The same schema compiles to TypeScript with --lang typescript (or automatically, in a Next.js/Vite/Node.js project):

import { env } from './config';

const port: number = env.API_PORT;               // z.coerce.number().min(1).max(65535)
const db = await connect(env.DATABASE_URL.value); // Secret<string> — masked on console.log/JSON.stringify

Secrets are masked by construction, not by convention: Python's SecretStr and the generated TypeScript Secret<T> wrapper (a real private field, not TypeScript's compile-time-only private keyword) both prevent the value from appearing in a log line, a console.log, or a stack trace by accident.

Requirements for the generated code, not for EnvShield itself: the Python output needs pydantic and pydantic-settings in your project; if any field has type = "email", it needs pydantic[email] too. The TypeScript output needs zod. EnvShield only generates the file — it doesn't install these for you.


Validating deployment manifests

Config drift rarely shows up in a .env file in production — it shows up in whatever actually deploys the service: a docker-compose file, a Kubernetes Deployment. envshield check validates those directly, the same way it validates a .env file:

envshield check docker-compose.yml
envshield check k8s/deployment.yaml --container api

docker-compose:

services:
  api:
    image: myorg/api
    environment:
      - DATABASE_URL=postgres://user:pass@db/app
    env_file:
      - .env.production

EnvShield merges environment: with whatever env_file: references (environment: wins on a conflict, matching Compose's own precedence). A bare KEY with no value, or anything sourced only from env_file, is treated as "present, value not visible in this file" rather than flagged missing — the real value legitimately lives outside the manifest.

Kubernetes: Deployment, StatefulSet, DaemonSet, Job, CronJob, and bare Pod manifests are all supported, including multi-document files (----separated). A ConfigMap/Secret referenced via envFrom is resolved if it's defined in the same file; a valueFrom reference (or an unresolvable envFrom) is treated the same way as compose's env_file case — present, value not visible here.

Multiple services/containers in one file? --container picks which one. If you don't pass it, EnvShield tries your --service name first (services and containers are very often named identically) before asking you to be explicit:

envshield check docker-compose.yml --service api --container api-backend

Register it once, stop typing the path. service discover/service add/init auto-detect a compose file and register it as that service's (or the project's) deployment manifest. Once registered, envshield check (with no file argument) validates it automatically alongside your .env, and doctor gains a "Deployment Manifest" health check — only shown for projects that actually have one registered.

envshield service add api services/api --deployment-manifest docker-compose.yml --container api

What this doesn't do (by design): EnvShield only reads deployment manifests — it never generates or rewrites one. Safely patching a real Kubernetes YAML file while preserving everything else in it is a meaningfully bigger, riskier problem than validating it, and isn't something this tool does yet.


Secret scanning and git hooks

envshield scan looks for two different problems at once: hardcoded secrets, and environment variables your code reads but the schema never declared.

envshield scan                 # Scan the current directory recursively
envshield scan --staged        # Scan only what's staged for the next commit (what the pre-commit hook runs)
envshield scan app/ --exclude "**/tests/*"
🚨 DANGER: Found 1 potential secret(s)!
┌──────────────┬──────┬──────────────────┬─────────────────────────────────────┐
│ File         │ Line │ Secret Type      │ Line Content                        │
├──────────────┼──────┼──────────────────┼─────────────────────────────────────┤
│ config.py    │ 12   │ Stripe Secret Key│ STRIPE_KEY = 'sk_live_abc123...'     │
└──────────────┴──────┴──────────────────┴─────────────────────────────────────┘

⚠️  WARNING: Found 1 undeclared variable(s)!
┌──────────┬──────┬────────────────────┐
│ File     │ Line │ Variable Name      │
├──────────┼──────┼────────────────────┤
│ app.py   │ 8    │ ANALYTICS_KEY      │
└──────────┴──────┴────────────────────┘

Commit aborted. Please fix the issues above before committing.

Detection recognizes framework "intentionally public" naming conventions (NEXT_PUBLIC_*, VITE_*, REACT_APP_*, NUXT_PUBLIC_*, GATSBY_*, dotenvx's DOTENV_PUBLIC_KEY) and never flags them as secrets on name alone — a Stripe publishable key is meant to ship in client-side code. A genuinely secret-shaped value under one of those names is still caught; only the naming-convention false positive is suppressed. node_modules, .git, virtualenvs, and build output are excluded from scans by default.

Diff-aware scanning for files with intentional baseline secrets

Some projects check in a config file with intentionally fake secrets for local dev (a shared team fixture, say) and exclude that file from scanning via secret_scanning.exclude_files in envshield.yml. A plain exclusion means a real secret added to that same file later would never be caught either. scan --staged handles this with line-level intelligence: it diffs an excluded file's staged content against HEAD and scans only the newly-added lines, so pre-existing baseline values are ignored but anything genuinely new is still checked.

# envshield.yml
secret_scanning:
  exclude_files:
    - "config/dev_fixtures.py"
ℹ️  config/dev_fixtures.py (excluded; diffs only: 1 new line(s))
🚨 DANGER: Found 1 potential secret(s)!
Line 47: PRODUCTION_SECRET = 'a_real_secret_that_just_got_added'

Git hooks

envshield install-hook (or the interactive prompt during init/setup/service discover) installs two hooks:

  • pre-commit — runs envshield scan --staged, aborting the commit if it finds anything.
  • post-merge — runs envshield doctor after every git pull/merge, but only when a schema file actually changed in that merge, so a teammate who just added a new required variable gets alerted immediately instead of finding out the next time the app crashes.

Hooks respect a configured core.hooksPath (as set by Husky or similar tools) instead of assuming .git/hooks, and installing over an existing hook always tells you first whether that hook was EnvShield's own (safe to replace) or something else (naming how many lines of unrelated logic would be lost) before asking you to confirm.


Setting up EnvShield for your project

A few concrete starting points, depending on what you're working with:

A brand-new project. envshield init in the project root. It detects your framework (Next.js, Vite, Django, Flask, or a generic default) and scaffolds a schema with a handful of sensible starting variables for that stack. Add your own on top, run envshield setup to create your local .env, and envshield generate once you're ready for typed config code.

An existing project with a real .env. envshield import .env instead of init — it reads your actual values and does most of the schema-writing for you. Review the output (it leaves "TODO: Add description." on every variable as a deliberate nudge), fill in descriptions, and adjust any secret/type classification it got wrong before committing the schema.

An existing Django/Flask project whose config is a Python module, not a dotenv file. envshield import config/settings.py works the same way — the parser reads top-level variable assignments via Python's AST (never executes the file). Register it with local_file pointing at that module so setup/schema sync patch it in place instead of trying to write a .env you don't actually use.

A monorepo you're adopting EnvShield into for the first time. envshield service discover at the repo root, not init in each service directory — it finds every service in one pass and seeds each schema from that service's real config. Run it again any time a new service is added; already-configured services are never touched or re-suggested.

A project deployed via docker-compose or Kubernetes. Register the manifest once — service add ... --deployment-manifest docker-compose.yml, or let init/service discover find it automatically — and check/doctor start validating it immediately, with no change to how you deploy.

A team of more than a couple of people. Install the git hooks (say yes when prompted, or run install-hook). Add envshield check (or envshield doctor) as a CI step so drift fails a pull request instead of a deploy — see the next section.


Maintaining EnvShield over time

Setting EnvShield up once is the easy part. What actually keeps a schema trustworthy for the life of a project:

Make check a CI gate, not just a local habit. A GitHub Actions step is enough — no special integration required:

- name: Validate environment configuration
  run: |
    pip install envshield
    envshield check
    # For a multi-service project, run it per-service, or loop over `envshield service list`.

This catches the exact failure mode EnvShield exists to prevent: a pull request that adds code reading a new environment variable without adding that variable to the schema, or a schema that's drifted out of sync with .env.example. check and doctor both exit non-zero on any drift.

Whenever you add a new environment variable, add it to the schema in the same commit, not after. The pre-commit hook's scan --staged will actually catch you here — a newly-used, undeclared variable shows up as a warning before you can commit it. Treat that warning as the schema reminding you, not as noise to dismiss.

Run envshield doctor after pulling, not just when something's already broken. The post-merge hook does this automatically whenever a schema file changed in the merge — if you skipped installing hooks initially, envshield install-hook takes thirty seconds and pays for itself the first time a teammate adds a required variable without telling anyone.

When a shared variable changes, update the base schema once, not every service that extends it. If you're using extends (see Sharing variables across services), a change to LOG_LEVEL's description or default belongs in the base schema — every service extending it picks it up automatically, with nothing to keep in sync by hand.

Re-run envshield doctor --fix instead of hand-editing your local .env when it's flagged as broken. It delegates to the same setup wizard used for onboarding, so it fixes exactly what's wrong (missing, blank, or invalid values) and leaves everything else untouched.

Keep EnvShield itself current. pip install --upgrade envshield. Schema files written by an older version remain fully valid — every field documented above is additive, not a breaking change to the format.


How EnvShield compares

EnvShield isn't trying to replace a dedicated secret scanner or a cloud secret manager — it's a different, complementary layer: the schema/contract/codegen layer that sits on top of (or alongside) whichever of those you already use.

EnvShield Gitleaks dotenvx Infisical / Doppler direnv
Schema-driven validation (types, enums, conditional requirements)
Typed config code generation (Python, TypeScript)
Validates deployment manifests (docker-compose, Kubernetes)
Multi-service schema management, with shared/composed schemas Partial (multi-environment, not a documented shared contract)
Interactive onboarding wizard ✅ (hosted)
Secret detection in code/commits ✅ (good enough for most teams) ✅ (more detector rules, actively maintained by a dedicated team) Varies by plan
Stores or syncs actual secret values across a team ❌ (never touches real values) Encrypts values in the file
Works fully offline Depends on the tier/self-hosting
Free / open source ✅ (all of it, indefinitely) Free tier + paid tiers

Where each of these genuinely is a better choice than EnvShield today: Gitleaks and similar dedicated scanners have years of tuned detector rules and are actively maintained specifically for detection accuracy — if secret-scanning coverage is your main concern, run one of them alongside EnvShield rather than relying on scan alone. Doppler and Infisical solve real-value distribution and multi-environment secret sync across a team, which EnvShield deliberately doesn't attempt (see the row above) — if that's your primary need, they're the right tool. direnv solves a different problem entirely (auto-loading env vars into your shell on cd) and isn't a substitute for schema validation or vice versa.

Where EnvShield is the only thing in this table that does it at all: one schema that's simultaneously documentation, a validation contract, a codegen input for two languages, and something your deployment manifests are checked against — kept in git, next to the code it configures, with nothing to host.


Troubleshooting / FAQ

check/doctor say a manifest declares "multiple services" / "multiple containers." Pass --container <name> explicitly, or register the manifest with service add --deployment-manifest ... --container <name> so you never have to pass it again.

My pre-commit hook doesn't seem to run. Check git config core.hooksPath — if it's set (Husky sets this), EnvShield installs there instead of .git/hooks, but if the hook was installed by an older EnvShield version before that was supported, re-run envshield install-hook. envshield doctor includes a "Git Pre-commit Hook" check that catches this.

scan is flagging something that isn't a secret, or missing something that is. The scanner is regex/entropy-based, not a machine-learning classifier — it's tuned to be broadly useful, not perfect for every codebase. For a false positive, add a targeted --exclude glob or a secret_scanning.exclude_files entry. For a miss, please open an issue with the (redacted) pattern that slipped through — and if secret-detection accuracy specifically is your priority, consider running Gitleaks or a similar dedicated scanner alongside scan rather than relying on it alone.

Can I use EnvShield without git hooks? Yes — every command works standalone. Hooks are a convenience, not a requirement; decline the prompt (or never run install-hook) if your team manages hooks another way.

Does EnvShield ever send my configuration or secrets anywhere? No. Every command reads and writes local files. There is no telemetry, no network calls, and no cloud backend in the current release.

What happens to my schema if I stop using EnvShield? Nothing — env.schema.toml is plain TOML and your .env files are plain dotenv files. Neither is EnvShield-proprietary; both remain exactly as useful (as documentation, if nothing else) with the tool uninstalled.


Roadmap

Available now, free, forever: everything documented above — schema management, typed codegen, deployment-manifest validation, schema composition, secret scanning, onboarding, and multi-service support. None of this is time-limited or moves behind a paywall later.

Being explored, not yet built — no committed timeline:

  • A hosted way to share actual secret values and coordinate per-environment (dev/staging/prod) overrides across a team — the one thing this README is explicit about EnvShield not doing today.
  • A shared/base schema referenced across separate repositories (today's extends is local-path-only, within one project — see Sharing variables across services).
  • Deeper integration with existing secret managers (Vault, AWS/GCP Secrets Manager) — pulling real values for local setup/check without EnvShield ever storing them itself.

If any of the above would matter to you, open a discussion — what actually gets built next is driven by what real projects hit first, not a fixed plan.


Community

Questions? Ideas? Found a bug?

Contributing

See CONTRIBUTING.md.

License

MIT — see LICENSE.md. Use it freely, in any project.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

envshield-4.3.0.tar.gz (132.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

envshield-4.3.0-py3-none-any.whl (125.5 kB view details)

Uploaded Python 3

File details

Details for the file envshield-4.3.0.tar.gz.

File metadata

  • Download URL: envshield-4.3.0.tar.gz
  • Upload date:
  • Size: 132.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for envshield-4.3.0.tar.gz
Algorithm Hash digest
SHA256 ccd7df5167b9d14546d28b68ceceb2e3e8b8f4d57378f43763752d152f722193
MD5 d870eb60249a9f37e1685d1de6a4bd18
BLAKE2b-256 300b1e74fa5fce6c7ae1cc940c6dad5617e5bf319907096c1e6a97522cf7feb6

See more details on using hashes here.

Provenance

The following attestation bundles were made for envshield-4.3.0.tar.gz:

Publisher: publish.yml on rabbilyasar/envshield

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file envshield-4.3.0-py3-none-any.whl.

File metadata

  • Download URL: envshield-4.3.0-py3-none-any.whl
  • Upload date:
  • Size: 125.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for envshield-4.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7ee79681c0aab595f1f2582a9fc09773c3c73a5c47b68c041153c4923c188676
MD5 a995f27e817e6b375108b11d08a7fcf6
BLAKE2b-256 d0c43c876ccfdadf9d67faf94b098bc198ffae556b614f3fc310193569f71829

See more details on using hashes here.

Provenance

The following attestation bundles were made for envshield-4.3.0-py3-none-any.whl:

Publisher: publish.yml on rabbilyasar/envshield

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

4.7.1

2 files

4.7.0

2 files

4.6.2

2 files

4.6.1

2 files

4.6.0

2 files

4.5.1

2 files

4.5.0

2 files

4.4.0

2 files

This release

4.3.0 This release

2 files

4.2.0

2 files

4.1.0

2 files

4.0.1

2 files

4.0.0

2 files

3.1.1

2 files

3.1.0

2 files

3.0.0

2 files

2.1.0

2 files

2.0.1

2 files

2.0.0

2 files

1.4.0

2 files

1.2.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page