docs(secrets): add beginner walkthrough for sops-nix

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.<name>.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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 08:47:19 +02:00
commit fc1d31244a
2 changed files with 319 additions and 0 deletions

View file

@ -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 Vue/Quasar UMD traps in lnbits page templates: no self-closing
tags, CSS specificity vs Quasar's `!important` utilities, cache tags, CSS specificity vs Quasar's `!important` utilities, cache
busting via `?v={server_startup_time}`, dark-mode color discipline. 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 ## Contributing to this scaffold

313
docs/secrets-management.md Normal file
View file

@ -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/<name>` 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/<hostName>.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/<hostName>.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.<name>` 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 `<name>` 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.<name>.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/<name>` on the running host.
### 7. Activate
```sh
sudo nixos-rebuild switch --flake .#<hostName>
```
At activation, sops-nix decrypts `secrets/<hostName>.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/<hostName>.yaml
# editor opens with plaintext view; sops re-encrypts on save
```
**View a secret without editing:**
```sh
sops -d secrets/<hostName>.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/<hostName>.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/<file>.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.<name>.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.<name>` (writes to
`/run/secrets/` with mode-bits you control) instead.
- **`builtins.pathExists` is checked at eval time.** If you delete
`secrets/<hostName>.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/<name>` file.
Don't delete a host's encrypted file unless you also remove every
`sops.secrets.<name>` referencing it.