March 11, 2026

Stop Committing Your Secrets (You Know Who You Are)

Plaintext .env files are a stupid little footgun. Here's the SOPS + age + direnv setup I use to keep secrets encrypted, auto-loaded, and out of Git.

Header graphic for the SOPS, age, and direnv secrets workflow.
SOPS + age + direnv. Tiny setup. Considerably less panic.

HAS THIS EVER HAPPENED TO YOU?

You’re working on three projects at once. Each one has its own .env file. Maybe a .env.local too. You’ve got API keys, database creds, tokens from services you barely remember signing up for… all sitting in plaintext on disk.

Your .gitignore is the only thing standing between you and a very bad day on GitHub.

And let’s be real. At some point, you absolutely committed one.

Maybe you caught it before pushing. Maybe you didn’t. Maybe you’re still doing the whole “it was only a dev key” routine while quietly rotating it at 2am.

I got tired of that feeling. So I finally set up a proper secrets workflow that encrypts everything at rest, auto-loads secrets when I cd into a project, and unloads them when I leave.

No plaintext .env files on disk. No cloud account. No paid secret manager. No “ah for ffs” moment when you realise your credentials are now in Git history.

The stack is: SOPS + age + direnv.

It’s simple. It works. And once you’ve used it for a bit, going back to raw .env files feels kind of barbaric.

What these things actually do

age

age is a modern file encryption tool. Think GPG, but without the part where you question your life choices.

One command generates a keypair. That is it.

  • no key servers
  • no web of trust
  • no 47-step wizard

SOPS

SOPS encrypts files while keeping the file structure visible.

This is the bit I actually care about.

Your dotenv file still looks like a dotenv file. The keys stay readable. Only the values get encrypted.

So you still get:

  • git diff still works
  • you can see what changed
  • your config stays readable in review

Example:

DB_HOST=localhost
DB_PASSWORD=ENC[AES256_GCM,data:...]

That is extremely useful when you’re reviewing commits.

Side-by-side comparison of a normal dotenv file and a SOPS-encrypted dotenv file with readable keys and encrypted values.
Readable keys, encrypted values. Exactly what you want in a review.

direnv

direnv hooks into your shell and automatically loads environment variables when you enter a directory.

Leave the directory and they vanish.

No more:

wait… which project’s DB_PASSWORD is currently in my shell?

Together, these three give you:

  • encrypted secrets committed safely to Git
  • automatic decryption on cd
  • clean environment isolation between projects

Fine. Let’s set it up

Install the bits

macOS

brew install age sops direnv

Ubuntu / Debian

sudo apt install age direnv
curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops

Hook direnv into your shell

Add this to ~/.bashrc:

eval "$(direnv hook bash)"

For zsh:

eval "$(direnv hook zsh)"

Restart your terminal.

Yes yes, you can source your shell config instead.

I still prefer restarting. Feels cleaner.

Generate your age key

mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt

This prints your public key. It will look something like:

age1xxxxxxxxxxxxxxxxxxxx

SOPS automatically reads that key file for decryption. No extra config needed.

Back this file up.

Lose it and your secrets are gone forever.

Put it somewhere sensible:

  • your password manager
  • a USB stick
  • an offline backup
Terminal output showing age-keygen generating a public key and writing the private key to the SOPS age key file.
`age-keygen` spits out the public key you need for `.sops.yaml`.

Tell SOPS which key to use

Create .sops.yaml in the project root:

creation_rules:
  - path_regex: \.enc\.env(\..*)?$
    age: >-
      age1yourpublickeyhere

Commit this file.

It only contains your public key, so it’s safe.

Why .enc.env and not .env.enc?

Because SOPS detects formats from the file extension.

Examples:

  • .env -> dotenv
  • .json -> JSON
  • .yaml -> YAML
  • .ini -> INI

If your encrypted file ends in .enc, SOPS stops recognizing it as dotenv and treats the whole thing as binary. Everything gets shoved into one giant data= blob.

Which is useless.

So instead, name files like:

  • .enc.env
  • .enc.env.local
  • .enc.env.staging

Now the file still ends in .env, and SOPS knows what it’s looking at.

Create encrypted secrets

Create the encrypted file directly:

sops .enc.env

Your editor opens. Put your secrets in there:

DB_HOST=localhost
DB_PASSWORD=supersecret
API_KEY=sk-abc123

