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:
parent
7af3bce544
commit
fc1d31244a
2 changed files with 319 additions and 0 deletions
|
|
@ -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
313
docs/secrets-management.md
Normal 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.
|
||||||
Reference in a new issue