Environment configuration cascade manager for .env files.
dotconfig assembles a single .env file from multiple layered source
files — public config, SOPS-encrypted secrets, and per-developer local
overrides — and can round-trip it back. It is designed for teams where:
- Different developers have different local settings (Docker context, domain names, key paths).
- Secrets live in SOPS-encrypted files alongside public config.
- Multiple named environments (dev, prod, test, staging, CI, …) share the same layout.
- Every tool (Docker, dotenv, IDEs) still reads a single
.envfile.
- Installation
- Quick start
- Directory structure
- Generated
.envformat - Commands
- SOPS integration
- Workflow
- Adding a new deployment
- Adding a new developer
- Design decisions
Install with pipx so the tool is globally available without polluting any project's virtual environment:
pipx install dotconfigOr into a project's own virtual environment:
pip install dotconfigVerify the install:
dotconfig --versionyour-project/
config/
sops.yaml ← SOPS encryption rules (generated by dotconfig init)
dev/
public.env ← create this (public dev vars)
secrets.env ← SOPS-encrypted dev secrets
prod/
public.env ← create this (public prod vars)
secrets.env ← SOPS-encrypted prod secrets
local/
yourname/
public.env ← create this (your personal overrides)
secrets.env ← optional personal encrypted secrets
.env ← generated; do not edit directly (add to .gitignore)
Load config into .env:
dotconfig load -d dev -l yourname # dev deployment + your local overrides
dotconfig load -d prod # prod deployment, no local overridesEdit .env directly if you need to tweak a value, then save it
back to the source files:
dotconfig saveLoad/save specific files (YAML, JSON, etc.):
dotconfig load -d dev --file app.yaml --stdout # print to stdout
dotconfig save -d dev --file app.yaml # store into config/dev/config/
sops.yaml # SOPS encryption rules (non-dotfile, in config/)
dev/
public.env # Public config for the "dev" deployment
secrets.env # SOPS-encrypted secrets for "dev"
prod/
public.env # Public config for the "prod" deployment
secrets.env # SOPS-encrypted secrets for "prod"
local/
alice/
public.env # Public local overrides for Alice
secrets.env # SOPS-encrypted local secrets (optional)
bob/
public.env # Another developer's overrides
Deployment names are open-ended — use any string that works as a directory
name (dev, prod, test, staging, ci, …).
dotconfig load produces a .env file with marked sections that map
back to the source files:
# CONFIG_DEPLOY=dev
# CONFIG_LOCAL=ericbusboom
#@dotconfig: public (dev)
APP_DOMAIN=inventory.example.com
NODE_ENV=development
PORT=3000
DEPLOYMENT=dev
DATABASE_URL=postgresql://app:devpassword@localhost:5433/app
DO_SPACES_ENDPOINT=https://sfo3.digitaloceanspaces.com
DO_SPACES_BUCKET=my-bucket
DO_SPACES_REGION=sfo3
#@dotconfig: secrets (dev)
SESSION_SECRET=abc123...
GITHUB_CLIENT_ID=...
GOOGLE_CLIENT_ID=...
#@dotconfig: public-local (ericbusboom)
DEV_DOCKER_CONTEXT=orbstack
PROD_DOCKER_CONTEXT=swarm1
QR_DOMAIN=http://192.168.1.40:5173/
SOPS_AGE_KEY_FILE=/Users/ericbusboom/.config/sops/age/keys.txt
#@dotconfig: secrets-local (ericbusboom)
#@dotconfig: files
CERT=LS0tLS1CRUdJTi... # optional — only when --embed is used (CERT_FILE → CERT)Last-write-wins: when the file is shell-sourced (set -a; . .env; set +a),
later sections override earlier ones. Local overrides deployment; secrets
override public.
The #@dotconfig: markers are unique to dotconfig — do not use this prefix
in your own comments. The two metadata comments (CONFIG_DEPLOY,
CONFIG_LOCAL) tell dotconfig save where to write each section back.
The #@dotconfig: files section is present only when --embed / -e was
passed to load. It holds base64-encoded file contents (for Docker / env-var
consumers that need PEM / cert files as variables). It is regenerated on each
load and is ignored by dotconfig save — it is never written back to any
source file.
Usage: dotconfig load [OPTIONS]
Assemble config files into .env, or load a specific file.
Options:
-d, --deploy TEXT Deployment / environment name (e.g. dev, prod).
-l, --local TEXT Local / developer name for personal overrides.
-f, --file TEXT Load a specific file instead of assembling .env.
-e, --embed [VAR_FILE]
Base64-embed a config file into the 'files' section
of the .env. Pass a *_FILE variable name to embed
only that one (e.g. -e CERT_FILE → CERT=<base64>);
pass -e with no value to embed every *_FILE
variable in the loaded config. Repeatable.
--no-export Strip the leading 'export ' prefix from assignment
lines. Implied by -S/--stdout. Use for parsers
that reject shell-style exports (e.g. docker
stack deploy).
--add-export Ensure every assignment line has an 'export '
prefix. Use to override -S/--stdout's implicit
--no-export. Mutually exclusive with --no-export.
-o, --output TEXT Destination file. [default: .env or the --file name]
-S, --stdout Print to stdout instead of writing to a file.
Implies --no-export unless --add-export is set.
--config-dir TEXT Root config directory. [default: config]
--help Show this message and exit.
Examples:
# Load dev deployment with Eric's local overrides
dotconfig load -d dev -l ericbusboom
# Load prod deployment, no local overrides
dotconfig load -d prod
# Write to a file other than .env
dotconfig load -d dev -o .env.dev
# Load a specific YAML file from the dev deployment
dotconfig load -d dev --file app.yaml
# Print a file to stdout (useful for agents / piping)
dotconfig load -d dev --file app.yaml --stdout
# Embed config files as base64 env vars (Docker / env-var consumers).
# Declare *_FILE variables in your config (e.g. CERT_FILE=server.pem),
# then pass the *_FILE name to -e (suffix is stripped → CERT=<base64>).
dotconfig load -d dev -e CERT_FILE # one file
dotconfig load -d dev -e CERT_FILE -e SSL_KEY_FILE # several
dotconfig load -d dev -e # every *_FILE var
# Plain KEY=value output (no `export` prefix) for docker stack deploy
dotconfig load -d prod --no-export -o .env
# -S/--stdout is pipe-friendly by default (implicit --no-export)
dotconfig load -d prod -S | jq .
# Keep the export prefix on stdout when you really want to source it
dotconfig load -d prod -S --add-exportWhen using --file, specify either -d or -l (not both) — the file
lives in one location only.
When using --embed / -e, the pairing of destination variable to
source filename lives in the config itself via the _FILE suffix
convention. Declare a <NAME>_FILE=<filename> variable in your config,
then pass -e <NAME>_FILE (or -e alone to expand every *_FILE
variable). For each expanded variable, dotconfig reads the referenced
file from config/<deploy>/<filename> first, then
config/local/<user>/<filename> (first-match wins; auto-decrypted if
SOPS-encrypted), base64-encodes it, and emits <NAME>=<base64> in the
#@dotconfig: files section. The original <NAME>_FILE variable is
preserved in its source section, so consumers that read the path-style
variable still work. The files section is regenerated on each load and
is not written back to any source file by dotconfig save. With
--split, embedded files are written to the .env.secret companion
(they are treated as secrets). Incompatible with --file, --json,
and --yaml.
When using --no-export, leading export prefixes are stripped from
assignment lines in the .env output. This is required by docker stack deploy (Swarm) and some other env-file parsers that reject shell-style
export prefixes; docker compose up accepts both forms. Metadata
comments and section markers are preserved. To round-trip source files
that use export KEY=value, pair with save --add-export so the
prefix is restored when writing back. Incompatible with --file.
-S/--stdout implies --no-export by default — piped consumers like
jq, docker stack deploy, and most env-file parsers reject the shell
export prefix, so the pipe-friendly form is the right default. Pass
--add-export alongside -S to keep the prefix when you do want a
shell-sourceable stdout dump. --no-export and --add-export are
mutually exclusive.
What it reads (without --file):
| Source file | Section in .env |
|---|---|
config/{deploy}/public.env |
#@dotconfig: public ({deploy}) |
config/{deploy}/secrets.env (SOPS-encrypted) |
#@dotconfig: secrets ({deploy}) |
config/local/{local}/public.env |
#@dotconfig: public-local ({local}) |
config/local/{local}/secrets.env (SOPS-encrypted) |
#@dotconfig: secrets-local ({local}) |
If a secrets file is absent or SOPS is unavailable, the section is written as empty with a warning — the command does not abort.
If a local file is absent, a warning is printed and the section is written as empty — useful when a new developer clones the repo before creating their own local overrides.
Usage: dotconfig save [OPTIONS]
Save .env sections back to config/ source files, or store a file.
Options:
-d, --deploy TEXT Target deployment (overrides .env metadata).
-l, --local TEXT Target local / developer name (overrides .env metadata).
-f, --file TEXT Save a specific file into the config directory.
--add-export Prepend 'export ' to assignment lines when writing
back to source .env files. Pairs with load's
--no-export for round-trip style preservation.
--env-file TEXT .env file to read and save. [default: .env]
--config-dir TEXT Root config directory. [default: config]
--help Show this message and exit.
Examples:
# Save all sections from .env back to config/
dotconfig save
# Save to a different deployment
dotconfig save -d staging
# Re-add `export` prefix on write (pairs with load --no-export)
dotconfig save --add-export
# Save a YAML file into the dev deployment
dotconfig save -d dev --file app.yaml
# Save a JSON file into a local config directory
dotconfig save -l alice --file settings.jsonWhen using --file, specify either -d or -l (not both) — the file
lives in one location only.
When using --add-export, every assignment line written back to a source
.env file is prefixed with export unless it already has one. Comments,
blank lines, and section markers are left alone. Incompatible with
--file, --json, and --yaml.
What it writes (without --file):
Section in .env |
Destination file |
|---|---|
#@dotconfig: public ({deploy}) |
config/{deploy}/public.env (plaintext) |
#@dotconfig: secrets ({deploy}) |
config/{deploy}/secrets.env (SOPS-encrypted) |
#@dotconfig: public-local ({local}) |
config/local/{local}/public.env (plaintext) |
#@dotconfig: secrets-local ({local}) |
config/local/{local}/secrets.env (SOPS-encrypted, only if non-empty) |
dotconfig save requires a dotconfig-managed .env (one that was produced
by dotconfig load) because it relies on the CONFIG_DEPLOY metadata
comment to know where to write the files back.
dotconfig delegates all encryption and decryption to
sops. You must install sops separately.
SOPS is optional for loading public config. If sops is not installed, or a secrets file is missing, the secrets section is left empty and a warning is printed.
Key discovery follows the standard sops precedence:
SOPS_AGE_KEY_FILEenvironment variable (path to an age private key file)SOPS_AGE_KEYenvironment variable (inline age private key)sops.yamlspecified via--configflag orSOPS_CONFIGenvironment variable
If SOPS_AGE_KEY_FILE is defined inside .env itself (e.g. in the
public-local section), dotconfig save reads it from the file before
invoking sops, so you do not need to export it manually.
Recommended config/sops.yaml:
creation_rules:
# Secrets companion files (app.secrets.yaml, etc.)
- path_regex: '.+\.secrets\.(?:env|json|yaml|yml|txt|conf)$'
age: >-
age1v3f2rn...,age1h02a69...
# Legacy secrets files (secrets.env, etc.)
- path_regex: '.+/secrets\.(?:env|json|yaml|yml|txt|conf)$'
age: >-
age1v3f2rn...,age1h02a69...
# Catch-all for any file dotconfig encrypts (private keys, etc.)
- path_regex: '.+'
age: >-
age1v3f2rn...,age1h02a69...Because sops.yaml is not a dotfile, SOPS will not auto-discover it.
Specify it explicitly when invoking sops directly:
SOPS_CONFIG=config/sops.yaml sops --encrypt --in-place config/dev/secrets.env
# or
sops --config config/sops.yaml --encrypt --in-place config/dev/secrets.envgit clone <repo>
# Create your personal local overrides
cp -r config/local/ericbusboom config/local/yourname
# Edit it with your values
$EDITOR config/local/yourname/public.env
# Load dev config
dotconfig load -d dev -l yourname# Reload if someone changed config files
dotconfig load -d dev -l yourname
# Make an ad-hoc change in .env directly, then save it back
$EDITOR .env
dotconfig saveAdd .env to .gitignore — it is a generated file:
# Generated by dotconfig load — do not commit
.envThe source files in config/ are committed. Encrypted secrets files
(config/secrets/) are safe to commit because they are SOPS-encrypted.
- Create the deployment directory and its public config:
mkdir -p config/{name} $EDITOR config/{name}/public.env # add public variables - Load the new deployment to generate
.env:dotconfig load -d {name} - Add any secrets to the secrets section in
.env, then save back:$EDITOR .env # add values under the secrets section dotconfig save # encrypts secrets via SOPS automatically
- Ask the developer to generate an age key pair:
age-keygen -o ~/.config/sops/age/keys.txt # Share the PUBLIC key (age1...) with the team
- Add their public key to
config/sops.yamland re-encrypt secrets:SOPS_CONFIG=config/sops.yaml sops updatekeys config/dev/secrets.env SOPS_CONFIG=config/sops.yaml sops updatekeys config/prod/secrets.env
- The developer creates their local overrides:
cp -r config/local/ericbusboom config/local/theirname $EDITOR config/local/theirname/public.env dotconfig load -d dev -l theirname
| Decision | Rationale |
|---|---|
Single .env file |
Tools (dotenv, Docker, IDEs) read one file — no cascade-compatibility issues. |
| Marked sections | Enable round-tripping between .env and config/ source files without extra metadata files. |
| Open deployment names | Not limited to dev/prod; supports test, ci, staging, or any custom name. |
| Last-write-wins ordering | Later sections override earlier when shell-sourced; local overrides deployment. |
| SOPS optional at load time | Developers without SOPS access can still load public config; secrets are skipped with a warning. |
| No shell variable expansion | Values are literal strings — no $VAR interpolation. Use a local override to change a value for a specific machine. |
| Local secrets are optional | config/local/{user}/secrets.env is supported but most developers won't need it. |