Inject secrets from passage or pass into environment variables at runtime, then exec a command. No plaintext secrets on disk.
Inspired by op run from 1Password and sops exec-env, but for the unix password store ecosystem.
.env files store secrets in plaintext on disk. pass-run replaces them with a reference file that maps variable names to password store paths — safe to commit, no secrets exposed.
# .env (secrets on disk — bad)
API_KEY=sk-1234567890abcdef
# .env.pass (references only — safe to commit)
API_KEY=ai/openai/api_key
Copy pass-run somewhere in your $PATH:
cp pass-run ~/.local/bin/Or with Nix (flake):
nix run github:vdemeester/pass-run -- --helpRequires: bash and either passage or pass
pass-run supports two backends:
| Backend | Encryption | Tool |
|---|---|---|
| passage | age | passage show |
| pass | GPG | pass show |
Both use the same show subcommand interface, so env files work with either.
Auto-detection (default): prefers passage if available, falls back to pass.
Explicit: use -b passage or -b pass to force a backend.
Create a .env.pass file mapping variable names to store paths:
# .env.pass
OPENAI_API_KEY=ai/openai/api_key
SYNTHETIC_API_KEY=ai/synthetic.new/api_key
DATABASE_URL=services/postgres/urlRun a command with those secrets injected:
pass-run .env.pass -- my-app serve
pass-run .env.pass -- npm start
pass-run .env.pass -- pi --provider syntheticpass-run -e API_KEY=ai/openai/api_key -- curl -H "Authorization: Bearer $API_KEY" ...pass-run .env.pass -e EXTRA_SECRET=path/to/secret -- my-appInline -e flags override values from the file.
# Use pass (GPG) instead of passage (age)
pass-run -b pass .env.pass -- my-app
# Explicitly use passage
pass-run -b passage .env.pass -- my-app-b <backend> Backend: "passage", "pass", or "auto" (default: auto)
-e VAR=path Add a single mapping (can be repeated)
-f <file> Env file to load (alternative to positional argument)
-q Quiet: don't print status messages to stderr
-n Dry run: show what would be exported without running the command
-p Parallel: fetch secrets concurrently (faster, noisier errors)
-h, --help Show help
See what would be injected without running anything:
$ pass-run .env.pass -n
# pass-run: would export the following variables (via passage):
export OPENAI_API_KEY=<51 chars>
export SYNTHETIC_API_KEY=<32 chars>
# then exec: my-app# Comments (lines starting with #)
VARIABLE_NAME=store/path/to/secret
# Inline comments are stripped
API_KEY=ai/openai/key # this is ignored
# Variable names must be valid shell identifiers
MY_VAR_123=some/path- One mapping per line:
VARIABLE_NAME=store/path - Lines starting with
#are comments - Blank lines are ignored
- Only the first line of each store entry is used (the password line)
# .env.pass.pi
SYNTHETIC_API_KEY=ai/synthetic.new/api_key
GEMINI_API_KEY=redhat/google/osp/vdeemest-api-key
# Run pi with secrets injected
pass-run .env.pass.pi -- pi --provider syntheticpass-run .env.pass -- docker compose up# In a wrapper script
#!/usr/bin/env bash
exec pass-run "$HOME/.env.pass.myapp" -- myapp "$@"Same env file works with both — just switch the backend:
# With GPG-based pass
pass-run -b pass .env.pass -- my-app
# With age-based passage
pass-run -b passage .env.pass -- my-app- Detects backend (
passagepreferred,passfallback, or-boverride) - Reads variable-to-path mappings from file and/or
-eflags - Calls
<backend> show <path>for each mapping - Exports the resolved values as environment variables
- Uses
execto replace itself with your command
Secrets exist only in the process environment — never written to disk.
- passage — age-backed password manager
- pass — the standard unix password manager (GPG)
- Stop Putting Secrets in .env Files — the blog post that inspired this pattern
op run— 1Password's equivalentsops exec-env— SOPS's equivalent
MIT