Encrypted, Merged Environment
Perfect for people who need exactly this!
Purpose
Profile-based merging of environment files, encrypted with age and your SSH keys.
emergenv is built for git-based deploys to dedicated servers - the bare-metal,
VPS, and homelab world, not cloud platforms with a managed secret store. The encrypted
.age files live in the repo, just do a checkout, and emergenv build using the SSH
host key the server already has. No KMS, no secret-injection pipeline, no separate key
inventory - the secrets ride along in the repo and the box already holds the only key
it needs to decrypt.
At its simplest it's git-crypt for .env files - three commands. Everything else (DRY composition, profiles, variable substitution, integer arithmetic) is opt-in when you need it. See Simplest possible use.
Contents
- Purpose
- Why emergenv?
- License
- Installation
- Quick start
- Deployment
- Directory structure
- What to commit
- Encryption
- Decryption
- Profiles
- Syntax
- Expansion
- Base file
- Resolution order
- Command reference
- Recipes
- Security
Why emergenv?
Most secret tooling assumes the cloud: a managed KMS, a Vault cluster, or a platform
that injects env vars at runtime. The classic dedicated-server workflow looks nothing
like that - you use a triggered pull or you push to a bare git repo on the box itself
and let a post-receive hook check out and deploy. None of the cloud machinery fits,
and you don't want it.
emergenv targets that case specifically:
- No infrastructure. It needs only the
agebinary and SSH keys that already exist on the machines involved. A server decrypts with its own host key; people decrypt with the keys in their~/.ssh. - Encrypted in the repo.
.agefiles are committed, so a diff shows that a secret changed without revealing what. Plaintext.envfiles stay out of git. - Composition, not just storage. Unlike git-crypt or
sops- which encrypt and decrypt blobs - emergenv merges fragments across profiles and targets via@include/@<key>=, with per-line# FROM:provenance so you can see where every value came from and what overrode it. If your base.envis now 200 lines and yourdocker-compose.ymlis a copy/paste party rather than usingenv_file, this might be for you. - Compute, don't repeat. Beyond merging, values can be computed at build time -
${VAR}substitution (defaults, slicing, search/replace, case) and$(( ))integer arithmetic, opt-in per line via$/%. Assemble aDATABASE_URLfrom its parts, derive a port offset. There's no shell, so a password containing$(rm -rf)is data, never a command. (See Expansion / EXPANSION.md.) - It won't silently corrupt your secrets. Every encryption is decrypted again in memory and verified against the original before anything is written.
If you're on a cloud platform with a real secret manager, use that. If you deploy to dedicated servers from git, this is built for exactly that.
Why not sops?
emergenv uses bare age rather than sops. That does cost some sops conveniences:
sops-capable IDE plugins, seeing from a git commit which variables changed (without their
contents), and structured merging of conflicting edits.
We accept those costs because sops is unreliable for .env files (specifically): numerous
bugs can leave the encrypted text not matching the original plaintext - worse than unusable,
it's downright dangerous. emergenv's verify-on-write (above) is the direct response.
License
Released under the MIT license
Installation
You're going to need age. A lot of distros carry it,
apt install age if running something Debian-based.
Installation of emergenv itself is done through pip:
pip install emergenv
Quick start
Reading docs can get complicated fast, so here's a small end-to-end example:
First:
- Run
emergenv initto initialize - Append your server's public key to
emergenv/authorized_keys
Create the following files:
dot.emerg.env
@include database
$DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}The $DATABASE_URL line is computed - it is assembled from the other values at
build time (see Expansion).
Alternatively (it is assumed you use the above form, not this one), you could import per-key:
@DB_HOST=database @DB_PORT=database @DB_NAME=database @DB_USER=database @DB_PASS=database
dot.local.emerg.env (do NOT commit, add to .gitignore)
emergenv/database.env
DB_HOST=localhost DB_PORT=5432 DB_NAME=default DB_USER=default # if we exclude this one, the @DB_PASS import-by-key would error for our local build, the @include would be fine DB_PASS=invalid
emergenv/staging/database.env
emergenv/production/database.env
DB_PASS=production-password
Now encrypt everything:
Check our status - should be all AGE (green):
Now three different versions of .env can be produced, locally or on the server (after
a trip through git), each with a different DB_PASS:
emergenv build dot --profile production --no-local emergenv build dot --profile staging --no-local emergenv build dot
(--no-local isn't needed on the server as it doesn't have the local override file)
The contents of .env for the production profile:
# FROM: emergenv/database.age DB_HOST=localhost DB_PORT=5432 DB_NAME=default DB_USER=default # if we exclude this one, the @DB_PASS import-by-key would error for our local build, the @include would be fine # DB_PASS=invalid # FROM: emergenv/production/database.age DB_PASS=production-password # FROM: dot.emerg.env # COMPUTED: $DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME} DATABASE_URL=postgres://default:production-password@localhost:5432/default
Or if we also add the --bare parameter:
DB_HOST=localhost DB_PORT=5432 DB_NAME=default DB_USER=default DB_PASS=production-password DATABASE_URL=postgres://default:production-password@localhost:5432/default
Now that our files are encrypted, we can edit them like this:
emergenv edit production/database
Deployment
This is the case emergenv is built for. The encrypted .age files are committed, so a
deploy is just: get the code onto the box (a git pull, or a push to a bare repo with a
post-receive hook that checks out), then run emergenv build to produce the .env.
No secret-injection step, no separate key inventory - the box already holds a key that
can decrypt.
The server needs some private key whose public half is an authorized recipient. Two common choices:
-
A user key. Any deploy user can use its own
~/.ssh/id_ed25519(orid_rsa) - no root required. If the user doesn't have one yet, generate it:then add
~/.ssh/id_ed25519.pubas a recipient. -
The SSH host key.
/etc/ssh/ssh_host_ed25519_keyalready exists on every server, which makes it a convenient default - paste its.pubintoauthorized_keysand there's no key to generate. The catch: it's readable only by root, so the build has to run as root (usually fine for a root-owned deploy hook). There's nothing special about the host key; a user key is equally valid and avoids running as root.
After adding the server's public key to emergenv/authorized_keys (or a narrower set),
run emergenv rekey so the existing .age files gain the new recipient, and commit. On
the server the deploy step is then:
git pull emergenv build dot --profile production --no-local
(--no-local is for clarity only - the server has no .local override anyway.)
If the build reports "no usable decryption key found" and you are not root, the likely cause is that the only recipient is the root-owned host key. Either run the build as root, point
EMERGENV_KEYat a key the deploy user can read, or add that user's own key as a recipient (thenrekey).
Directory structure
git-root/
├── emergenv/
│ ├── .gitignore
│ ├── authorized_keys
│ ├── <target>.emerg.(age|env)
│ ├── <fragment>.(age|env)
│ ├── <profile>/
│ │ ├── authorized_keys
│ │ └── <fragment>.(age|env)
│ └── <target>/
│ ├── authorized_keys
│ ├── <fragment>.(age|env)
│ └── <profile>/
│ ├── authorized_keys
│ └── <fragment>.(age|env)
├── <target>.emerg.env
└── <target>.local.emerg.env
What to commit
The golden rule: plaintext secrets never enter git. A committed file is either
encrypted (.age) or contains no secrets.
emergenv/ ships its own .gitignore (created by init) that already keeps plaintext
fragments out of the store - so inside emergenv/ you don't have to think about it.
The files outside emergenv/ - the base file and the built output - are your
responsibility.
| Path | Commit? | Why |
|---|---|---|
emergenv/**/*.age |
yes | encrypted; the whole point |
emergenv/**/authorized_keys |
yes | public keys only |
emergenv/**/*.env |
no | plaintext; the store's .gitignore handles this |
<target>.emerg.env (base, in CWD) |
only if it has no secrets | otherwise move it into emergenv/ and encrypt it |
<target>.local.emerg.env |
no | local override; plaintext-only |
<target>.env / .env (built output) |
no | generated plaintext |
A base file that does nothing but @include other fragments holds no secrets, so it's
fine to commit in the working directory. The moment it contains a literal secret, move
it under emergenv/ (as emergenv/<target>.emerg.(age|env), see Base file)
so it gets encrypted like everything else.
Don't reach for a blanket *.env ignore. It would also catch <target>.emerg.env,
which you often do want to commit. Ignore the specific built outputs and the .local
file instead, e.g.:
.env # built output of the `dot` target *.local.emerg.env # local overrides # ...and any other built <target>.env you produce
Simplest possible use: just encrypt one file
If all you want is "encrypt my .env and regenerate it later", you need no profiles,
includes, or computed values at all:
mv whatever.env emergenv/whatever.emerg.env # move it into the store emergenv encrypt whatever.emerg # -> emergenv/whatever.emerg.age (plaintext removed) emergenv build whatever # -> whatever.env, exactly as before
build whatever finds emergenv/whatever.emerg.age, decrypts it in memory, and writes
whatever.env. Commit emergenv/whatever.emerg.age; the regenerated whatever.env
stays out of git.
Encryption
Files are en/decrypted using age. The latest binary should be on the path.
While emergenv supports plain text files as well, it will always prefer an .age
file over a .env file. Plain text files should not be committed, and by default are
ignored by the generated .gitignore.
One should endeavour to never have .env files in plain text unless you are actively
editing them - if another contributor updates the encrypted file, it's easy to overwrite
their updates with your own stale version. That is, unless you are not using encryption
at all.
authorized_keys
The authorized_keys files are the same ones you use for SSH. Just paste the public
keys for everyone who needs to decrypt into it. Only ssh-ed25519 and ssh-rsa are
supported (see the age binary).
Common sources for public keys:
Server (root only):
/etc/ssh/ssh_host_ed25519_key.pub
/etc/ssh/ssh_host_rsa_key.pub
User (may also be server):
~/.ssh/id_ed25519.pub
~/.ssh/id_rsa.pub
emergenv/authorized_keys is your base set, but you can override the set in every
subdirectory. Note that these do not extend each other, they are whole-file overrides.
When encrypting a file, the closest authorized_keys (walking up to emergenv/)
provides the recipients - so deeper directories can narrow who can decrypt.
Note: if your own key is not in the relevant authorized_keys file, encryption will
also fail.
Decryption
emergenv will attempt decryption using these private keys:
/etc/ssh/ssh_host_ed25519_key
/etc/ssh/ssh_host_rsa_key
~/.ssh/id_ed25519
~/.ssh/id_rsa
Every key whose .pub variant is listed in any authorized_keys is offered to
age, which uses whichever one actually decrypts the file.
This can be overridden using the EMERGENV_KEY environment variable, which
should point at a single private key file to use instead.
Profiles
A profile is a named overlay - a subdirectory under emergenv/ (and optionally under
a target directory) holding fragments that extend the base ones. They don't replace
the base fragment; its file and the profile's file are concatenated, and last-key-wins
applies to the result. So a profile that sets only DB_PASS changes just that key -
every other key from the base survives untouched. The obvious use is deployment
environments (dev, staging, production), but a profile is just a label: it works
equally well for regions (eu, us), tiers (free, pro), or any axis along which a
handful of values differ.
You select profiles at build time with --profile, and they layer left-to-right - when
the same key is set in more than one, the later one wins:
emergenv build dot --profile production emergenv build dot --profile production,eu
production,eu concatenates the base fragment, then the production fragment, then the
eu fragment; last-key-wins then resolves any key set by more than one of them (see
Resolving a fragment for the exact search order).
Profiles are entirely optional. If every environment shares the same values, or you keep
per-environment differences in the .local file, you may never define one - a build
with no --profile simply uses the base fragments.
Syntax
.emerg.env files are ordinary .env files with two extra directives:
@include <fragment>- splice in the entire resolved contents of<fragment>@<key>=<fragment>- emit a single<key>=<value>line, taking the winning value of<key>(case-sensitive) from the resolved contents of<fragment>
Both directives build on the same operation: resolving a <fragment> into one
merged env block. They differ only in how they consume that block.
Resolving a <fragment>
Base resolve order is:
emergenv/<fragment>.(age|env)
emergenv/<profile>/<fragment>.(age|env)
emergenv/<target>/<fragment>.(age|env)
emergenv/<target>/<profile>/<fragment>.(age|env)
For example, database referenced from my.emerg.env with profiles all and
prod is resolved by searching these paths, in order.
emergenv/database.(age|env)
emergenv/all/database.(age|env)
emergenv/prod/database.(age|env)
emergenv/my/database.(age|env)
emergenv/my/all/database.(age|env)
emergenv/my/prod/database.(age|env)
In short: each file contains overrides, the last one wins. The override is per key, not per whole file.
Every file that exists is concatenated in that order. For each path the .age
file is preferred, and its .env sibling is then ignored. Resolution is
recursive: a resolved file may contain its own @include / @<key>= directives,
which expand the same way - but the search paths always remain relative to the
original working directory, never to the file doing the include. Finally,
last-wins resolution (see Resolution order) is applied to
the concatenated result, yielding a single merged block.
Prefixing a <fragment> with ! makes it an absolute path within emergenv/: no
search is performed, and only emergenv/<fragment>.(age|env) is resolved (with the
! stripped). For example, @include !database resolves exactly
emergenv/database.(age|env), ignoring every profile and target variant. The
.age-over-.env preference, recursion, and the no-match error still apply.
If <fragment> matches no file at all, emergenv aborts with an error.
Note that if the current working directory does not contain an emergenv/ directory,
ancestors are searched, stopping at a git root boundary.
@include <fragment>
The directive line is replaced by the entire merged block for <fragment>.
@<key>=<fragment>
The directive line is replaced by a single line, <key>=<value>, where
<value> is the winning value of <key> within the merged block for <fragment>.
If <fragment> resolves but contains no <key>, emergenv aborts with an error.
Constraints
A <fragment> may contain / (but must not start with one) and must never contain
... It may begin with ! to force an absolute path within emergenv/ (see
above), in which case the remainder follows the same rules. Take care that
directives do not form a cycle.
Expansion
Besides the @include / @<key>= directives, a value can be computed from other
values with a marked assignment:
$<key>=<template>- expand<template>using the other built keys.%<key>=<template>- same, but the host environment is also available as a fallback (a locally-defined key always wins over the environment).
Templates support ${VAR} substitution (with defaults, substring, search/replace,
case, ...) and $(( )) integer arithmetic. Evaluation happens last, in order, so a
template may only use values defined above it. Plain <key>=<value> lines are
never expanded - expansion is opt-in per line via $/%. Both markers work with
export and travel through @include / @<key>=.
DB_HOST=localhost DB_PORT=5432 $DATABASE_URL=postgres://${DB_USER:-app}@${DB_HOST}:${DB_PORT}/${DB_NAME}
See EXPANSION.md for the complete, exact specification of every supported form (and how it deliberately differs from bash).
Base file
<target>.emerg.env files can be located in the current working directory. If
not found, it will also look for <target>.emerg.(age|env) in the emergenv/
directory, allowing it to partake in all encryption operations: there it is just
a fragment named <target>.emerg, so emergenv encrypt prod.emerg encrypts
emergenv/prod.emerg.env to emergenv/prod.emerg.age (and decrypt, status,
and clean work the same way). The .emerg infix keeps these base files in a
separate namespace from ordinary fragments, and ensures encryption never
overwrites the build input.
A base file in the current working directory shadows the one in emergenv/ (it
doesn't merge), so use one or the other, not both. Either location honours / in
<target> to reach into subdirectories - relative to the working directory for
the CWD base, relative to emergenv/ for the fallback.
Rule of thumb: if the base file doesn't actually contain any secrets, keep
it in the normal directory, next to where the built output file would
occur. If you don't do extensive inclusion and have secrets directly
inside of it, place it in emergenv/.
Local extension
The <target>.local.emerg.env file (always relative to the working directory)
is suffixed to the base file before processing, and may contain its own
@include / @<key>= directives. It is plaintext-only and should not be
committed.
Resolution order
In the final output file, the last variable of the same name wins. This is enforced by automatically commenting out preceding instances.
Command reference
Note: emergenv/ is located by searching the current working directory and then
its ancestors, stopping at a .git boundary (the repo root) - so a sibling
project's store is never picked up by accident. All <fragment>'s are relative to
that emergenv/; all <target>'s (and the build output) remain relative to the
current working directory itself.
Unlike the @include / @<key>= directives, a <fragment> given to these commands
is an exact path under emergenv/ (no profile/target searching). For
shell-completion convenience a leading emergenv/ and a trailing .age/.env
are stripped, so database, emergenv/database, and emergenv/database.age all
refer to the same name. A <fragment> may still contain / to reach files inside
profile and target subdirectories.
. is never a valid <fragment> (use dot for the .env target), and emergenv
is reserved as a first path component since it is ambiguous with the stripped
prefix.
init
Creates emergenv/, emergenv/.gitignore, and a base
emergenv/authorized_keys (containing the public keys from id_ed25519.pub and
id_rsa.pub in ~/.ssh if either is present, otherwise empty)
edit <fragment> [--wait]
Decrypts <fragment>.age to <fragment>.env, opens it with $VISUAL, $EDITOR,
/usr/bin/editor, or vi, and re-encrypts it after the editor closes.
If <fragment>.age doesn't exist, or <fragment>.env already exists, emergenv aborts with
an error.
If no editor can be executed or --wait is passed, instead it waits for the user to
press ENTER between decrypting and re-encrypting, allowing the user to edit the file
in for example their IDE.
The decrypted file is removed after having been re-encrypted.
decrypt [<fragment>|--all]
Decrypts <fragment>.age to <fragment>.env, aborting with an error if either <fragment>.age
doesn't exist, or <fragment>.env already exists.
If --all is passed rather than a <fragment>, all .age files are decrypted,
overwriting any existing .env files.
encrypt [<fragment>|--all] [--keep]
Encrypts <fragment>.env to <fragment>.age, overwriting <fragment>.age if it exists, and
aborting with an error of <fragment>.env doesn't exist.
If --all is passed rather than a <fragment>, all .env files are encrypted
(overwriting).
For both cases, unless --keep is passed, the .env files are deleted after
encryption.
Before overwriting, an existing <fragment>.age is decrypted and compared: if it
already decrypts to the same plaintext, it is left untouched. (age ciphertext
is non-deterministic, so re-encrypting unchanged contents would otherwise churn
git history and the file's timestamp for no real change.)
rekey
Re-encrypts every fragment to its current recipients. Run this after editing any
authorized_keys file, so the new recipient set is applied across the store.
It is similar to encrypt --all, but always rewrites each <fragment>.age -
the plaintext is unchanged, but the recipient set is not, and age ciphertext
does not expose its recipients for the usual unchanged-skip to compare against.
It works entirely in memory, in two passes, so that a detectable problem aborts before anything is written:
- Verify. Every
<fragment>.ageis decrypted in memory (you must be a recipient of all of them, as with any decryption). If a plaintext<fragment>.envsits beside an.ageand their contents differ, emergenv cannot know which is authoritative - perhaps the.ageis newer from a pull, perhaps the.envis a pending edit - so it aborts and lists the offenders. Make sure your working tree is clean first (encrypt,decrypt, orclean). A fragment that is.env-only (no.age) is adopted: its.agewill be created. - Write. Each collected plaintext is re-encrypted to its nearest
authorized_keys, overwriting the<fragment>.age.
.env files are never created, written, or removed. A matching .env you had is
left in place - run clean afterwards if you want the plaintext gone (including
for any .env-only fragment that was just adopted).
Note that pass 2 is not atomic: if it fails partway (for example a verify-on-write
error), some .age files will already be on the new recipient set and others not.
Fix the cause and run rekey again - it is safe to repeat.
Because of this, run rekey on a clean working tree (everything committed) and
commit its result as a single change. Then a rare partway failure is trivially
recoverable - git checkout back to the clean state and retry, rather than reasoning
about a half-rewritten tree. The pass-1 pre-flight makes such a failure unlikely; the
clean-tree habit makes it cheap regardless.
There is intentionally no per-file rekey. It operates on the whole store on
purpose: picking individual files makes it far too easy to leave one behind, and
an omitted file is invisible - age ciphertext does not reveal its recipients, so
nothing (not even status) can later tell you a fragment is still encrypted to a
key you meant to revoke. Re-keying everything is the safe default; the only cost
is git churn, which is cheap and, as a single commit, doubles as a clean record
that a rotation happened.
status
As a pre-flight, status first verifies you are a recipient of every
authorized_keys set under emergenv/ - it probes each set with your identities
(honouring EMERGENV_KEY, exactly as real decryption would). If any set does not
include you, it prints those sets and aborts with exit code 3, since you would
otherwise only discover the lockout when an encrypt/edit/rekey write fails
for a file governed by that set. When you are a recipient everywhere it says
nothing and proceeds to the listing.
It then lists every <fragment> and its state, one per line as <fragment> <status>:
| status | meaning | colour | code |
|---|---|---|---|
AGE |
only the encrypted file exists | green | 0 |
AGE+ENV MATCH |
both exist and the .env matches the .age |
orange | 1 |
ENV |
only the plaintext file exists | orange | 1 |
AGE+ENV MISMATCH |
both exist but differ | red | 2 |
ERROR |
the .age could not be decrypted |
red | 3 |
Every fragment is listed even if some fail to decrypt. The process exit code is the
highest code shown (so it is 0 only when everything is encrypted-only, great for
a pre-commit hook). Colours are emitted only to a terminal, and suppressed when
NO_COLOR is set.
clean
Deletes all .env files for which a .age file exists with the same contents.
build [--profile [,[...]]] [--no-source] [--bare] [--no-local] [--output ]
Builds <target> and writes the result to <target>.env in the working
directory (which should not be committed). The base file (and its optional
.local extension) is resolved as described under Base file.
--profile takes a comma separated list of profiles, processed in the passed order.
A profile must be a single path segment (no /).
By default each emitted line is annotated with a # FROM: <path> comment marking
the source file it came from (one header per contiguous run, so it isn't repeated
per line). Overridden values stay visible - commented out - under their own
source, so you can see exactly where every value came from and what shadowed it.
Pass --no-source to omit these markers.
Pass --bare to strip the output of all comments and blank lines entirely,
leaving only the winning assignments (one line per variable).
For tidy output, blank-line handling is normalised (whitespace-only lines count
as blank): any run of consecutive blank lines collapses to a single blank, files
are separated by one blank line, leading/trailing blanks are dropped (the output
never ends with a blank line), and a # FROM: header is shown only before actual
content - a source that contributes nothing but a blank line gets no header.
Pass --no-local to ignore the <target>.local.emerg.env override and build from
the base file alone (useful for reproducing the committed/deploy build while a
local override is present).
Pass --output <filename> to override the output filename, use - as filename
to write to stdout.
While building, progress is logged to stdout - the target name, profiles, the
resolved emergenv/, base, and .local paths (absolute), each imported fragment
(emergenv/-relative, only files that actually resolved), and the output path:
building: dot
profiles: dev staging
emergenv: /home/user/myproject/emergenv
target: /home/user/myproject/emergenv/dot.emerg.age
local: /home/user/myproject/dot.local.emerg.env
importing: database.age
importing: dot/dev/database.age
writing: /home/user/myproject/.env
When --output - is used these logs are dropped entirely so stdout carries only
the built environment (errors still go to stderr).
A <target> may be given with a .emerg.(age|env), .local.emerg.(age|env), or
.env suffix (stripped for shell-completion convenience).
The .env output (dot target)
To produce a plain .env file, use the reserved target name dot: it behaves
like any other target - base dot.emerg.env (or emergenv/dot.emerg.(age|env)),
local dot.local.emerg.env, override directory emergenv/dot/ - except the
final output is written as .env rather than dot.env. Consequently dot is
reserved: you cannot define a target whose output is literally dot.env. The
bare names . and "" are never valid as a <target> or <fragment>.
Recipes
docker-compose
Two distinct uses, both common:
Variables for the compose file itself. Compose automatically reads a .env from the
project directory and uses it to interpolate ${VAR} references in docker-compose.yml
(image tags, ports, volume paths, ...). Build the dot target and Compose picks it up
with no extra configuration:
emergenv build dot # -> .envEnvironment for a container. To set the variables a service actually runs with, build
a separate file and point that service's env_file at it:
emergenv build app # -> app.envservices: app: env_file: app.env
Keeping the two files separate (the .env Compose interpolates with, and the app.env
a container runs with) keeps build-time and runtime variables from bleeding into each
other.
Consume it from systemd
[Service] EnvironmentFile=/srv/app/.env ExecStart=/srv/app/run
Regenerate the file (emergenv build dot) before systemctl restart.
Rotate a secret
emergenv edit production/database # decrypts, opens $EDITOR, re-encrypts on close git commit -am "rotate db password"
The .age diff shows that it changed without revealing the new value. Redeploy as
usual.
Revoke someone's access
Remove their public key from every authorized_keys file that lists it.
authorized_keys files are whole-file overrides, not additive - a subdirectory's set
doesn't inherit from emergenv/authorized_keys, so a key left in even one subdir set
keeps decrypting the fragments that set governs. Then re-encrypt every fragment to the
new recipients:
emergenv rekey
git commit -am "revoke <name>"rekey rewrites all .age files (see rekey); there is deliberately no
per-file version, because an .age doesn't reveal its recipients and a forgotten file
would silently stay readable by the revoked key. See Security on why this
isn't enough when a key is actually compromised.
Onboard a new server
Add the server's public key - its host key, or a deploy user's ~/.ssh/id_ed25519.pub
(see Deployment) - to every authorized_keys file governing a fragment
the server must read. Since authorized_keys files are whole-file overrides (a
subdirectory's set replaces the base one rather than extending it), adding only to
emergenv/authorized_keys leaves any fragment in a subdirectory that has its own set
undecryptable by the new key. Then:
emergenv rekey
git commit -am "add <server> as recipient"The server can decrypt on its next pull.
Security
What's protected. Secret contents never enter git in the clear. Each .age file
is encrypted to a specific set of recipients, so a leaked repo - or a contributor who
isn't a recipient - reveals nothing but the fact that an encrypted blob exists and
changed. age also hides the recipient list: the ciphertext doesn't say who can decrypt
it (which is exactly why rekey is all-or-nothing).
Integrity. Every encryption is decrypted again in memory and compared against the
original before anything is written, so a corrupt or mismatched write is caught rather
than silently committed. This is the core reason emergenv uses bare age instead of
sops (see Why not sops?).
No shell. Expansion is pure-Python with no command execution - there is no command
substitution at all. A value like pass=$(rm -rf ~) or pass=`whoami` is inert
data, never a command, even on a $/% computed line.
What's not protected - and is your responsibility:
- The built output is plaintext.
.env/<target>.envon disk holds real secrets. Keep it out of git (it already should be) and restrict its file permissions; on a server, only the deploying user and the service that reads it need access. - Recipients see everything they're a recipient of. Access is per-directory
(
authorized_keysgranularity), not per-value - there's no way to hand someone a single key out of a fragment they can otherwise decrypt. - An SSH host key used as a recipient becomes secret-grade. Using the server's
/etc/ssh/ssh_host_*_keyas the decryption identity is convenient (see Deployment), but it means that key now decrypts every secret it is a recipient of - and host keys get backed up, snapshotted, and copied with server images in ways their owners rarely treat as sensitive. Anywhere that key lands, your secrets can be decrypted. A dedicated deploy-user key (generate one just for this) keeps the blast radius to a single key you manage deliberately, and avoids running the build as root. - Revocation is forward-only. Removing a key and re-keying stops it decrypting
future commits, but git history still contains the old
.agefiles, and the revoked key can still decrypt those. So whenever access genuinely needs to end - a compromised key, or a person who has left - rotate the secrets themselves (edit+ commit), not just the recipient set. Re-keying changes who can read new commits; only a new secret value invalidates what the old key already saw.