Skip to content

Shell Plugins

The dotsecenv shell plugin loads secrets from a .secenv file when you cd into a directory, and unloads them when you leave. Your secrets stay in environment variables for as long as you work in the project, and your environment goes back to clean when you walk away.

This guide builds one .secenv file step by step. Each section shows the exact lines you would have at that point, so you can follow along in your own project.

Here is the file we will end up with:

.secenv
# Example .secenv for the dotsecenv shell plugin
APP_ENV=production
NODE_ENV=production
DATABASE_URL={dotsecenv}
PROD_DB_PASSWORD={dotsecenv/prod::DATABASE_URL}

Pick your shell. Each method gives you the auto-load behavior and the dse command.

Oh-My-Zsh:

Terminal window
git clone https://github.com/dotsecenv/plugin.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/dotsecenv

Then add dotsecenv to your plugins in ~/.zshrc:

Terminal window
plugins=(... dotsecenv)

Antigen:

Terminal window
antigen bundle dotsecenv/plugin

Manual:

Terminal window
source /path/to/dotsecenv.plugin.zsh

Open a new shell, then create a starter .secenv in your project. Start with one plain variable so you have something to load:

~/myapp/.secenv
APP_ENV=production

To confirm the plugin is active, check that dse is a shell function:

Terminal window
type dse # should report a function, not "not found"

The plugin never loads a file you have not approved. The first time you cd into a directory with a .secenv, it asks.

Add a second plain variable so the prompt has more to report:

~/myapp/.secenv
APP_ENV=production
NODE_ENV=production

Now cd into the project:

~ $ cd myapp/
dotsecenv: found .secenv in /home/user/myapp
Load secrets? [y]es / [n]o / [a]lways: y
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV

Your answer decides what happens:

OptionBehavior
y / yesLoad for this session only
n / noSkip loading for this session
a / alwaysRemember this directory and stop asking

Answering a records the directory in ~/.config/dotsecenv/trusted_dirs. To stop trusting it, delete that line:

Terminal window
vim ~/.config/dotsecenv/trusted_dirs # remove the directory's path

Before any load, the plugin checks the file. It must be owned by you or root, and it must not be world-writable. A file that fails is refused:

dotsecenv: refusing to load /home/user/myapp/.secenv - world-writable

Fix the file and the next load goes through:

Terminal window
chown "$(whoami)" .secenv # take ownership
chmod o-w .secenv # drop world-write

A .secenv line is either a plain value or a reference to your vault. Plain values are written verbatim. References use the {dotsecenv} syntax, and the plugin fetches them from your vault at load time.

Add a secret reference for the database URL:

~/myapp/.secenv
APP_ENV=production
NODE_ENV=production
DATABASE_URL={dotsecenv}

DATABASE_URL={dotsecenv} means “load the vault secret named DATABASE_URL into the variable DATABASE_URL.” The name on the left and the name in the vault match, so you can leave the braces empty.

Reload the directory to pick up the new line:

~/myapp $ cd . # or: dse reload
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV
dotsecenv: loaded 1 secret(s) from .secenv: DATABASE_URL
~/myapp $ echo $DATABASE_URL
postgres://...

To pull a vault secret into a variable with a different name, put the vault name in the braces:

Terminal window
DB_PASS={dotsecenv/DATABASE_PASSWORD} # vault key DATABASE_PASSWORD -> $DB_PASS

A namespace selects a specific vault when a secret name exists in more than one. Write the namespace before the key with ::.

Add a reference that reads the DATABASE_URL secret from the prod vault into a separate variable:

~/myapp/.secenv
APP_ENV=production
NODE_ENV=production
DATABASE_URL={dotsecenv}
PROD_DB_PASSWORD={dotsecenv/prod::DATABASE_URL}

{dotsecenv/prod::DATABASE_URL} reads the DATABASE_URL key from the prod namespace and loads it into PROD_DB_PASSWORD. Now DATABASE_URL and PROD_DB_PASSWORD can hold different values from different vaults.

~/myapp $ dse reload
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV
dotsecenv: loaded 2 secret(s) from .secenv: DATABASE_URL, PROD_DB_PASSWORD

Secrets are tree-scoped. Once loaded, they stay loaded while you work in subdirectories, and unload only when you leave the project tree.

This is the full example file from here on:

~/myapp/.secenv
APP_ENV=production
NODE_ENV=production
DATABASE_URL={dotsecenv}
PROD_DB_PASSWORD={dotsecenv/prod::DATABASE_URL}

Navigate down into the project and the secrets follow you. Step out of the tree and they unload:

~ $ cd myapp/
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV
dotsecenv: loaded 2 secret(s) from .secenv: DATABASE_URL, PROD_DB_PASSWORD
~/myapp $ cd src/components/
~/myapp/src/components $ echo $DATABASE_URL
postgres://... # still loaded; same tree
~/myapp/src/components $ cd ~/other-project/
dotsecenv: unloaded 2 env var(s): APP_ENV, NODE_ENV
dotsecenv: unloaded 2 secret(s): DATABASE_URL, PROD_DB_PASSWORD

