Syncing env files across environments

As a project grows, its env files drift. A new key lands in .env.local, someone adds something to .env.production, and .env.example quietly falls behind. Dotvault gives you a few different ways to spot drift and fix it deliberately.

The .env.example prompt

The most common drift is between a working env file and the .env.example that’s meant to document it. After you save any .env file, Dotvault checks whether any of your new keys are missing from .env.example. If so, a prompt pops up listing the missing keys and offering to add them for you.

You get two choices:

  • Keys only. Adds the key names with empty values. Safe to commit to git.
  • Keys + values. Copies values along with the keys. Dotvault warns you before doing this if the target is .env.example, .env.sample, or .env.template, since those files are almost always committed.

You can selectively include or skip individual keys before confirming.

Three things about when the prompt appears. It needs an .env.example to already exist in the project folder, so create one once and the prompt takes over from there. It targets .env.example specifically, so a project that documents itself in .env.sample or .env.template will not get the automatic offer, though both are still recognised as risky sync targets. And it fires after saving any env file in the project except .env.example itself, so a key added to .env.staging gets caught the same way one added to .env does.

Keys you accept are appended to the end of the target file, under a # Synced from <file> comment naming the file they came from. If your example file is organised into sections, move them into the right one afterwards.

Sync is a write, so the target file gets a snapshot holding the content it ended up with, exactly as a save does. It is one snapshot per sync and not one per key, so syncing ten keys leaves one entry in History rather than ten, and it is labelled Synced from .env.production or whichever file the keys came from, which is what tells it apart from a save you made yourself. Undoing a sync you did not mean to make is a restore from the History tab (Snapshots and history).

Adding a variable and updating the example in the same commit

The habit that stops all of this from being a chore:

  1. Add the variable in the editor and save.
  2. Take the prompt, on keys only.
  3. Commit .env.example alongside the code that reads the variable.

The Git tab stages and commits env files, .env.example among them, so that half is one click. The code change goes through git the way it always does. If you stage it in your terminal first, the Git tab tells you how many other staged files will ride along in the commit, which for once is exactly what you want.

The single commit is the whole trick. A teammate who pulls gets the code that reads the new variable and the documentation of it at the same moment, so their app fails at startup with a name they can look up rather than at 3pm with a null. An example file updated three commits later is an example file nobody trusts, and once nobody trusts it, nobody updates it.

Your own .env is not part of that commit and should not be part of any commit. It stays gitignored. The example file is the part that travels.

The same applies in reverse when you remove a variable. Deleting it from .env and leaving it in .env.example is how people spend twenty minutes provisioning a service you stopped using last quarter.

What to put in an example value

Three options, all of them legitimate:

  • Nothing at all. DB_PASSWORD=. What keys only writes. Says “this exists and you have to supply it”, carries no information anyone could misuse, and is never the wrong answer.
  • A real, safe default. DB_HOST=127.0.0.1, APP_ENV=local, LOG_CHANNEL=stack. Turns the example into a working starting point, so a new clone boots. Only for values that are genuinely the same for everyone and are not secret.
  • An obvious placeholder. STRIPE_SECRET=sk_test_xxxxxxxx. Communicates the shape of the thing without pretending to be it. Useful when the format is not guessable.

What is never an option is a real value. Not an expired one, not a test-mode one, not “just for now”. A committed example file goes to everyone who clones the repo and stays in the history after you fix it, and if Dotvault is showing an exposed-secret warning on it, the mistake has already happened rather than being about to. Keys + values exists for real-to-real syncing, .env.local across to .env.staging, which is why Dotvault raises a warning the moment the target of a values sync looks like an example file.

One trade-off worth knowing when you choose between the first two. Dotvault marks a variable Default in the editor when its value still matches the value in .env.example, which is how you catch the placeholder nobody filled in. That check needs the example value to be non-empty, so it does nothing for keys-only entries. Empty values are the safest thing to commit; placeholders and safe defaults buy you a warning later. Both are defensible.

A variable that should not be in the example at all

Sometimes there is a key you do not want documented publicly. An internal hostname, a vendor you have not announced, something that names a system by name.

The mechanical answer first: there is no permanent ignore list, so a key that is missing from .env.example will be offered again the next time you save. Dismissing the prompt dismisses that one prompt.

The answer that actually works is to add the key with an empty value. Keys only writes the name and nothing else, which documents that the variable exists and has to be set without carrying anything about what it is or what it points at. The prompt then stops asking, because the key is no longer missing.

If even the name is too much, that is a useful signal in itself: the variable probably belongs in your deploy platform’s secret store rather than in a file in the repo, and the example file is not the thing that needs fixing.

The Diff tab

For manual, two-file comparisons — dev against production, for instance — open the Diff tab. Pick a source file and a target file and Dotvault lists every key across both, highlighting the rows where values differ or a key is missing from one side.

From the Diff tab you can:

  • Filter the list with the search box to narrow in on a prefix or service.
  • Switch between All, Differences and Missing, each showing a count.
  • Sync individual keys one at a time with the arrow buttons on each row.
  • Sync everything missing at once with the bulk Add N to file buttons at the top.
  • Switch the sync mode between keys-only and keys+values, just like the .env.example prompt.

The arrow buttons appear on rows where a key is on one side only. A row where both files have the key but the values disagree is marked Different and gets no arrow, deliberately: Dotvault has no way of knowing which of the two values is correct, and guessing would be worse than pointing at it and leaving it to you.

The Compare tab

Sometimes you don’t want a pairwise diff — you want a bird’s-eye view of every env file in the project at once. The Compare tab shows a matrix with one row per variable and one column per .env file. Rows where values differ across environments are highlighted, so you can scan top-to-bottom and spot the keys that look wrong.

Compare is read-only. It’s the tool you reach for when you’re asking “is anything weird here?”, not the one for making edits. Clicking a cell drops you into the Diff tab focused on that variable, which is where the editing happens. There is a shorter answer on the Compare tab on its own.

Encrypted files are included

You can sync between encrypted and plaintext env files transparently. Dotvault decrypts .env*.encrypted files on the fly for the Diff and Compare tabs, so you can keep an encrypted production file in sync with a plaintext local one without any manual steps at all.

The review habit

Drift comes back. What keeps it away is a thirty-second check rather than a policy.

Before you push, open the Diff tab with .env on one side and .env.example on the other, and switch the filter to Missing. It answers two questions at once. A key in .env and not in the example is something you have added and not documented. A key in the example and not in .env is either stale, or a service you have not set up yet, and it is worth knowing which.

Then read the example file top to bottom as though you had just cloned the repo. If you could not get the app running from it, neither can anyone else.

One thing that check does not tell you is whether your app will actually boot. A key missing from .env.example is a documentation problem. A key your framework cannot start without is a different problem with its own banner in the editor, driven by the frameworks and packages Dotvault found in the project rather than by your example file. The two are easy to conflate, and there is a guide on tracking down the second kind.

Managing drift over time

A few habits keep drift under control:

  • Always accept the .env.example prompt after adding a new key locally, even if the values are empty.
  • Use Compare periodically to catch silent divergence between environments.
  • Rotate secrets through Dotvault so every environment picks up the new value through a deliberate sync, rather than patching files ad hoc.
  • Commit the example file in the same commit as the code that needs it, every time.

If .env and .env.local are the two files you keep confusing, that is a guide of its own, and if the worry is what ends up in git, so is that.