All posts
6 min readDavid Nussio

From .env to the keychain in five minutes

Your app reads its configuration from process.env, and a plaintext .env file fills it in. That file is readable by every process running as you, and it gets copied wherever your project folder goes. Here is how I move a typical Node or Next.js project off it, one command at a time.

The setup you have today

A project like this one:

bash
# Database
DATABASE_URL="postgres://app:s3cret@localhost:5432/myapp"
STRIPE_SECRET_KEY=sk_test_example_not_real
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXTAUTH_SECRET='a-long-random-string'

Something loads it at startup: the dotenv package, Node's own --env-file flag, or Next.js, which reads .env* files on its own.

bash
# Plain Node with dotenv
node -r dotenv/config server.js

# Next.js reads .env* files by itself
npm run dev

The goal: keep process.env exactly as your code expects it, but fill it from the OS credential store instead of a file. Your application code doesn't change.

1. Install envsec

Terminal
# macOS / Linux
$brew tap davidnussio/homebrew-tap
$brew install envsec
# or, with Node.js 22.13+
$npm install -g envsec

On macOS envsec uses the Keychain and needs nothing else. On Linux it talks to the Secret Service through secret-tool (package libsecret-tools on Debian and Ubuntu), with a keyring daemon such as GNOME Keyring running. On Windows it uses the Credential Manager. The Homebrew formula installs a standalone binary, so it doesn't need Node.js.

2. Load the .env into a context

A context is a named group of secrets. I use <project>.<environment>, so myapp.dev here. load reads .env from the current folder by default:

Terminal
$cd ~/projects/myapp
$envsec -c myapp.dev load
text
✔ Done: 4 added, 0 overwritten, 0 skipped

Variable names become dotted, lowercase keys: STRIPE_SECRET_KEY is stored as stripe.secret.key. Quotes, export prefixes, inline comments and multi-line quoted values are handled the way dotenv files usually write them.

load never overwrites silently. Run it a second time and every key that already exists is skipped:

text
▲ Skipped "database.url": already exists (use --force to overwrite)
▲ Skipped "stripe.secret.key": already exists (use --force to overwrite)
▲ Skipped "next.public.site.url": already exists (use --force to overwrite)
▲ Skipped "nextauth.secret": already exists (use --force to overwrite)
✔ Done: 0 added, 0 overwritten, 4 skipped

--force (-f) overwrites. That is what you want for a .env.local that overrides .env:

Terminal
# .env.local overrides .env, so let it win
$envsec -c myapp.dev load --input .env.local --force

Three things to check in your file

3. Check what landed

Terminal
$envsec -c myapp.dev list
$envsec -c myapp.dev get stripe.secret.key
text
◆ database.url  updated: 2026-10-09 12:21:22
◆ next.public.site.url  updated: 2026-10-09 12:21:22
◆ nextauth.secret  updated: 2026-10-09 12:21:22
◆ stripe.secret.key  updated: 2026-10-09 12:21:22

▪ 4 secrets in myapp.dev

list shows key names and timestamps, never values. get prints one value, so use it when you actually need to see one.

4. Start the app with envsec run

Terminal
# Next.js
$envsec -c myapp.dev run --inject 'npm run dev'
# Plain Node: drop -r dotenv/config
$envsec -c myapp.dev run --inject 'node server.js'

--inject (-i) reads every secret in the context, turns each key back into an environment variable name (stripe.secret.key → STRIPE_SECRET_KEY) and runs the command through /bin/sh with those variables added to its environment. On Windows the shell is cmd.exe. Nothing is written to disk.

For Next.js, values already in process.env take precedence over .env* files. That means envsec wins, but a variable you forgot to import would still come from the file, and the app would look fine. Move the files aside before you test:

Terminal
# Move .env (and .env.local, if you have one) out of the project
$mkdir -p ~/env-backup/myapp
$mv .env ~/env-backup/myapp/
$envsec -c myapp.dev run --inject 'npm run dev'

If the app starts and works, every value it needs is in the keychain. You can now remove dotenv and the -r dotenv/config flag from your scripts.

Placeholders for one-off commands

Without --inject, run resolves {key} placeholders instead:

Terminal
$envsec -c myapp.dev run 'psql {database.url}'

envsec doesn't paste the value into the command line. It replaces the placeholder with a reference to a temporary environment variable ($ENVSEC_0_DATABASE_URL) and lets the shell expand it, so the value stays out of your shell history. If a placeholder names a key that doesn't exist, the command doesn't run at all.

