From fc1d31244ab19f05bdef85dbfb34c17c993ff0fe Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 08:47:19 +0200 Subject: [PATCH] docs(secrets): add beginner walkthrough for sops-nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/secrets-management.md walks through getting secrets out of .env and into sops-encrypted YAML, assuming zero familiarity with sops or age. Companion to the wiring landed in the prior commit. Sections: - Why bother — the .env failure modes (history leaks, plaintext on disk) and what sops/age fixes. - What's in the box — pointers to the existing scaffold (flake input, modules/secrets.nix, .sops.yaml, .gitignore guards), why it's inert until the first encrypted file lands. - Step-by-step — install tools, generate the age key (with a clear warning about back-ups + no recovery), paste the public key into .sops.yaml, create the first encrypted file via `sops`, declare secrets in NixOS, reference via config.sops.secrets..path, activate. - Common operations — edit / view / rotate / updatekeys. - Multi-host server deployment — per-host age keys at /var/lib/sops-nix/key.txt, path_regex-scoped recipients in .sops.yaml so each host only decrypts its own secrets. - Pitfalls — don't commit unencrypted YAML, don't lose the key, updatekeys ≠ rotation, sops -d outputs are sensitive, the pathExists gate fails-silently-on-delete trap. Linked from README "Further reading" with a one-liner noting the sops-nix wiring already ships with the scaffold. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 + docs/secrets-management.md | 313 +++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 docs/secrets-management.md diff --git a/README.md b/README.md index 43fbc08..76cc987 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,12 @@ top of the human-facing docs, not a replacement for them. Vue/Quasar UMD traps in lnbits page templates: no self-closing tags, CSS specificity vs Quasar's `!important` utilities, cache busting via `?v={server_startup_time}`, dark-mode color discipline. +- [`docs/secrets-management.md`](docs/secrets-management.md) — + beginner-friendly walkthrough for getting secrets out of `.env` + and into sops-encrypted YAML files: generating an age key, adding + recipients, declaring secrets in NixOS, rotating, multi-host + server setups, and common pitfalls. The scaffold ships the sops-nix + wiring already (inert until you create your first encrypted file). ## Contributing to this scaffold diff --git a/docs/secrets-management.md b/docs/secrets-management.md new file mode 100644 index 0000000..b94509e --- /dev/null +++ b/docs/secrets-management.md @@ -0,0 +1,313 @@ +# Secrets management with sops-nix + +A beginner-friendly walkthrough of getting secrets out of your +`.env` files and into `git` safely, using +[sops-nix](https://github.com/Mic92/sops-nix). Assumes no prior +familiarity with sops, age, or NixOS secret management. + +## Why bother + +Most LNbits dev setups start with secrets in `.env`: + +```env +LNBITS_ADMIN_KEY=changeme-real-key-goes-here +POSTGRES_PASSWORD=changeme-real-password-goes-here +``` + +This works, but `.env` files have two failure modes: + +1. **They land in git accidentally.** A `git add -A` in the wrong + moment, a forgotten `.gitignore` line, and the key history-leaks + into a public repo. Rotating after the fact is painful — there's + no fixing what's already in `git log`. +2. **They live as plaintext on disk.** A backup of your dev box, a + shared filesystem snapshot, a forgotten copy in `~/Downloads/` — + all paths to the same compromise. + +sops + age fixes both. Secret values get encrypted into a YAML file +checked into your repo. The encrypted file is safe to commit, push, +and back up. Decryption happens transparently at NixOS activation +time on the host that has the matching private key. Each secret +becomes a file under `/run/secrets/` that your services read. + +## What's in the box (lnbits-sensei scaffold) + +This repo already wires sops-nix: + +- **`flake.nix`** declares `sops-nix` as a flake input. +- **`modules/secrets.nix`** imports the NixOS module and points it at + `secrets/${hostName}.yaml` + your private key at + `~/.config/sops/age/keys.txt`. +- **`.sops.yaml`** declares the recipients (which public keys can + decrypt files under `secrets/`). +- **`secrets/`** is gitignored except for `*.yaml` (which is + encrypted) and `README.md`. +- **`configuration.nix`** imports `modules/secrets.nix`. + +All of this is **inert until you create your first encrypted file**. +`nix flake check` stays green meanwhile because the module gates on +`builtins.pathExists`. + +## Step-by-step + +### 1. Install the tools + +`sops` and `age` ship in nixpkgs: + +```nix +# in modules/packages.nix or home.nix +environment.systemPackages = with pkgs; [ sops age ]; +``` + +After `nixos-rebuild switch`, `sops` and `age-keygen` are on PATH. + +For an ad-hoc install without rebuilding: + +```sh +nix-shell -p sops age +``` + +### 2. Generate your age key + +The age private key is the one piece of state that lives outside git +— it's how this machine decrypts secrets at activation time. Generate +it once per host: + +```sh +mkdir -p ~/.config/sops/age +age-keygen -o ~/.config/sops/age/keys.txt +``` + +The file looks like: + +``` +# created: 2026-05-26T... +# public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +AGE-SECRET-KEY-1YYYY... +``` + +The line starting with `AGE-SECRET-KEY-` is your **private key**. +**Back it up somewhere safe** (a password manager, a hardware key, a +trusted offline drive). If you lose it, you lose access to every +secret encrypted to it — there is no recovery. + +Print just the **public key** (you'll paste this into `.sops.yaml`): + +```sh +age-keygen -y ~/.config/sops/age/keys.txt +# age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 3. Add your public key to `.sops.yaml` + +Open `.sops.yaml` at the repo root. Replace the placeholder +`age1REPLACEME...` with your real public key from the previous step: + +```yaml +keys: + - &admin age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +creation_rules: + - path_regex: secrets/.*\.yaml$ + key_groups: + - age: + - *admin +``` + +What this says: any file matching `secrets/*.yaml` should be +encrypted for the recipients listed under `key_groups.age`. Right +now that's just `*admin` (a YAML anchor pointing at your key). When +you have collaborators, you'll add their public keys as additional +recipients here. + +### 4. Create your first encrypted secrets file + +`sops` is the editor wrapper. It opens your `$EDITOR` on a plaintext +view of the file; on save it encrypts in place. + +```sh +sops secrets/.yaml +``` + +(Use the same `hostName` you put in `settings.nix` — that's what +`modules/secrets.nix` looks for.) + +Your editor opens an empty buffer. Write your secrets as plain YAML: + +```yaml +lnbits-admin-key: changeme-real-key-goes-here +postgres: + lnbits-password: changeme-real-password-goes-here +``` + +Save and exit. sops encrypts in place. Cat the file to see what +landed on disk: + +```sh +cat secrets/.yaml +``` + +You'll see the keys are now opaque base64 blobs, and a `sops:` +metadata block at the bottom describes which recipients can decrypt. +This file is **safe to commit and push**. + +### 5. Declare each secret in your NixOS config + +For each secret you want exposed to a service, add a +`sops.secrets.` declaration in the consuming module: + +```nix +# in the module that consumes the secret +{ config, ... }: +{ + sops.secrets.lnbits-admin-key = { + mode = "0400"; + owner = config.lnbits-sensei.user; + }; +} +``` + +The `` matches the YAML key in your encrypted file. Dots in +the key become nested paths under `/run/secrets/`: + +| YAML key | Runtime file | +|---|---| +| `lnbits-admin-key` | `/run/secrets/lnbits-admin-key` | +| `postgres/lnbits-password` (nested) | `/run/secrets/postgres/lnbits-password` | + +### 6. Reference the secret from a service + +Read the runtime path via `config.sops.secrets..path`: + +```nix +# example: passing the LNbits admin key file to a service +services.someThing.adminKeyFile = + config.sops.secrets.lnbits-admin-key.path; +``` + +Use a file-path reference (not the value directly) wherever the +service supports one — that way the secret never ends up in the +Nix store, just at `/run/secrets/` on the running host. + +### 7. Activate + +```sh +sudo nixos-rebuild switch --flake .# +``` + +At activation, sops-nix decrypts `secrets/.yaml` using the +private key at `~/.config/sops/age/keys.txt`, writes each declared +secret as a file under `/run/secrets/`, and your service reads from +the file path. Done. + +## Common operations + +**Edit a secret:** + +```sh +sops secrets/.yaml +# editor opens with plaintext view; sops re-encrypts on save +``` + +**View a secret without editing:** + +```sh +sops -d secrets/.yaml | grep lnbits-admin-key +``` + +**Rotate to new recipients (e.g. add a collaborator):** + +1. Add their public key to `.sops.yaml`: + ```yaml + keys: + - &admin age1xxx... + - &alice age1zzz... + creation_rules: + - path_regex: secrets/.*\.yaml$ + key_groups: + - age: + - *admin + - *alice + ``` +2. Re-encrypt every file to the new recipient set: + ```sh + sops updatekeys secrets/.yaml + ``` + +**Remove a recipient:** edit `.sops.yaml`, then `sops updatekeys`. +**Also rotate the underlying secret values** — anything the removed +party could previously decrypt is now compromised. + +## Multi-host / server deployment + +When you have one repo deploying multiple machines, each machine +needs its own age private key, and each machine's secrets file +needs to be encrypted to that machine's public key in addition to +the operator's. + +**On each NixOS host (one-time):** + +```sh +sudo mkdir -p /var/lib/sops-nix +sudo age-keygen -o /var/lib/sops-nix/key.txt +sudo chmod 600 /var/lib/sops-nix/key.txt +sudo age-keygen -y /var/lib/sops-nix/key.txt +# copy this public key into .sops.yaml as a new recipient +``` + +In `modules/secrets.nix`, swap `age.keyFile` for the host-local path: + +```nix +sops.age.keyFile = "/var/lib/sops-nix/key.txt"; +``` + +Then in `.sops.yaml`, list every host's public key alongside the +operator's, scoped via `path_regex` if you want per-host isolation: + +```yaml +keys: + - &admin age1xxx... # operator (laptop / dev box) + - &host-a age1aaa... # server A + - &host-b age1bbb... # server B +creation_rules: + - path_regex: secrets/host-a\.yaml$ + key_groups: + - age: + - *admin + - *host-a + - path_regex: secrets/host-b\.yaml$ + key_groups: + - age: + - *admin + - *host-b +``` + +That way `host-a` can only decrypt its own secrets, `host-b` only +its own, and the operator can decrypt both. + +## Pitfalls + +- **Don't commit unencrypted YAML under `secrets/`.** The pre-commit + hook most consumers use catches obvious cases, but it's not + foolproof. Always verify with `cat secrets/.yaml` that you + see `sops:` metadata + base64 blobs, not plaintext. +- **Don't lose the private key.** No recovery. Back up the file at + `~/.config/sops/age/keys.txt` (or `/var/lib/sops-nix/key.txt` on + servers) somewhere safe and offline. +- **`updatekeys` re-encrypts but doesn't rotate secret values.** If + you're removing a recipient because they shouldn't have access + anymore, also change the underlying secrets — they could've + cached the decrypted values while they had access. +- **`sops -d` prints plaintext.** Don't pipe it into anything that + logs (`tee`, journalctl, history-aware shells). Use the file path + via `config.sops.secrets..path` in NixOS config; never + hardcode the decrypted value. +- **`environment.etc` writes to the Nix store** — readable by every + user on the host. Use `sops.secrets.` (writes to + `/run/secrets/` with mode-bits you control) instead. +- **`builtins.pathExists` is checked at eval time.** If you delete + `secrets/.yaml` after a successful build, the next + rebuild silently drops the sops block — the failure surfaces only + when a service tries to read a missing `/run/secrets/` file. + Don't delete a host's encrypted file unless you also remove every + `sops.secrets.` referencing it.