Ships `modules/dev-env/scripts/git-hooks/pre-commit` — the same secret-scanner pattern omnixy uses, lightly adapted (drops the omnixy-specific test_auth.py skip, generic header comment). New option `lnbits-sensei.devEnv.gitHooks.enable` (off by default). When on, modules/dev-env/config.nix installs the hook at `~/.local/share/lnbits-sensei/git-hooks/pre-commit` and sets the consumer's git `core.hooksPath` to that directory, so every repo on the machine picks it up without per-repo wiring. The hook refuses to commit obvious secrets (PRIVATE KEY blocks, `password=…`, `secret=…`, `api_key=…`, `admin_key=…`, AWS keys, non-placeholder POSTGRES_PASSWORD) and unencrypted sops files (checks for a top-level `sops:` block AND `mac: ENC[…]` — either signal alone is forgeable). False positives are handled via `# pragma: allowlist secret` line- or block-level markers (gitleaks convention). docs/secrets-management.md gets a new subsection covering what the hook does, when to enable it, and the false-positive escape hatches. The Pitfalls section's reference to "the pre-commit hook most consumers use" is replaced with a concrete pointer to this option. `nix flake check --no-build` stays green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Secrets management with sops-nix
A beginner-friendly walkthrough of getting secrets out of your
.env files and into git safely, using
sops-nix. Assumes no prior
familiarity with sops, age, or NixOS secret management.
Why bother
Most LNbits dev setups start with secrets in .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:
- They land in git accidentally. A
git add -Ain the wrong moment, a forgotten.gitignoreline, and the key history-leaks into a public repo. Rotating after the fact is painful — there's no fixing what's already ingit log. - 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.nixdeclaressops-nixas a flake input.modules/secrets.niximports the NixOS module and points it atsecrets/${hostName}.yaml+ your private key at~/.config/sops/age/keys.txt..sops.yamldeclares the recipients (which public keys can decrypt files undersecrets/).secrets/is gitignored except for*.yaml(which is encrypted) andREADME.md.configuration.niximportsmodules/secrets.nix.modules/dev-env/scripts/git-hooks/pre-commitis the shared secret-scanner hook. Opt in by settinglnbits-sensei.devEnv.gitHooks.enable = true;— see below.
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.
The pre-commit hook (recommended)
lnbits-sensei.devEnv.gitHooks.enable = true installs a single
secret-scanner pre-commit hook under
~/.local/share/lnbits-sensei/git-hooks/ and sets git's
core.hooksPath so every repo on the machine picks it up
without per-repo wiring. The hook refuses to commit:
- Lines matching
PRIVATE KEY,BEGIN RSA/EC/OPENSSH PRIVATE,password=…,secret=…,api_key=…,admin_key=…,AWS_SECRET_ACCESS_KEY,POSTGRES_PASSWORD=…(unless the value isexample|changeme|placeholder). - Files under
secrets/matching*.yamlthat lack asops:metadata block (i.e. unencrypted secrets files staged by accident).
False positives (legitimate matches on placeholder values, prose
comments, test fixtures) are handled via line- or block-level
# pragma: allowlist secret markers — same convention as gitleaks.
See the hook source for the full marker semantics.
Last resort: git commit --no-verify bypasses the hook entirely.
Use sparingly, after confirming the diff doesn't actually contain
secret material.
Step-by-step
1. Install the tools
sops and age ship in nixpkgs:
# 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:
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:
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):
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:
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.
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:
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:
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:
# 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:
# 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
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:
sops secrets/<hostName>.yaml
# editor opens with plaintext view; sops re-encrypts on save
View a secret without editing:
sops -d secrets/<hostName>.yaml | grep lnbits-admin-key
Rotate to new recipients (e.g. add a collaborator):
- Add their public key to
.sops.yaml:keys: - &admin age1xxx... - &alice age1zzz... creation_rules: - path_regex: secrets/.*\.yaml$ key_groups: - age: - *admin - *alice - Re-encrypt every file to the new recipient set:
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):
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:
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:
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 shared pre-commit hook (enable viadevEnv.gitHooks.enable = true) catches the obvious cases by checking for asops:metadata block +mac: ENC[…]line — both are required for a real sops-encrypted file. Always verify withcat secrets/<file>.yamlthat you see metadata + base64 blobs, not plaintext, before trusting the hook. - Don't lose the private key. No recovery. Back up the file at
~/.config/sops/age/keys.txt(or/var/lib/sops-nix/key.txton servers) somewhere safe and offline. updatekeysre-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 -dprints plaintext. Don't pipe it into anything that logs (tee, journalctl, history-aware shells). Use the file path viaconfig.sops.secrets.<name>.pathin NixOS config; never hardcode the decrypted value.environment.etcwrites to the Nix store — readable by every user on the host. Usesops.secrets.<name>(writes to/run/secrets/with mode-bits you control) instead.builtins.pathExistsis checked at eval time. If you deletesecrets/<hostName>.yamlafter 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 everysops.secrets.<name>referencing it.