This document is a generic specification for managing configuration, secrets, and workflows in Python-based projects.
It is designed to work with:
- Pure Python apps (CLI tools, workers, etc.)
- Web services (FastAPI, Django, Flask, etc.)
- Any stack using Docker, CI/CD (e.g. GitLab), and optionally Kubernetes
The document is intended both for humans (to discuss and agree on a workflow) and for AI coding agents (to implement it consistently across projects).
-
Single source of truth per environment
Each environment (local, staging, production, etc.) is described exactly once via an encrypted.env.<name>file. -
Same configuration model everywhere
The same env files are used by:- Local development (host virtualenv)
- Local development (Docker / docker-compose)
- CI jobs
- Deployment pipelines (e.g. Kubernetes)
-
dotenvx only at the edges
- dotenvx is used to hydrate environment variables for commands.
- Runtime containers and deployed apps do not depend on dotenvx or plaintext
.envfiles.
-
No secret duplication
Secrets are defined once in.env.<name>and then propagated through tools (Docker, CI, Kubernetes) as environment variables or secrets—not copy-pasted into multiple configs. -
Support for multiple workflows
- Local host workflow (virtualenv,
uvorpip) - Container-based workflow (Docker / docker-compose)
- Both use the same
.envsources.
- Local host workflow (virtualenv,
Pick a small, explicit set of environment names for the project, e.g.:
local– developer machine (pure host, no Docker required).dev– shared development environment (optional).staging– pre-production / UAT.production– live environment.
You can add more (e.g. preview, performance) as needed. Every environment name must map to a corresponding .env.<name> file.
For each environment <name>, define:
.env.<name>– encrypted, committed to git, managed by dotenvx.
In addition, define:
.env– local overrides, not tracked in git..env.keys– dotenvx key material, not tracked in git..env.example– non-secret example values for documentation / onboarding.
Summary table:
| File | Tracked | Encrypted | Purpose |
|---|---|---|---|
.env |
no | no | Developer-specific overrides (hostnames, local tweaks). |
.env.local |
yes | yes | Defaults for local dev / unit tests (fake or low-value secrets). |
.env.dev |
yes | yes | Shared development env (optional). |
.env.staging |
yes | yes | Staging environment configuration + secrets. |
.env.production |
yes | yes | Production configuration + secrets. |
.env.keys |
no | n/a | dotenvx key(s), used to decrypt encrypted env files. |
.env.example |
yes | no | Example values: for humans, not used by tooling. |
Note: If you use a different naming convention (e.g.
.env.sandboxinstead of.env.production), adapt the names but keep the pattern: one encrypted file per environment.
When running commands, env files are applied in a deterministic order:
-
Local development (host or Docker):
.env(local overrides).env.local(baseline dev config)
-
Shared dev environment:
.env(local overrides, if applicable).env.dev
-
Staging:
.env.staging(no.env) -
Production:
.env.production(no.env)
Command pattern (local dev):
dotenvx run -f .env -f .env.local -- <command>Command pattern (staging, CI):
dotenvx run -f .env.staging -- <command>Only these .env.* files are considered the source of truth for configuration and secrets.
Use a single top-level environment variable to describe the deployment environment:
- Name:
APP_ENV(orAPP_DEPLOYMENT_ENVif the project already uses that). - Example values:
local,dev,staging,production.
This variable is used by:
- Application configuration (e.g. to choose logging levels, toggle debug features).
- Docker builds (e.g. whether to install dev toolchains).
- CI/CD pipelines (e.g. to pick which env file to use).
Follow these naming patterns for environment variables:
- Uppercase, snake_case names.
- Prefix by concern / subsystem, e.g.:
APP_– app-level settings (APP_ENV,APP_DEBUG,APP_LOG_LEVEL).DB_– database connection (DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORD).CACHE_– cache stores (CACHE_URL,CACHE_TIMEOUT).BROKER_– message broker (BROKER_URL).AUTH_– authentication-related (AUTH_JWT_SECRET,AUTH_JWT_ALGORITHM).THIRD_PARTY_X_– anything talking to external services.
These variables should be documented in .env.example with non-sensitive placeholder values.
Example:
APP_ENV=local
APP_DEBUG=true
APP_LOG_LEVEL=DEBUG
DB_HOST=localhost
DB_PORT=5432
DB_NAME=app
DB_USER=app
DB_PASSWORD=changeme
CACHE_URL=redis://localhost:6379/0
AUTH_JWT_SECRET=changeme
AUTH_JWT_ALGORITHM=HS256.env.example is for humans and onboarding; it is not used at runtime.
Any Python project (FastAPI, Django, pure Python) can adopt a common pattern for configuration.
Example with Pydantic Settings (optional but recommended):
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_env: str = 'local'
app_debug: bool = False
app_log_level: str = 'INFO'
db_host: str
db_port: int
db_name: str
db_user: str
db_password: str
cache_url: str | None = None
auth_jwt_secret: str
auth_jwt_algorithm: str = 'HS256'
class Config:
env_prefix = ''
case_sensitive = FalseUsage in application:
settings = Settings()For a Django project, this pattern can be used inside settings.py (or settings/ package) to populate Django settings from environment variables.
For CLI or worker processes, reuse the same Settings class to ensure consistency.
To avoid repeating long dotenvx run commands everywhere, define reusable macros in Makefile.
At the top of Makefile:
ENV_FILES_LOCAL := .env .env.local
ENV_FILES_DEV := .env.dev
ENV_FILES_STAGING := .env.staging
ENV_FILES_PRODUCTION := .env.production
DOTENVX_LOCAL := dotenvx run $(addprefix -f ,$(ENV_FILES_LOCAL)) --
DOTENVX_DEV := dotenvx run $(addprefix -f ,$(ENV_FILES_DEV)) --
DOTENVX_STAGING := dotenvx run $(addprefix -f ,$(ENV_FILES_STAGING)) --
DOTENVX_PRODUCTION := dotenvx run $(addprefix -f ,$(ENV_FILES_PRODUCTION)) --You can adjust which environments you actually use (e.g. drop dev if not relevant).
Examples for a Python app using uv and pytest (adapt as needed):
.PHONY: env-local
env-local:
$(DOTENVX_LOCAL) uv sync --dev
.PHONY: run-local
run-local:
$(DOTENVX_LOCAL) uv run python -m app
.PHONY: test-unit
test-unit:
$(DOTENVX_LOCAL) uv run pytest -m 'not integration'
.PHONY: test-integration-staging
test-integration-staging:
$(DOTENVX_STAGING) uv run pytest -m 'integration'If the project uses pip instead of uv, replace the commands but keep the dotenvx pattern.
For projects using Docker, adopt a single multi-stage Dockerfile with environment-controlled behavior.
basestage – installs Python, package manager (e.g.uv), and common OS deps.builderstage – installs dependencies, builds wheels, runs migrations or compile steps as needed.runtimestage – minimal image for running the application.
Use a build argument to control the environment behavior:
ARG APP_ENV=production
ENV APP_ENV=${APP_ENV}In the runtime stage, you may optionally install extra tooling only for non-production builds:
RUN set -e; if [ "$APP_ENV" != "production" ]; then apt-get update && apt-get install -y --no-install-recommends build-essential python3-dev; apt-get clean && rm -rf /var/lib/apt/lists/*; fiKey rules:
- The final runtime image must not contain:
- dotenvx binaries
.env.*files (encrypted or plaintext)- secret key files for dotenvx
- The application reads configuration only from environment variables provided by Docker / Kubernetes.
If the project uses private Python indexes (via pip or uv) or other package sources:
- Define credential env vars in
.env.<name>, e.g.:PIP_INDEX_URL,PIP_EXTRA_INDEX_URL, orUV_INDEX_<INDEX_NAME>_USERNAME,UV_INDEX_<INDEX_NAME>_PASSWORD
- Pass them through as build args from the current environment.
Example build command pattern:
dotenvx run -f .env.production -- docker build --build-arg UV_INDEX_INTERNAL_USERNAME --build-arg UV_INDEX_INTERNAL_PASSWORD -t app-image:production .The Dockerfile declares corresponding ARG entries in the builder stage and uses them during dependency installation. The values do not persist as environment variables in the runtime image.
For projects using docker-compose locally, the compose file should:
- Define variable names, not secret values.
- Use
${VAR_NAME}interpolation to pull values from the environment. - Avoid hardcoding real credentials; keep those in
.env.*.
services:
app:
build:
context: .
args:
APP_ENV: local
environment:
APP_ENV: local
DB_HOST: '${DB_HOST}'
DB_PORT: '${DB_PORT}'
DB_NAME: '${DB_NAME}'
DB_USER: '${DB_USER}'
DB_PASSWORD: '${DB_PASSWORD}'
CACHE_URL: '${CACHE_URL}'
AUTH_JWT_SECRET: '${AUTH_JWT_SECRET}'
AUTH_JWT_ALGORITHM: '${AUTH_JWT_ALGORITHM}'
volumes:
- ./:/app
ports:
- '8000:8000'When starting the stack for local dev, you always go through dotenvx:
dotenvx run -f .env -f .env.local -- docker compose up appCompose reads environment variables from its own process environment (hydrated by dotenvx), interpolates ${VAR}, and injects them into containers.
Testing is split by dependency level, each mapped to specific environment files.
- Do not rely on external network services (DB, cache, message broker, etc.).
- Use
.env.localfor configuration (plus.envfor local overrides if needed). - Run frequently (pre-commit, CI on every push).
Example command (host):
dotenvx run -f .env -f .env.local -- uv run pytest -m 'not integration'- Talk to real services (a real DB, real cache, real external APIs, etc.).
- Use environment-specific
.env.<name>files (e.g..env.staging). - Run less frequently (on certain branches, nightly, or manually triggered).
Example command (host or CI):
dotenvx run -f .env.staging -- uv run pytest -m integrationThe specifics of how tests are marked (@pytest.mark.integration, etc.) can be project-specific, but the environment mapping should always follow this pattern.
For full stack / browser tests, follow the same idea:
.env.stagingor.env.productionfor the deployed app.- Additional env vars for test tooling as needed.
This section describes how to use dotenvx in continuous integration and how to propagate secrets into deployed environments.
In your CI system (e.g. GitLab CI):
- Configure dotenvx key material as secure CI variables (e.g.
DOTENVX_KEYor equivalent). - The CI runner must be able to install and run dotenvx according to the project’s standard.
Each job that needs application configuration or secrets runs commands via dotenvx, using the appropriate .env.<name> file.
Examples:
-
Unit tests job (local dev config):
dotenvx run -f .env.local -- uv sync --dev dotenvx run -f .env.local -- uv run pytest -m 'not integration' -
Integration tests job (staging config):
dotenvx run -f .env.staging -- uv sync dotenvx run -f .env.staging -- uv run pytest -m integration
-
Build image job (production config for private package auth):
dotenvx run -f .env.production -- docker build --build-arg UV_INDEX_INTERNAL_USERNAME --build-arg UV_INDEX_INTERNAL_PASSWORD -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
Deployment targets (like Kubernetes) do not receive encrypted .env files. Instead:
- CI runs a deploy job with dotenvx for the target environment (e.g.
.env.staging). - That job reads env vars from its own environment (already hydrated by dotenvx).
- It maps those vars into:
- Kubernetes
Secrets (for sensitive values). - Kubernetes
ConfigMaps or inline env vars (for non-sensitive config).
- Kubernetes
A simple pattern is to maintain an allowlist of environment variable names intended for the application, e.g. deploy/env.allowlist:
APP_ENV
APP_DEBUG
APP_LOG_LEVEL
DB_HOST
DB_PORT
DB_NAME
DB_USER
DB_PASSWORD
CACHE_URL
AUTH_JWT_SECRET
AUTH_JWT_ALGORITHM
A small script (bash or Python) can:
- Read
deploy/env.allowlist. - For each entry, read the variable from the current environment.
- Generate a Kubernetes
Secretmanifest or runkubectl create secret genericwith--from-literalpairs.
The application Deployment then references that secret:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
template:
spec:
containers:
- name: app
image: registry.example.com/app:{{ .Values.imageTag }}
envFrom:
- secretRef:
name: app-envThe exact tooling (raw kubectl, Helm, Kustomize, etc.) can vary, but the rule remains: dotenvx only runs in CI, not in the cluster.
To apply this template to a new or existing project:
- Choose environment names and create corresponding
.env.<name>files. - Encrypt them with dotenvx and commit the encrypted versions.
- Create
.env.examplelisting all expected vars with safe placeholder values. - Introduce Makefile macros for dotenvx (
DOTENVX_LOCAL,DOTENVX_STAGING, etc.). - Update local workflows (run server, tests, scripts) to go through dotenvx.
- Update Dockerfile (if used) to:
- Use
APP_ENVas a build arg. - Avoid embedding dotenvx or
.env.*in the runtime image.
- Use
- Update docker-compose.yml (if used) to only reference
${VAR_NAME}and never hardcode secrets. - Update CI pipeline to:
- Install dotenvx.
- Run relevant jobs with
dotenvx run -f .env.<name> -- .... - Build images using env-driven build args for private indexes.
- Add deployment scripts/manifests that create/update Kubernetes Secrets or equivalent from CI env vars, using an allowlist.
- Document the chosen environment names and patterns for the team so everyone understands the configuration model.
Once this template is instantiated, new services (web APIs, background workers, etc.) can be added without redesigning secrets and environment workflows—they simply subscribe to the same conventions.
Managing .env files and secrets across local / dev / staging / prod is where most teams quietly give up and turn their project into a config graveyard.
Secrets duplicated in CI, Docker, and K8s
.env files drifting out of sync
“Just run this 200-char dotenvx command bro”
Nobody remembers how to onboard a new dev without a 30-minute voice call
So I wrote a universal, ready-to-use spec for env & secrets management: one model that works for any Python project (FastAPI, Django, CLI), with:
dotenvx
uv
docker compose
make
optional Kubernetes / CI integration
Curious: what is your way of managing .env files and secrets across environments?