dotenv-sync is a cross-platform Go CLI for keeping a local .env file aligned
with a schema in .env.example while resolving provider-managed values through
a secret provider. Built-in providers currently include Bitwarden (via the
rbw CLI) and KeePass (via keepassxc-cli), with room for additional
providers over time.
- Keep
.env.exampleas the committed schema for required environment variables - Fill local
.envvalues from your configured secret provider - Detect drift, malformed files, duplicates, and missing secrets before they break teammates or CI
- Bootstrap
.env.examplefrom an existing.env - Use
ds pushfor Bitwarden round-trip write-back workflows - Use
ds scaffoldto bootstrap blank KeePass entries from.env.example
Most people just need a schema, a provider config, and ds sync. The examples
below assume ds is already installed and available; install/build options are
further down.
# Application settings
DATABASE_URL=
JWT_SECRET=
PORT=8080Blank values are treated as provider-managed secrets. Literal values are treated
as safe defaults and copied into .env.
Bitwarden example:
provider: bitwarden
schema_file: .env.example
env_file: .env
item_name: my-app
mapping:
DATABASE_URL: db_url
JWT_SECRET: auth_jwtKeePass example:
provider: keepass
schema_file: .env.example
env_file: .env
keepass_database: /path/to/secrets.kdbx
keepass_group: dotenvds doctor
ds syncThat checks your provider prerequisites, then writes .env from
.env.example, copying safe defaults and resolving blank values from your
provider.
ds diff
ds validate
ds missingds diffpreviews real changes without writing filesds validateis the CI-friendly check for malformed files, drift, duplicates, and unresolved secretsds missinglists unresolved schema keys only
| Command | What it is for |
|---|---|
ds sync |
Create or update .env from .env.example and your provider |
ds doctor |
Check config and provider readiness before syncing |
ds diff |
Preview redacted drift without writing files |
ds validate |
Fail on malformed files, drift, duplicates, or unresolved secrets |
ds missing |
List unresolved provider-backed keys |
ds init |
Create .env.example from an existing .env |
ds reverse |
Add new keys from .env back into .env.example as blanks |
ds push |
Upload .env back into Bitwarden (Bitwarden-only) |
ds scaffold |
Seed blank KeePass entries from .env.example for initial setup (KeePass-only) |
ds --version / ds version |
Show build and release metadata |
| Provider | What ds can write today |
Recommended workflow |
|---|---|---|
| Bitwarden | ds push can write .env changes back to the provider |
Use ds sync to pull secrets and ds push when you want a round-trip workflow |
| KeePass | ds scaffold can create missing blank entries from .env.example, but it does not update existing entry values from .env |
Run ds scaffold once to bootstrap the vault structure, then edit secret values in KeePassXC and use ds sync to read them back |
.envsync.yaml is optional. Running ds init or ds scaffold with no config
present will walk you through first-run setup and write it for you.
provider: bitwarden
schema_file: .env.example
env_file: .env
item_name: my-app
storage_mode: fields
mapping:
DATABASE_URL: db_url
JWT_SECRET: auth_jwtprovider: keepass
schema_file: .env.example
env_file: .env
keepass_database: /path/to/secrets.kdbx
keepass_group: dotenvkeepass_database is the path to your .kdbx file (absolute or relative to the
project root). keepass_group is the group inside the vault that holds env var
entries — each entry's title is the key name and its password field is the
secret value. Defaults to dotenv if omitted.
.kdbx files are automatically ignored by the bundled .gitignore entry.
For Bitwarden, if item_name is omitted, ds derives it from the Git
repository root directory name and falls back to the current working directory
name when Git metadata is unavailable. By default, Bitwarden-managed keys
resolve as rbw get <item_name> --field <ENV_VAR>, and mapping overrides
only the field name inside that Bitwarden item.
Bitwarden storage_mode defaults to fields for backward-compatible reads
from the repo-scoped item fields. In fields mode, ds push can update
provider-managed keys that map to Bitwarden's built-in password field. Set
storage_mode: note_json to store the full repo env map in the item notes for
round-trip push/sync workflows.
That makes shared aliases across repos possible with the default field-based layout. For example, both repos below read and write the same Bitwarden value:
# repo1/.envsync.yaml
item_name: shared-dev
mapping:
DB_PASSWD: password# repo2/.envsync.yaml
item_name: shared-dev
mapping:
PSWD: passwordIf your team wants commits to keep a single .env.example in sync
automatically, a simple lefthook setup can run ds reverse, push provider
updates, and re-stage the schema before commit:
pre-commit:
parallel: true
skip:
- rebase
commands:
ds-sync:
run: |
ds reverse
ds push
git add .env.exampleThat example mirrors a real-world lefthook flow, but keeps it to one
.env.example so it stays easy to understand.
Use this pattern when:
.env.exampleis your committed schema source of truth- you want local env additions reflected back into the schema before commit
- you use Bitwarden write-back and want provider updates pushed automatically
If you use KeePass or another read-only flow, drop the ds push line and keep
just ds reverse plus git add .env.example.
For the smoothest Bitwarden write-back flow, use storage_mode: note_json.
On Arch Linux, install the AUR package:
yay -S dotenv-sync-binThat package installs the ds executable.
Without an AUR helper:
git clone https://aur.archlinux.org/dotenv-sync-bin.git
cd dotenv-sync-bin
makepkg -siTo build from source locally:
go build -o ./bin/ds ./cmd/dsLocal development builds report dev metadata by default. Release builds inject
their version, commit, and build time at build time instead of editing source
files.
ds sync
ds sync --dry-run- Reads
.env.exampleas the schema contract - Resolves blank entries through the configured provider
- Preserves comment order and line endings when rewriting
.env - Produces
WRITTEN,UNCHANGED, andMISSINGoutput vocabulary for sync runs - On successful writes, prints the changed keys before the final summary without exposing raw values
- Uses
CHECKEDsummaries for dry-run previews
ds push --dry-run
ds push- Bitwarden-only for now; other providers may add write support later
- Requires a Bitwarden write-capable setup;
note_jsonis the simplest full round-trip mode - Reads
.envas the upload source and.env.exampleas schema context - In
note_json, writes a deterministic JSON payload into the repo-scoped Bitwarden item notes - In
fields, updates provider-managed keys present in.envwhen they map to Bitwarden's built-inpasswordfield - Never prints raw values; previews use redacted markers such as
[REDACTED] - Leaves
.envand.env.exampleuntouched
Field mode is useful for shared aliases across repos, but rbw cannot script
writes to arbitrary custom Bitwarden fields. If a repo maps pushed keys to
custom fields, ds push fails with actionable guidance to use password or
switch to storage_mode: note_json.
KeePass does not use ds push today. Its nearest equivalent is
ds scaffold, but that command only creates missing blank entries during
initial setup; it does not write current .env values back into KeePass.
ds diffPrints only real changes using redacted markers such as [RESOLVED] and
[STATIC].
ds validateReturns exit code 2 when drift, malformed input, duplicates, or unresolved
secrets are found.
ds doctorChecks .envsync.yaml readability and provider readiness without printing any
secret values. Works for both Bitwarden and KeePass providers.
ds init
ds init --dry-runCreates .env.example from .env, blanking secret-like values while copying
safe defaults.
If no .envsync.yaml exists and stdin is a terminal, ds init runs first-run
setup — asking which provider to use and writing the config file before
continuing. For KeePass, it verifies the database path and group exist before
writing anything.
If .env is malformed or contains duplicate keys, ds init returns a
validation error with actionable diagnostics instead of silently generating a
schema.
ds scaffold
ds scaffold --dry-runSeeds a KeePass vault with blank entries for every provider-managed key in
.env.example. Existing entries are skipped — never overwritten. Only
supported when provider: keepass.
This is a bootstrap step, not a KeePass version of ds push: it creates the
entry structure, but it does not update existing KeePass entry values from
.env.
This is a dev lead tool for the recommended KeePass team workflow:
- Design
.env.examplewith blank values for all secrets - Run
ds scaffold— creates stub entries in the vault - Open KeePassXC, fill in the real secret values
- Run
ds syncto verify.envpopulates correctly - Share the
.kdbxfile with team members via a secure channel - Team members run
ds syncand are ready to go
ds missingLists only unresolved provider-managed schema keys and exits with code 2 when
any key is missing.
ds reverse --dry-run
ds reverseAdds keys found in .env but missing from .env.example as blank placeholders.
ds --version
ds versionds --versionprints a concise version string such asds v0.4.0ds versionprints detailed metadata including version, commit, build time, and platform- Local development builds fall back to
dev,none, andunknown
0: success or no-op success1: operational failure2: validation, drift, or missing-value issue
GitHub Actions runs go test ./... on every push, pull request, and manual
dispatch via .github/workflows/go-tests.yml.
Every push to main now drives release automation automatically:
git switch main
git pull --ff-only
git push origin main.github/workflows/release.yml runs on pushes to main, calculates the next
patch version, reruns go test ./..., builds versioned archives for Linux,
macOS, and Windows, bundles README.md and LICENSE into the release
archives, writes ds_<version>_SHA256SUMS, verifies the Linux reference
artifact with ds --version, and then creates or refreshes the matching GitHub
release.
If AUR_SSH_PRIVATE_KEY is configured, .github/workflows/aur-publish.yml is
invoked directly from .github/workflows/release.yml after the GitHub release
succeeds, and updates the dotenv-sync-bin AUR package from the published Linux
release artifacts. This direct downstream handoff is also the intended pattern
for future package-manager publishers. The AUR package installs the ds
executable even though the package name is dotenv-sync-bin.
If you need an AUR-only packaging fix without a new upstream release tag, rerun
go run ./scripts/aurpkg against the existing tag with a higher --pkgrel
value and push the updated AUR repo.
You can also run Publish AUR package manually from GitHub Actions with a
release_tag such as v0.0.7 and a pkgrel such as 2.
To enable AUR publishing from GitHub Actions:
- Add an SSH key to your AUR account.
- Save the matching private key as the
AUR_SSH_PRIVATE_KEYrepository secret. - Make sure the
dotenv-sync-binAUR repo exists atssh://aur@aur.archlinux.org/dotenv-sync-bin.git.
You can monitor the latest release run with:
gh run list --workflow release.yml --limit 1
gh run watch <run-id>The AUR publisher can be monitored separately with:
gh run list --workflow aur-publish.yml --limit 1
gh run watch <run-id>If you keep ds in ~/.local/bin/ds, the simplest update flow is:
git switch main
git pull --ff-only
./scripts/install-local.shBy default ./scripts/install-local.sh installs to ~/.local/bin/ds. You can
override that with --bin /custom/path/to/ds.
If you use this repository's lefthook setup, lefthook install also enables
automatic local refreshes after git commit and git merge on the default
branch. Those hooks call ./scripts/install-local.sh --quiet through
./scripts/install-local-hook.sh, and they intentionally skip non-default
branches so feature work does not overwrite your globally installed ds.
The script injects version metadata from your current checkout:
- exact release tag checkout:
v0.4.0 - commits ahead of a release:
v0.4.0-3-gabc1234 - no release tags yet:
dev-abc1234
That means:
- if
mainis exactly on the latest release tag,ds versionmatches what a GitHub release installer sees - if
mainis ahead of the latest release, your local build intentionally shows a newer git-derived version so it does not pretend to be the last published release
If you want the exact same version string as the latest GitHub release, build from the release tag or install the GitHub release artifact.
go test ./...
go test ./... -run TestContract
go test ./... -bench . -run '^$'Preview the next patch tag from the current reachable semver tags:
go run ./scripts/nextversionExample outputs:
v0.0.1
v0.4.3
scripts/nextversion is a local preview helper for the same automatic release
logic used in CI. By default it predicts the next patch release from the
current reachable semver tags.
Then build a local binary with that predicted release metadata:
VERSION=$(go run ./scripts/nextversion)
COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
go build -o ./bin/ds \
-ldflags "-X dotenv-sync/pkg/dotenvsync.Version=$VERSION -X dotenv-sync/pkg/dotenvsync.Commit=$COMMIT -X dotenv-sync/pkg/dotenvsync.BuildTime=$BUILD_TIME" \
./cmd/ds
./bin/ds --version
./bin/ds versionLocal development builds without injected metadata fall back to dev, none,
and unknown.
To install straight into ~/.local/bin/ds with Git-derived version metadata:
./scripts/install-local.shYou can always run the binary directly as ./bin/ds (or bin\ds.exe on
Windows), but for a normal ds ... workflow you can either add the build
directory to your PATH or copy/symlink the binary into a directory that is
already on your PATH.
Add your build directory to a shell startup file such as ~/.profile,
~/.bashrc, or ~/.zshrc:
export PATH="$PATH:/absolute/path/to/dotenv-sync/bin"Reload the file or open a new shell:
source ~/.bashrcfish_add_path /absolute/path/to/dotenv-sync/binFor the current session:
$env:Path += ";C:\absolute\path\to\dotenv-sync\bin"For a persistent install, add the same directory through your system PATH
settings or place ds.exe in a directory that is already on PATH.
Examples:
ln -s /absolute/path/to/dotenv-sync/bin/ds ~/.local/bin/dsor:
install -Dm755 ./bin/ds ~/.local/bin/ds