Save and close.

SOPS encrypts it on write.

No plaintext .env ever touches disk.

Migrate an existing .env

If you already have a plaintext .env, use --filename-override.

Safer version with atomic write:

sops --encrypt --filename-override .enc.env .env > .enc.env.tmp
mv .enc.env.tmp .enc.env
rm .env

Wire up direnv

This is the tiny bit of shell glue.

Create .envrc:

load_encrypted_env() {
  local file="$1"

  [ ! -f "$file" ] && return

  while IFS= read -r line; do
    [ -z "$line" ] && continue
    [[ "$line" == \#* ]] && continue
    export "$line"
  done < <(sops -d "$file")
}

load_encrypted_env .enc.env
load_encrypted_env .enc.env.local

Then run:

direnv allow

Now:

cd project
echo "$DB_PASSWORD"

works.

Leave the directory and the variables disappear again.

Terminal session showing direnv loading encrypted environment variables when entering a project directory.
`direnv` loads the secrets on entry and gets out of the way after that.

Helper scripts because I got tired of repeating this dance

After doing this across a few projects, I got bored of typing the same setup over and over. So I threw a few helpers into my shell config.

init-secrets

Bootstraps a new project.

init-secrets() {
  local key_file="$HOME/.config/sops/age/keys.txt"

  if [ ! -f "$key_file" ]; then
    echo "No age key found. Run:"
    echo "age-keygen -o $key_file"
    return 1
  fi

  local age_key
  age_key=$(grep '^# public key:' "$key_file" | awk '{print $4}')

  touch .gitignore

  cat > .sops.yaml << EOF
creation_rules:
  - path_regex: \.enc\.env(\..*)?$
    age: >-
      ${age_key}
EOF

  cat > .envrc << 'ENVRC'
load_encrypted_env() {
  local file="$1"
  [ ! -f "$file" ] && return

  while IFS= read -r line; do
    [ -z "$line" ] && continue
    [[ "$line" == \#* ]] && continue
    export "$line"
  done < <(sops -d "$file")
}

load_encrypted_env .enc.env
load_encrypted_env .enc.env.local
ENVRC

  for pattern in ".env" ".env.local" ".env.*.local" ".direnv/"; do
    grep -qxF "$pattern" .gitignore || echo "$pattern" >> .gitignore
  done

  direnv allow

  echo "Secrets initialized. Run:"
  echo "sops .enc.env"
}

Usage:

cd new-project
init-secrets
sops .enc.env

secrets-edit

Open the encrypted env file in SOPS.

secrets-edit() {
  sops "${1:-.enc.env}"
}

secrets-peek

Quickly view secrets without editing them.

secrets-peek() {
  local file="${1:-.enc.env}"
  local key="$2"

  [ ! -f "$file" ] && echo "File not found: $file" && return 1

  if [ -n "$key" ]; then
    sops -d "$file" | awk -F= -v k="$key" '$1==k'
  else
    sops -d "$file"
  fi
}

secrets-migrate

Convert a plaintext .env file safely.

secrets-migrate() {
  local src="${1:-.env}"
  local dest="${2:-.enc.${src#.}}"

  [ ! -f "$src" ] && echo "File not found: $src" && return 1
  [ ! -f ".sops.yaml" ] && echo "Run init-secrets first." && return 1

  sops --encrypt --filename-override "$dest" "$src" > "$dest.tmp"
  mv "$dest.tmp" "$dest"

  echo "Encrypted $src -> $dest"

  read -r -p "Delete plaintext $src? (y/N) " confirm
  [[ "$confirm" =~ ^[Yy]$ ]] && rm "$src"
}

secrets-check

Check whether Git is tracking plaintext env files.

secrets-check() {
  if git ls-files | grep -E '^\.env'; then
    echo "WARNING: plaintext env files tracked by git"
  else
    echo "All clear"
  fi
}

One more safety net

Install one of these as a pre-commit hook:

  • gitleaks
  • trufflehog

Because at 3am, everyone is stupid. Myself included.

Final thoughts

This setup gives me:

  • encrypted secrets in Git
  • automatic environment loading
  • zero plaintext .env files
  • clean separation between projects
  • no cloud dependency

And it takes maybe five minutes to set up.

Which is a lot less time than cleaning secrets out of Git history and rotating credentials you forgot even existed.

Now go encrypt your stuff.