Auto-loading happens when you cd through the directory that holds the .secenv. If your shell starts straight inside a subdirectory, for example a tmux pane or an editor terminal, the parent file was never crossed, so nothing loaded. Run dse up to walk up the tree and load ancestor files:

~ $ cd myapp/src/components/ # opened directly here; parent .secenv not crossed
~/myapp/src/components $ echo $DATABASE_URL
# empty
~/myapp/src/components $ dse up
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV
dotsecenv: loaded 2 secret(s) from .secenv: DATABASE_URL, PROD_DB_PASSWORD
~/myapp/src/components $ echo $DATABASE_URL
postgres://...

A subdirectory can have its own .secenv. Its variables layer on top of the parent’s. Suppose prod/ adds one secret:

~/myapp/prod/.secenv
API_KEY={dotsecenv/prod::API_KEY}

Entering prod/ adds API_KEY to what the parent already loaded. Leaving prod/ unloads only API_KEY:

~/myapp $ cd prod/
dotsecenv: loaded 1 secret(s) from .secenv: API_KEY
# parent's 4 plus prod's API_KEY are all available here
~/myapp/prod $ cd ..
dotsecenv: unloaded 1 secret(s): API_KEY
# API_KEY gone; the parent's 4 remain

When a child sets a variable that the parent already set, the child wins while you are inside it. On the way out, the parent’s value is fetched again from the vault and restored.

The forms you can write on the right side of a .secenv line:

ReferenceLoads
KEY=valuePlain value, written as-is
KEY={dotsecenv}Vault secret named KEY into $KEY
KEY={dotsecenv/OTHER}Vault secret named OTHER into $KEY
KEY={dotsecenv/ns::OTHER}Secret OTHER from namespace ns into $KEY

Mapped onto the running example:

~/myapp/.secenv
APP_ENV=production # plain value
NODE_ENV=production # plain value
DATABASE_URL={dotsecenv} # vault key DATABASE_URL
PROD_DB_PASSWORD={dotsecenv/prod::DATABASE_URL} # prod namespace, key DATABASE_URL

Loading runs in two phases. Plain values load first so they are available right away, then references are fetched from the vault. With the file above, APP_ENV and NODE_ENV are set before DATABASE_URL and PROD_DB_PASSWORD are fetched.

You do not have to type these lines by hand. dotsecenv init secenv builds them from your vaults, writing references only and never values, and skips keys already present. In a terminal it opens a picker, one tab per vault, space to select. With --all it adds every reference at once. See init secenv in the CLI reference.

Terminal window
cd ~/myapp
dotsecenv init secenv # pick interactively
dotsecenv init secenv --all # add every vault reference

The plugin adds dse, a short front end for everyday work against the same .secenv.

~/myapp/.secenv
APP_ENV=production
NODE_ENV=production
DATABASE_URL={dotsecenv}
PROD_DB_PASSWORD={dotsecenv/prod::DATABASE_URL}
CommandWhat it does
dsePass-through to dotsecenv
dse get NAMEShort form of dotsecenv secret get NAME
dse cp NAMECopy a secret straight to the clipboard
dse reloadReload the current directory’s secrets
dse up [DIR]Load ancestor .secenv files above the current one

dse on its own forwards to dotsecenv:

Terminal window
dse vault describe # same as: dotsecenv vault describe

dse get reads one secret. For a value referenced by the example file:

Terminal window
dse get DATABASE_URL # same as: dotsecenv secret get DATABASE_URL

dse cp copies a secret to the clipboard without printing it, so it never lands on screen or in shell history:

Terminal window
dse cp PROD_DB_PASSWORD
# dotsecenv: secret copied to clipboard

Clipboard backends: pbcopy on macOS, wl-copy on Wayland, xclip or xsel on X11.

dse reload re-runs the loader for the current directory. Use it after you edit the .secenv, after you change a secret in the vault, or to retry a fetch that failed:

~/myapp $ dse reload
dotsecenv: loaded 2 env var(s) from .secenv: APP_ENV, NODE_ENV
dotsecenv: loaded 2 secret(s) from .secenv: DATABASE_URL, PROD_DB_PASSWORD

dse up walks from the current directory toward the git root (or the filesystem root if you are not in a repo), collects every ancestor that holds a .secenv, and loads them root-first so layering stays correct. Ancestors already loaded are skipped. Pass a directory to stop the walk early:

Terminal window
dse up # walk to the git root
dse up /home/user # stop the walk at this directory

dse up was introduced in v0.5.0.

Secrets did not load when you entered the directory:

  1. Confirm the plugin is active with type dse. It should report a function.
  2. Check the file with ls -la .secenv. Confirm you own it and it is not world-writable.
  3. Run dse reload to load it by hand.

The prompt never appears and you see No TTY for trust prompt. The trust prompt needs an interactive terminal, so scripts and non-interactive shells cannot answer it. Pre-trust the directory instead:

Terminal window
echo "/home/user/myapp" >> ~/.config/dotsecenv/trusted_dirs

A reference failed to resolve:

dotsecenv: error fetching secret 'DATABASE_URL' for DATABASE_URL:
<error details>

Common causes: the secret is not in the vault, the GPG agent is not running, or the vault is locked. Confirm the secret exists with dse get DATABASE_URL, then dse reload.