Skip to content

Backup and Recovery

sysid edited this page Sep 16, 2026 · 1 revision

Backup and Recovery

Your vault holds the only copy of things that are, by design, absent from your project repos: env hierarchies, guarded secrets, swap overrides. This page covers how that data becomes durable, and how to get an earlier version back.

What Is Actually Durable

  ┌─ VAULT (~/.rsenv) ────────────────────────────────────────────────┐
  │                                                                   │
  │  vaults/myproject-abc123/                                         │
  │    envs/local.env              ← plaintext, GITIGNORED (*.env)    │
  │    envs/local.env.a1b2c3d4.enc ← committed  ●                     │
  │    guarded/secrets.yaml.b9c0d1e2.enc        ●                     │
  │    swap/docker-compose.yml                  ●                     │
  │    dot.envrc.f3a4b5c6.enc                   ●                     │
  │                                                                   │
  │  .git/  ← ONE repo at ~/.rsenv, covering every vault              │
  └───────────────────────────────────────────────────────────────────┘
                              │ rsenv vault commit --push
                              ▼
                        remote (your backup)

Only the ● files reach git. Plaintext *.env and *.envrc are gitignored — the patterns are derived from sops.file_extensions_enc, so they track your config automatically. Nothing recovers a .env you never encrypted.

Artifact Durable?
envs/*.env Only via its .enc — and only once encrypt has run
guarded/* Only via its .enc
swap/* overrides Yes, committed as-is (not secrets by definition)
dot.envrc Only via its .enc
*.bkp.env sweeps from rsenv env init Yes — the marker sits before the extension, so backups stay covered by gitignore and SOPS
Files currently swapped in No — the live bytes are in the project; the vault holds a frozen sentinel

Why the Hash Is Part of the Filename

Encrypted files are named {name}.{hash8}.enc, where hash8 is the first 4 bytes of the SHA-256 of the plaintext. This is the single design decision the whole backup story rests on, so it is worth explaining properly.

The question it answers

Given local.env and local.env.….enc side by side: is the ciphertext up to date?

Every obvious way to answer that fails:

Approach Why it fails
Re-encrypt and compare bytes SOPS output is non-deterministic — see below
Decrypt and compare Needs your private key for a routine status check
Compare mtimes git checkout, rsync and editors all rewrite mtimes
Sidecar manifest of hashes A second file that can drift out of sync, and merges badly

SOPS non-determinism is not a subtlety — encrypting the identical file twice produces a fresh data key, a fresh IV per value, and a fresh MAC:

$ sops -e --age $KEY --output a.enc t.env
$ sops -e --age $KEY --output b.enc t.env
$ diff a.enc b.enc
< export A=ENC[AES256_GCM,data:Jg==,iv:VovErgrYwe2...,tag:4MGnjm9J...]
> export A=ENC[AES256_GCM,data:GQ==,iv:2zlOCCtXZbG...,tag:obyyI6cv...]
< sops_mac=ENC[AES256_GCM,data:FpyKTXLNbck...]
> sops_mac=ENC[AES256_GCM,data:EuNLqpuj7J9...]

So ciphertext can never be compared to ciphertext. Something outside the encryption has to carry the identity of the plaintext.

The answer

Put the plaintext's hash in the ciphertext's name. The filename is the manifest — and unlike a sidecar file it cannot drift, because it travels with the file through git, rsync, branch merges and machine syncs. Comparing state is then a string comparison against a cheap local hash.

This one choice buys five behaviours:

Capability How the hash provides it
Status without keys rsenv sops status hashes the plaintext and parses the filename. It never invokes sops -d, so it works with no GPG agent and no YubiKey plugged in.
Pre-commit enforcement Same reason: rsenv sops status --check is a keyless exit code, so the hook can block a commit on any machine.
Idempotent encryption If {name}.{hash}.enc already exists, encrypt returns early without calling SOPS. Unchanged content therefore produces no new git blob — without this, non-determinism would churn a fresh blob on every single commit.
Safe cleanup rsenv sops clean deletes plaintext only when a hash-matching .enc exists. It is structurally incapable of destroying an unsaved edit.
Exactly one ciphertext Before writing a new hash, encrypt deletes prior-hash .enc files for that name, so a vault never accumulates ambiguous duplicates.

The trade-offs

Being explicit about the costs, because they shape how recovery works:

  • The filename is not encrypted, and neither is the hash. Anyone holding the repo can test a guessed plaintext offline against that 32-bit fingerprint. It does not reveal content, but it is metadata: it can confirm "this vault still holds the stock config". Treat a pushed vault remote as private regardless.
  • There is no version history on disk. Prior hashes are deleted on re-encryption. Every earlier version exists only as a git object.
  • Git cannot follow a file across versions. Because the name changes with the content, git sees a delete plus an add, and rename detection cannot link them (the ciphertexts share no bytes). git log --follow on a .enc path will not work. Recovery uses the recipes below.

Setting Up the Vault Repository

rsenv does not create this repo for you. rsenv vault init sets up the vault directory; the git repository is yours to create, once, at base_dir (default ~/.rsenv) — it covers every vault:

cd ~/.rsenv
git init
rsenv sops encrypt --global     # make sure nothing plaintext is left to stage
git add -A .
git commit -m "Initial vault"

Add a remote if you want off-machine durability:

git remote add origin git@github.com:you/rsenv-vault.git
git push -u origin main

Optionally install the keyless pre-commit guard:

rsenv hook install

If the repo is missing, rsenv vault commit fails with git's own "not a git repository" error and rsenv hook install reports Not a git repository: ~/.rsenv.

Routine Backup

rsenv swap out          # required: commit refuses unless the vault holds the live bytes
rsenv vault commit -a   # encrypts, stages, commits this project's vault dir alone
rsenv vault commit -a --push

vault commit re-encrypts before staging (sops.encrypt_on_commit, default true), so a .env you edited is captured as current ciphertext rather than stale. See Command Reference for the full step list and safety checks.

The swap out requirement is the one that bites. While files are swapped in, the live bytes live in your project and the vault holds only a frozen sentinel — that work is in no git repo at all until you swap out.

Recovery

List every recorded version of an env file

Because each version is a differently-named blob, list additions by glob rather than following one path:

cd ~/.rsenv
git log --diff-filter=A --name-only --date=short \
        --pretty='%C(yellow)%h %ad %s' \
        -- 'vaults/myproject-*/envs/local.env.*.enc'

