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
|
||||
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
|
||||
|
||||
|
|
|
|||
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