Shipping

Every .env file in my projects is gone and nothing broke

A local secrets file is a copy nobody controls. An encrypted vault that materializes per ecosystem removes the copy rather than protecting it.

MobileByteSensei 10 min read
Two locks on one hasp, and the identical casks standing outside under nobody's
secretstoolingshipping

There is no local secrets file in any project I work on. Not ignored, not encrypted in place, not reduced to a template — absent.

Nothing about the development loop got harder, which was not what I expected when I started.

The case this post is about

One .env file that everybody agreed was temporary.

It held four values. It was gitignored, it was created during onboarding by copying .env.example and filling in the blanks, and the blanks were filled in by asking a colleague over chat.

So there were as many copies as there were developers, each slightly different, none of them enumerable, and rotating any one of those values meant finding every machine it had been pasted on. The file is not the problem; the fact that it is the only record of the value is.

This post follows that one file, because replacing it is not "use a secret manager" — it is that secrets have to arrive in the form each ecosystem already expects, at the moment it expects them, or the dotfile comes back.

The symptom

The file is agreed to be temporary by everybody who has ever touched it. It is created during setup, it is meant to be replaced by something better, and it is still there four years later with a production credential in it that somebody pasted during an incident.

The second thing is that it multiplies without anybody deciding to. One per project, then one per project per machine, then a copy in a scratch directory from a debugging session, then one in a colleague's downloads folder because that is how the value got to them. Every copy is equally live and no copy knows about the others, so a rotation updates one of them.

Copies accumulated without a system, in a yard where nobody could state the total

The third is the near-miss that everybody has. The file is ignored by version control, which is correct and is a single line of configuration away from not being true. A renamed file, a new directory layout, a fresh clone with a slightly different ignore set, a git add -f typed by somebody in a hurry — the protection is real and it is one mistake deep.

The fourth is the part I underestimated: it makes handing a secret to a teammate an unsolved problem every single time. There is no mechanism, so the mechanism becomes whatever is to hand — a chat message, a screenshot, a paste in a ticket. Each of those is a new permanent copy in a system that was not built to hold one, and the convention that forbids it is competing with somebody needing to work in the next five minutes.

The competition is not close, and it should not be. Somebody blocked on a credential is blocked on real work, and a policy whose honest instruction is "wait" will lose to a chat message every time. Any version of this that does not make the correct path the fastest one is a policy that gets quietly routed around by people doing their jobs.

Why the obvious fix failed

Encrypt the file in place. Better than plaintext and keeps the structure that causes the problem: there is still a per-machine file, still copies, still a decryption key that has to travel to each machine by some means. The thing that needed removing was the copy, not the readability of the copy.

A hosted secrets manager. The right answer at a certain scale and it did not fit mine. It adds a service to depend on, an availability question during local development, and an access model to maintain — for a handful of projects where the real requirement was one controlled place and a way to get values out of it.

I would not argue against one for a team of any size. The thing worth separating is the shape of the answer from the product that implements it: one controlled store, values materialized at point of use, no durable local copy. A hosted manager gives you that shape with an invoice and an uptime dependency; a repository of encrypted files gives you the same shape with neither, and a worse story if you ever need audit logs or per-environment access.

Commit an example file and document the rest. Universal and it addresses the documentation problem rather than the secrets problem. Everybody now knows which keys they need and the question of how a value reaches a machine is exactly where it was.

A stricter ignore rule and a pre-commit hook. Both worth having, both guards on the last step. A hook that refuses a commit containing a secret is a good hook and it does nothing about the copy in the downloads folder, the one in the chat history, or the fact that the value had to be transmitted somehow in the first place.

The actual mechanism

Keep one encrypted store per organisation, and have values materialize in the form each ecosystem expects, at the moment they are needed.

vault        a private repository, SOPS + age encrypted, one per org
pull         materializes per ecosystem — local.properties, secrets.local.json
layout       the canonical path per (stack, kind) is resolved, never hardcoded
forbid       a mere .env on any project with a manifest fails the build

The second line is what makes the absence tolerable, and it is the part most alternatives skip. A Kotlin project wants local.properties; a Node project wants a JSON file; a Python project wants a TOML one. If the vault produces only one format, every consumer needs a shim, and shims are where the copies come back. Producing the native artifact means the build reads the file it was always going to read, and nothing downstream knows a vault exists.

One tap, four unlike vessels, each the form its own trade uses

The sub-case that decides whether anybody adopts this is intake — how a value gets into the vault in the first place. The naive answer is to type it into a command, which puts it in shell history and in the process table. What works on a machine with a system keychain is to prompt for it in a dialog, hold it in the keychain, and pipe it into the vault without it ever being an argument or appearing on a terminal. The value goes from the person's clipboard to encrypted storage without existing anywhere in between.

There is a related property that turns out to matter more than the encryption: the vault is a list. Because there is one store, the question "what credentials does this organisation have" has an answer, and so does "which of them has not been rotated this year". With files scattered across machines, neither question is answerable at all, and a security review consists of asking people.

Ranked stillage, where the stock and the shortfall are both countable

