Install dotenvctl, edit your first .env file without disturbing a byte you did not touch, and find the next guide for what you want to do.
On this page: Before you start · Install · The 60-second tour · Choosing the file: -f · Variable expansion: --expand · Scripting: --json and exit codes · Pitfalls · What next
dotenvctl is a command-line tool for .env files: read a value, set one, comment one out, compare environments, run a program with a file's values, and push values to secret stores. It is built on the github.com/ubgo/dotenv Go library, which you can also use directly from Go (see What next).
- Go, to install with
go install. The CLI module declares Go 1.26.4; with an older Go and the defaultGOTOOLCHAIN=auto,go installdownloads that toolchain first. The library on its own needs only Go 1.22. - A shell. Every example below runs in an empty directory, so you can follow along without touching a real project.
go install github.com/ubgo/dotenv/cli/cmd/dotenvctl@main
dotenvctl --version--version prints the module version stamped into the binary at build time (dotenvctl version …). Make sure Go's install directory ($(go env GOPATH)/bin unless you set GOBIN) is on your PATH, or the shell will not find dotenvctl.
cd your-project # commands default to ./.env
dotenvctl set DB_HOST=localhost DB_PORT=5432 # creates .env (0600) if missing
dotenvctl get DB_HOST # → localhost
dotenvctl keys # names, one per line
dotenvctl list # aligned KEY VALUE table
dotenvctl unset DB_PORT # comments it out: '# DB_PORT=5432' — reversible
dotenvctl restore DB_PORT # …and back, byte-identical
dotenvctl run -- npm start # child process gets the file's valuesTry it in an empty directory. Mutating verbs print nothing on success; the file shows what happened:
$ dotenvctl set DB_HOST=localhost DB_PORT=5432
$ dotenvctl get DB_HOST
localhost
$ dotenvctl keys
DB_HOST
DB_PORT
$ dotenvctl list
DB_HOST localhost
DB_PORT 5432
$ dotenvctl unset DB_PORT
$ dotenvctl restore DB_PORT
$ cat .env
DB_HOST=localhost
DB_PORT=5432
$ dotenvctl set DEBUG=true --dry-run
+DEBUG=true
$ dotenvctl run -- sh -c 'echo "child sees $DB_HOST:$DB_PORT"'
child sees localhost:5432The --dry-run line is the whole change the write would make, as a minimal -old/+new diff; nothing was written. The point of this tool over sed/hand-editing: your .env files keep their comments, section banners, blank lines, and quoting exactly as written. Only the line you touch changes — verify it yourself with --dry-run on any mutating command.
Every verb in this tour also has a runnable Go example in cli/dotenvcmd/example_test.go (ExampleExecute_get, ExampleExecute_set, ExampleExecute_unset, ExampleExecute_list, ExampleExecute_keys).
Every command takes -f/--file (default ./.env):
dotenvctl -f .env.production get DATABASE_URL
dotenvctl -f deploy/.env.staging set REGION=eu-west-1Reading a missing file reports keys as not found; set bootstraps a missing file with 0600 permissions (env files hold credentials — restrictive by default).
$ dotenvctl -f .env.missing get X; echo "exit=$?"
dotenvctl: key "X" not found in .env.missing
exit=1Values may reference other keys — URL=${HOST}:${PORT} — with the full Docker Compose interpolation syntax (defaults ${VAR:-x}, requireds ${VAR:?msg}, and so on). Reads return the raw text by default; --expand resolves:
$ cat .env.x
HOST=localhost
PORT=5432
URL=${HOST}:${PORT}
$ dotenvctl -f .env.x get URL
${HOST}:${PORT}
$ dotenvctl -f .env.x get URL --expand
localhost:5432The raw form is what is on disk; the expanded form is what a consumer sees. A failing ${VAR:?message} makes the command exit 1 with the message — that is the point of the :? form.
Every command supports --json: exactly one envelope object on stdout.
dotenvctl get DB_HOST --json
# {"ok":true,"data":{"key":"DB_HOST","value":"localhost","expanded":false}}
dotenvctl get NOPE --json
# {"ok":false,"error":{"code":"not_found","message":"key \"NOPE\" not found in .env"}}
# exit code: 1Exit codes: 0 success · 1 the operation ran and failed (missing key, required-var error, diff found differences) · 2 the invocation was malformed. run is the exception by design: it passes the child's exit code through untouched, so dotenvctl run -- make test behaves exactly like make test to CI.
$ dotenvctl get; echo "exit=$?"
dotenvctl: accepts 1 arg(s), received 0
Run 'dotenvctl --help' for usage.
exit=2
$ dotenvctl get --json; echo "exit=$?"
{"ok":false,"error":{"code":"usage","message":"accepts 1 arg(s), received 0"}}
exit=2In human mode every failure message goes to stderr; under --json the envelope, success or failure, is the only thing on stdout. A few things are never envelopes: --version, help (and --help) and completion print plain text even with --json, and run and exec plugins own their output (run passes the child's stdout through).
Error code values are stable API: not_found · io · required · usage · exec. The envelope for every verb is pinned by ExampleExecute_json.
- Check the exit code, not the output. A failing verb prints
dotenvctl: …on stderr and nothing on stdout, soVALUE=$(dotenvctl get KEY)leavesVALUEempty when the key is missing, and an empty value is also a valid value. Test the status:if VALUE=$(dotenvctl get KEY); then …; fi. In cli v0.1.2 and earlier, human-mode failures were printed on stdout and ended up insideVALUE. - A typo in
-fis not an error for reads. A missing file reads as empty:getsaysnot found,keysandlistprint nothing and exit0. - Reads are raw unless you ask.
getandlistprint${HOST}literally; add--expandwhen you want what a program would see.runand the plugin pushes expand by default. - Commented-out and name-only lines are not active.
# KEY=valueand a bareKEYare invisible toget,keysand plainlist;list --disabledandlist --inheritedshow them.
Using the CLI
- Selecting subsets of a file (
--prefix,--exclude, multi-value syntax): the shared selection grammar - Full flag-by-flag detail,
keys,plugins,completionand--version: Command reference - Comparing environments and generating reports: Multi-environment tools
- Complete copy-paste workflows (CI gates, env cloning, secret sync): Recipes
- Syncing secrets outward: Plugins, with the GitHub and Vercel guides
- Writing your own subcommand in any language: exec plugins
Using the Go library (go get github.com/ubgo/dotenv)
- Read a file into a map, or open it to inspect keys, duplicates and disabled lines: Reading files
- Change values in place without touching anything else, and save: Editing files
- Exactly how quotes, comments,
exportand multi-line values parse: Syntax ${VAR}, defaults, required variables and how expansion resolves: Expansion- Hook into reads, expansion and saves with plugins: Library plugins
Embedding the CLI or building on its packages
- Run any verb in-process with
dotenvcmd.Execute: Drive the CLI from Go envkit,providerkitand the helper packages: Go API guide- Every feature, with its guide and example: Feature index