Keep your secrets in git, encrypted, and have them appear in process.env
at startup — decrypted with the SSH key you already have. A small, standalone,
non-Nix take on the agenix pattern, built
directly on age.
No vault server, no secret-manager SaaS, no Nix evaluation, no wrapper process. Three short Bun/TypeScript files you can copy into any project.
- Secrets live as
age-encrypted env files insecrets/— committed to git. - They're encrypted to a list of public SSH keys (
secrets/recipients.txt), so every listed teammate can decrypt with their private key, and nobody else can. - Blobs are ASCII-armored, so
secrets/*.ageare plain text with readable git diffs — not opaque binary. - At startup,
applyAgeSecrets()decrypts and injects eachKEY=valueintoprocess.envbefore your app reads its config. In-process: no wrapper, no dev shell. - It's best-effort: with no blob, no
age, or the wrong key, your program still starts and each feature reports its own "not configured" state. So you can scaffold and run before any real credentials exist.
Both encrypt secrets to SSH public keys and commit the ciphertext to git; both
let you add/remove readers by editing a recipient list and re-sealing. The
difference is what sits around age: this pattern is a handful of Bun files
that inject into process.env, while agenix is a Nix module that decrypts to
files at system activation. It is the convenience of agenix (encrypt to SSH keys,
edit-and-reseal) without requiring Nix to be in the loop at runtime.
Each approach gets its own write-up, with a worked example, under
concepts/:
concepts/age-sealed-env/— the current method, the pattern this repo implements.concepts/agenix/— agenix, the Nix-native approach it's modeled on.
See concepts/README.md for the full
dimension-by-dimension comparison table, and
concepts/agenix-migration.md for whether
agenix plus a Nix-packaged CLI could replace this loader outright (short answer:
it replaces sealing and storage, not ensure-env — so ensure-env became a Nix
package instead).
src/
secrets.ts # the loader: applyAgeSecrets() (best-effort) + ensureEnv() (fail-loud)
demo.ts # tiny example that loads the vault and reads two vars
nix/
ensure-env.nix # the same shell-facing CLI, built by Nix: no Bun, no checkout
scripts/
secrets-seal.ts # encrypt secrets/server.env -> server.env.age (agenix's "seal")
secrets-unseal.ts # decrypt secrets/server.env.age -> server.env (agenix's "-e" half)
ensure-env.ts # shell-facing CLI: guarantee named secrets, emit export/dotenv lines
secrets/
recipients.txt # the public keys allowed to decrypt (committed)
server.env.example # template for the shared vault (committed)
user.env.example # template for a personal override (committed)
README.md # full seal/unseal/participant/override workflow
*.env.age # the encrypted secrets you create (committed)
*.env # decrypted plaintext (gitignored, never committed)
Requires Bun. For encryption you need an age-compatible
binary on PATH (age or rage) — or just nix, which the scripts fall back
to automatically (nix run nixpkgs#age).
bun install
# 1. Run the demo with no vault yet — it boots and reports "not configured".
bun run src/demo.ts
# 2. Add your public key as a recipient.
cat ~/.ssh/id_ed25519.pub
$EDITOR secrets/recipients.txt # paste it in, replacing the placeholder
# 3. Create and seal a shared vault.
cp secrets/server.env.example secrets/server.env
$EDITOR secrets/server.env # set EXAMPLE_API_KEY, DATABASE_URL, …
bun run scripts/secrets-seal.ts # -> secrets/server.env.age (plaintext deleted)
# 4. Run the demo again — now the values come from the sealed vault.
bun run src/demo.ts
# Shell env still wins over the vault:
EXAMPLE_API_KEY=from-shell bun run src/demo.tsgit add secrets/server.env.age and commit — the encrypted blob is safe to push.
A flake.nix is included so you need nothing on your machine but Nix — bun
and age come from the flake. The apps run against your current checkout (the
project root is found from the working directory), so seal/unseal still read and
write the working tree's secrets/:
nix run .#seal # encrypt secrets/server.env -> server.env.age
nix run .#unseal # decrypt secrets/server.env.age -> server.env
nix run .#demo # run the demo program
# Flags pass straight through after `--`:
nix run .#seal -- --user # seal your personal override
nix run .#unseal -- secrets/other.env.age
nix develop # drop into a shell with bun + age + ssh + ensure-env on PATHThe ensure-env CLI is also a package, not just a repo script. age is
pinned by the derivation and the result is self-contained in the store, so it
needs neither Bun nor a checkout — which is what makes it installable rather
than merely runnable:
nix run .#ensure-env -- --list # straight from this flake
nix profile install .#ensure-env # …or put it on $PATH for good# In a NixOS or home-manager config — add the overlay, then the package:
nixpkgs.overlays = [ inputs.binp-age-sealed-env.overlays.default ];
environment.systemPackages = [ pkgs.ensure-env ];The flags, the output formats, and the precedence are the same as
scripts/ensure-env.ts — all three formats are byte-identical on the same vault
— with two deliberate differences:
- A bare invocation prints help and exits 0, instead of exiting 2.
- The vault is discovered, not resolved relative to the script, because a
store path has no project next to it:
$VAULT_SECRETS_DIRfirst, then a directory baked in at build time (callPackage ./nix/ensure-env.nix { defaultSecretsDir = "/var/lib/vault/secrets"; }), then the nearestsecrets/at or above the working directory holding a*.env.age. One installed binary serves every checkout on the machine.
Like the loader, it parses the vault as a dotenv document in memory — it never
sources it, so a secret value containing $(...) stays data.
Copy src/secrets.ts, scripts/secrets-seal.ts, scripts/secrets-unseal.ts,
scripts/ensure-env.ts, and the secrets/ directory into your project, then
call the loader once, first, before anything reads config:
import { applyAgeSecrets } from "./secrets";
applyAgeSecrets(); // decrypt the vault into process.env
// ...now the rest of your app can read process.env.EXAMPLE_API_KEY etc.The loader logs to the console and never throws — a missing or undecryptable vault degrades to "nothing loaded", not a crash.
When a feature genuinely cannot run without a given secret, guard its edge with
ensureEnv — the loud counterpart to the best-effort loader. It applies the
vault, then guarantees the named vars are present, exiting with an actionable
message (which keys are missing + how to seal them) if any is not:
import { ensureEnv } from "./secrets";
// Loads the vault, then guarantees these are set — or exits non-zero, loudly.
const { EXAMPLE_API_KEY, DATABASE_URL } = ensureEnv("EXAMPLE_API_KEY", "DATABASE_URL");ensureEnv still honours precedence (shell env > personal override > shared
vault), so a value exported in your shell satisfies it just as a sealed one
does. Use applyAgeSecrets() for the scaffold-and-run path where a missing
secret should degrade gracefully; reach for ensureEnv only where you'd rather
fail fast than hit an opaque 401 later.
ensureEnv is in-process, for Bun code. For shell scripts (or any consumer
that just wants the values in its environment) there's scripts/ensure-env.ts:
it loads the vault, guarantees the named keys, and prints them as export lines
to eval. Capture first so a missing key aborts before eval:
secrets="$(bun run --no-env-file scripts/ensure-env.ts EXAMPLE_API_KEY DATABASE_URL)" || exit 1
eval "$secrets"
# $EXAMPLE_API_KEY and $DATABASE_URL are now set| Invocation | What it does |
|---|---|
ensure-env KEY… |
verify + print export KEY='value' lines (stdout) |
ensure-env --dotenv KEY… |
print KEY="value" dotenv lines for a parser that reads stdout directly |
ensure-env --all |
expand to every key sealed in the vault (no need to name them) |
ensure-env --list |
list available secret names + their source layer — no values |
ensure-env --check KEY… |
verify only; print nothing on success |
All vault logging goes to stderr, so stdout stays safe to eval or parse.
Missing keys print an actionable message to stderr and exit non-zero. Run it
with --no-env-file (the shebang, the ensure-env package script, and
mise run ensure-env all do) so a stray project-root .env can't quietly
satisfy a --check.
Adopt the seal script's delete-on-seal behaviour, don't strip it. On a successful seal,
secrets-seal.tsremoves the plaintext*.envinput by default (pass--keepto retain it) so no decrypted secret is left lying on disk. This is a deliberate safety default, not an accident — keep it when you copy the script into your project, and pair it with a.gitignorerule forsecrets/*.envso a plaintext (kept or mid-edit) can never be committed.
This pattern moves the trust boundary; it doesn't remove it. Be clear about which side of the line each thing sits on.
What it protects. Encryption gates who can decrypt — only the SSH keys
listed in secrets/recipients.txt — and it protects the blob at rest in git.
That's what makes the committed secrets/*.age safe to push: without a listed
private key it's just ciphertext, so a leaked repo, a public mirror, or a
teammate who isn't a recipient learns nothing.
What it does NOT protect. Encryption does not sandbox the decrypted values
from local code. Anything holding both an authorized SSH private key and a
checkout of the repo can decrypt the whole vault — and that trust boundary is
any process running as you on a key-holding machine, not just you at the
keyboard. Such a process can run scripts/secrets-unseal.ts, call
age --decrypt directly, or simply read process.env after applyAgeSecrets()
auto-loads the vault at app startup. AI coding agents (Claude Code, Cursor,
and the like) with shell access sit squarely inside that boundary and can do all
three.
So the SSH private key — not the *.age blob — is the real asset: be
deliberate about running untrusted tooling (AI agents included) on a machine
that holds a recipient key, and remember that old blobs persist in git history,
so a leaked secret must be rotated at its source rather than merely re-sealed.
| Variable | Purpose |
|---|---|
VAULT_SECRETS_DIR |
The secrets/ directory to read (Nix package only — the Bun files resolve it relative to themselves). Skips discovery. |
VAULT_USER |
Override the OS login name used to find a personal override file. |
VAULT_AGE_IDENTITIES |
Comma/colon-separated SSH key paths to try for decryption (default ~/.ssh/id_ed25519, ~/.ssh/id_rsa). |
AGE_BIN |
Force a specific age-compatible binary. |
AGE_NIX_REF |
Flake ref for the nix run fallback (default nixpkgs#age). |
See secrets/README.md for the full seal/unseal,
add-a-participant, and personal-override workflows.
Seal runs age --encrypt --armor -R recipients.txt -o server.env.age server.env. Armor makes the output text; -R encrypts to every public key, so
each holder of a matching private key can decrypt.
Load (applyAgeSecrets()) resolves an age command ($AGE_BIN → age →
rage → nix run) and your SSH identities, then for each layer — personal
override first, shared vault second — runs age --decrypt -i <key> file.age,
parses the dotenv output, and merges it into process.env where the key isn't
already set. "Already set wins" gives the precedence shell env > personal
override > shared vault.
Unseal is the inverse of seal: age --decrypt -i <key> -o server.env server.env.age, writing a 0600 plaintext you edit and then re-seal.
That's the entire system: age for crypto, SSH keys for identity, a dotenv
parser, and a process.env merge.
MIT — see LICENSE.