5. Save it as a command

Typing the context every time gets old. --save stores the command under a name, together with its context:

Terminal
# Saves the command, then runs it
$envsec -c myapp.dev run --save --name dev --inject 'npm run dev'
# From now on
$envsec cmd run dev --inject
text
› dev  →  npm run dev  (ctx: myapp.dev)

That line is what envsec cmd list prints. Note the --inject on cmd run: the saved entry keeps the command string and the context, not the flag. A saved command always runs in the context it was saved with, unless you pass -c explicitly.

6. Or open a shell with the secrets loaded

When you're going to run several commands, start a subshell with the whole context in its environment:

Terminal
$envsec -c myapp.dev shell
text
▶ envsec shell — context: myapp.dev (4 secrets loaded)
Type 'exit' or press Ctrl+D to leave the session.

ENVSEC_CONTEXT is set inside the session, and bash and zsh get an (envsec:myapp.dev) prefix in the prompt. For those two shells envsec skips your startup files (--norc, --no-rcs), so aliases from .bashrc or .zshrc won't be there. When you exit, the variables go away with the process. --no-inherit starts the shell with only PATH and the secrets.

7. Optional: read secrets from code

For scripts that run outside your dev server, such as a seed script, @envsec/sdk reads the same contexts. It needs Node.js 22.13 or later, or Bun.

Terminal
$npm install @envsec/sdk
ts
import { loadSecrets } from "@envsec/sdk";

// Sets process.env.DATABASE_URL, STRIPE_SECRET_KEY, …
await loadSecrets({ context: "myapp.dev", inject: true });

loadSecrets returns the secrets as an object keyed by the dotted names; inject: true also copies them into process.env. For more than one read, the client:

ts
import { EnvsecClient } from "@envsec/sdk";

const client = await EnvsecClient.create({ context: "myapp.dev" });
try {
  // Keys use the dotted form, not the env var name
  const databaseUrl = await client.require("database.url");
  await seedDatabase(databaseUrl);
} finally {
  await client.close();
}

I keep the SDK out of application code. An app that reads process.env runs anywhere, with any source of variables. An app that imports the SDK needs a working keychain wherever it runs.

Teammates

Each developer has their own keychain, so nothing syncs. To hand a teammate the values once, encrypt them for their GPG key:

Terminal
# You: encrypt the context for a teammate's GPG key
$envsec -c myapp.dev share --encrypt-to alice@example.com -o myapp.dev.asc
# Alice: decrypt straight into her own keychain
$gpg --decrypt myapp.dev.asc | envsec -c myapp.dev load --input /dev/stdin

The payload uses the same KEY="value" format that load reads, and the pipe keeps the decrypted text off the disk (/dev/stdin works on macOS and Linux). The details, including what GPG does and doesn't protect, are in Sharing secrets with GPG.

CI and servers

envsec is built for developer machines, and I don't recommend it in CI. On Linux it needs a D-Bus session and an unlocked keyring daemon. envsec's own end-to-end tests start both under dbus-launch on Ubuntu runners, which shows it can work, but that is a test rig, not a deployment pattern.

In CI, use your provider's secret store and let it set the environment variables. Because the app still reads process.env, nothing changes in your code: locally the values come from envsec run --inject, in CI from the pipeline.

When a tool insists on a file

Some tools only read files, such as a Docker Compose env_file. For those, write a temporary one:

Terminal
$envsec -c myapp.dev env-file --output .env
$docker compose up
$rm .env
# Lists generated files that still exist, forgets deleted ones
$envsec audit

env-file writes plaintext, so treat the result like the file you are getting rid of. envsec records where it wrote it: envsec audit lists generated files that still exist and drops the records of deleted ones, and envsec rescue skips them because their values are already in the keychain.

8. Delete the old .env

Once the app runs from the keychain, remove the copies you moved aside. Instead of rm, let envsec check them first:

Terminal
# The folder is named myapp, so rescue checks the files against myapp.dev
$envsec rescue ~/env-backup/myapp --remove-plaintext

rescue deletes a file only after reading every value back from the keychain and finding it unchanged. Otherwise it keeps the file and prints why: a value missing from myapp.dev, or a value in .env overridden by .env.local. More on that in the rescue post. If the .env was ever committed to git, deleting it now doesn't remove it from the history: rotate those keys.

In short

Every command above is in the docs, and the comparison page puts envsec next to dotenv and the alternatives.