The fix

one store      per organisation, encrypted, access by membership
materialize    native per ecosystem, on demand, gitignored output
resolve        paths from a declared layout, never written by hand
block          a committed secret at the hook, and the dotfile at the gate
migrate        adopt existing files, then delete them

The middle two lines are the mechanism, and they are what makes the first one usable. A project declares which aliases it needs and when; nothing says where a file goes, because the layout is a lookup rather than a decision made per project:

# secrets-manifest.yaml — a pure alias list. No paths, no values, no formats.
schema: 3.0
needs:
  - alias: acme-firebase-sa          # org-shared: one value per workspace
    materialize: { at: build }
  - alias: storefront-supabase-url
    materialize: { at: dev }
# secrets-resolve-path.sh — the ONLY thing that turns (stack, category) into a path.
# Every consumer calls this; nobody hardcodes a destination.
resolve_path() {
  local alias="$1" stack category
  stack=$(registry_get "$alias" tech_stack)      # kmp | node | python
  category=$(registry_get "$alias" category)     # env_var | android_keystore | …

  case "$stack:$category" in
    kmp:env_var)            echo "local.properties" ;;
    kmp:android_keystore)   echo "keystores/upload.jks" ;;
    node:env_var)           echo "secrets.local.json" ;;
    python:env_var)         echo "secrets.local.toml" ;;
    *) echo "unmapped ($stack:$category) — add a row to SECRETS_LAYOUT.yaml" >&2
       return 4 ;;
  esac
}

The return 4 is the part that matters. An unmapped combination is a loud failure with the file to edit named in it — not a default path that happens to work on one machine and writes a secret somewhere nobody is looking.

Materialising a project, then checking the result is ignored:

$ /secrets pull
  acme-firebase-sa              → app/google-services.json        (build)
  storefront-supabase-url   → local.properties                (dev)
  2 materialized · 0 unmapped

$ git check-ignore -v app/google-services.json local.properties
.gitignore:14:app/google-services.json
.gitignore:15:local.properties

The last line is the one that determines whether this ever finishes. A migration that adds a vault and leaves the files produces a system with two sources of truth, which is worse than either — and because the files still work, nothing forces the conversion. The adoption step that reads existing files, imports their contents and then removes them is the whole migration, and doing it per project in one pass is much better than gradually.

What I would tell somebody weighing this is that the payoff is not primarily about security. It is that a new machine, or a new person, becomes one command rather than an afternoon of asking around — and that the question of how a value travels, which used to be answered ad hoc each time, has exactly one answer that everybody uses because it is easier than the alternatives.

That last clause is doing the real work. Every version of this that relies on people choosing the secure path over the convenient one decays, because the convenient path is still there and the pressure to use it arrives on the worst day. The reason this one has held is not discipline; it is that pulling from the vault is genuinely less effort than finding somebody who has the value.

How we proved it

The absence is checkable, which is the cleanest part of this. A gate that fails any project with a secrets manifest and a dotfile present turns the claim into a property. It is not an aspiration about hygiene; either the files are gone or the build is red.

The same rotation, reaching copies and reaching consumers

The materialized output is the real test. A build that runs, from a fresh clone, with no manual step beyond one pull command, demonstrates that the vault produces what each ecosystem actually needs. That is the thing the shim-based approaches fail, and it fails loudly rather than subtly.

What I cannot tell you is whether it prevented an incident. I have no case of a leaked credential that this stopped, because the counterfactual is unobservable. What I can say is that the number of places a given value exists went from unknown to one, and that the unknown was the thing I actually objected to.

What I have not solved: the bootstrap. The vault is encrypted, so a new machine needs a key to read it, and that key has to arrive somehow — which is the original problem, reduced to exactly one instance. It is a much better position than one instance per secret, and it is not nothing, and anybody adopting this should plan that step deliberately rather than discover it.

And a cost that is real: this is more moving parts than a file. There is a store, an encryption scheme, a membership model, a materialization step and a layout registry, and every one of them is something that can go wrong on a Monday morning when somebody just wants to run the app. A dotfile has no moving parts at all, which is precisely why it survives everything.

The thing I got wrong initially was treating the migration as optional per project. I added the vault, moved the secrets that were easy, and left the awkward projects for later — which meant that for several months the honest answer to "where does this credential live" was "it depends", and that is a worse answer than either of the two states it sat between.

What this generalizes to

A convention that everybody agrees with and nothing enforces will be violated at exactly the moment it matters, because that is the moment somebody is in a hurry.

The pattern is a practice that is correct, documented, universally endorsed and entirely dependent on people choosing it under pressure. Not committing generated files. Not logging personal data. Not hardcoding a timeout. In every case the discipline holds for months and fails during the incident, and the failure is by the most experienced person present, because they are the one doing the emergency work.

So the useful question about any practice you rely on is what makes it true. If the answer is that everybody knows, it is not true yet — it is a thing that is currently the case. Making it structural usually means removing the alternative rather than warning against it, and the test of whether you have done that is whether the wrong thing is still available to somebody who wants it at two in the morning.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS