Per-project environment variables without copying .env files
Every project needs its own environment variables, and most of us handle that by copying .env files. One per
project, then one per environment: .env, .env.staging, a .env.prod you made once for a migration and never
deleted. New laptop, new teammate, rotated key: copy them again, from a chat message or an old folder.
The result is plaintext secrets in every checkout, and no clear answer to “which file is current?”. There are good tools for this. They solve different parts of it, so here is what each one does, with the commands, and where each one stops.
What you want from a setup
- The right values for the project you are in, and nothing from the last project.
- No plaintext secret files lying around in checkouts, backups and old folders.
- Switching environments is one word, staging to production, without editing a file.
- Any command works:
php artisan,npm run,psql, a shell script. - Rotating or removing a secret is one change, not a hunt through copies.
Each tool below covers some of these well and makes you pay for it somewhere else. They also combine.
1. direnv: load on cd
direnv hooks into your shell. Before each prompt it looks for an .envrc in the current
directory and its parents, loads it, and unloads it when you leave. It works in bash, zsh, fish and several other
shells, with any language.
# .envrc
export AWS_PROFILE=acme
dotenv .env.staging # stdlib: load a .env file too
cd ~/work/acme # direnv refuses: this .envrc is not allowed yet
direnv allow # approve it; now it loads on cd and unloads when you leave
A new or changed .envrc does nothing until you run direnv allow, so a repo you clone cannot run code in your
shell just because you cd into it.
Good at: zero friction. You never type a command to load anything.
Where it stops: the values are still in plaintext files, and they are exported to your whole shell, so every
command you run from that directory sees them, not just the one that needs them. You can keep secrets out of the
files by having .envrc ask your password manager:
# .envrc
export DB_PASSWORD="$(op read 'op://Acme/Staging DB/password')"
That runs every time you enter the directory, which can mean an unlock prompt on every cd.
2. 1Password’s op run: references instead of values
The 1Password CLI can read a .env that holds secret
references instead of secrets, and resolve them for one command:
# .env.staging: safe to commit, it holds no secrets
DB_HOST=db.staging.acme.example
DB_PASSWORD="op://Acme/Staging DB/password"
op run --env-file=.env.staging -- php artisan migrate
The secrets are “available as environment variables only for the duration of the subprocess”, and secrets the
command prints to stdout or stderr are masked unless you pass --no-masking.
Good at: nothing secret on disk, and the file of references can live in the repo. Rotating a secret is one edit in 1Password. Sharing is 1Password’s vault permissions.
Where it stops: you need a 1Password account and the CLI signed in, and each secret is a vault item you have to create and name.
3. dotenvx: encrypt the values, commit the file
dotenvx encrypts the values inside your .env with a public key
(secp256k1) and keeps the private key in .env.keys, which you do not commit:
dotenvx encrypt -f .env.staging # values become encrypted:…, names stay readable
git add .env.staging # .env.keys stays out of Git
dotenvx run -f .env.staging -- php artisan migrate
Good at: one file per environment, in the repo, reviewed in pull requests like anything else. It works with
any language, because dotenvx run just starts your command with the variables set.
Where it stops: the private key still has to reach every machine, and that is the same copying problem with
one file instead of many. And because old ciphertext stays in Git history, removing someone’s access means
rotating the secrets themselves, not just the key. (dotenv-vault, from the same author, is a paid cloud service
that syncs .env files instead.)
4. Encrypted files: SOPS, or Laravel’s env:encrypt
SOPS is a CNCF sandbox project that encrypts YAML, JSON, INI and dotenv files with
age, PGP or a cloud KMS, for one or several recipients. A file name ending
in .env is read as dotenv; keys stay readable and values are encrypted:
sops encrypt --age "$(age-keygen -y ~/.config/sops/age/keys.txt)" staging.env > staging.enc.env
rm staging.env # keep only the encrypted copy
sops exec-env staging.enc.env 'php artisan migrate'
exec-env puts the values in the environment of that one child process, “never written to disk”
(SOPS docs). SOPS looks for your age key in
~/.config/sops/age/keys.txt by default on Linux, and age-keygen -y prints its public half.
Good at: several recipients per file (you, a teammate, CI), and a choice of key backends.
Where it stops: more setup than the others, and the same key-distribution and history caveats as dotenvx.
If the project is Laravel, the framework already has a version of this:
php artisan env:encrypt --env=staging --readable # writes .env.staging.encrypted
php artisan env:decrypt --env=staging # needs LARAVEL_ENV_ENCRYPTION_KEY or --key
It is good for keeping environment files in the repo, but env:decrypt writes the plaintext file back to disk,
so it solves sharing, not files lying around
(Laravel docs).
Side by side
- direnv loads on
cd. Secrets are in plaintext files unless.envrcasks a vault, and your whole shell sees them. You need direnv and its shell hook. op runloads per command. Only references are on disk, and only that command sees the values. You need a 1Password account.- dotenvx loads per command. Ciphertext is on disk, plus
.env.keys, and only that command sees the values. You need its private key on each machine. - SOPS
exec-envloads per command. Ciphertext is on disk, plus your age key, and only that command sees the values. You need a key per recipient. - Laravel
env:encryptkeeps ciphertext in the repo, but decrypting writes a plaintext.env, which the app reads as usual. You need the key, and Laravel.
A common combination: direnv for non-secret settings, and op run, dotenvx or SOPS in front of the commands that
need secrets.
Two things that apply to all of them
Environment variables are not hidden. Every child process inherits them, and on Linux other processes running
as your user can usually read them through /proc/<pid>/environ. An error reporter or debug page that dumps the
environment will show them. Injecting per command limits who gets them; it does not make them secret inside the
process.
Laravel’s cached config ignores them. After php artisan config:cache, the app uses the values from when you
cached, and variables injected later do not change its config. Keep config:cache for deploys, and run
php artisan config:clear locally if injected values seem to have no effect.
Where ShellTasks fits
We are building ShellTasks for the personal side of this: keyboard-first tasks for your projects, where each task
can carry its context in a #pack. A pack per client environment holds typed fields: URLs, commands, dates and
secrets. A CLI, tsk, will read them:
tsk env acme-staging > .env # when a tool insists on a file
tsk exec acme-staging -- php artisan migrate # the pack as env vars, no file
It is meant for the values you keep per project and per client, next to the tasks that use them, not for production secrets on servers or in CI. Use your platform’s secret store or one of the tools above for those.
The apps and tsk are not released yet. When they are, secret fields will be encrypted on your device before
they sync, so our server only ever stores ciphertext; the security page says what is built and what
is not. Until then, the tools above are what we would use.
You can try tsk env and tsk exec now in a simulated terminal, next to the input bar, with no account. The
Laravel app template’s keys come out as Laravel’s own names, like DB_HOST.
Try ShellTasks in your browser. If it fits how you work, join the waitlist and we will email you when the beta opens.