Each hit is one saved version, newest first.

Restore a previous version

cd ~/.rsenv
git show <sha>:vaults/myproject-abc123/envs/local.env.a1b2c3d4.enc > /tmp/old.enc
sops -d --input-type dotenv --output-type dotenv /tmp/old.enc

The --input-type dotenv flags are required for .env files — rsenv passes them on every encrypt and decrypt of an .env, and SOPS will misparse the file without them. Other file types need no flags.

To adopt the old version, write it into place and re-encrypt so the filename hash matches again:

sops -d --input-type dotenv --output-type dotenv /tmp/old.enc \
     > ~/.rsenv/vaults/myproject-abc123/envs/local.env
rsenv sops encrypt
rsenv sops status        # should report local.env as current

Restore an entire vault onto a new machine

git clone git@github.com:you/rsenv-vault.git ~/.rsenv
rsenv sops decrypt --global

Decryption needs the GPG or Age private key the vault was encrypted to — the repo alone is not enough. Back that key up separately, by whatever means you already trust; rsenv does not manage it.

What Is Not Backed Up

Thing Why What to do
Plaintext never encrypted *.env / *.envrc are gitignored rsenv sops encrypt before committing
More than one generation of env init backups A sweep skips existing backups, so local.bkp.env is overwritten by the next env init rather than cascading Use the vault's git history for anything older
Legacy *.env.bkp files from rsenv 6.0.0 That extension is outside both the gitignore and file_extensions_enc, so encrypt skips them and vault commit refuses the whole commit Salvage what you need, then delete them (details)
Files currently swapped in Vault holds a frozen sentinel, not the live bytes rsenv swap out first
Your GPG / Age private key Deliberately outside rsenv's scope Back it up yourself

Related

rsenv Documentation

Getting Started
Features
Reference
Upgrading

Clone this wiki locally