This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/docs/secrets-management.md
Padreug 773632562e feat(dev-env): wire shared pre-commit secret scanner via core.hooksPath
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>
2026-05-26 09:21:27 +02:00

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:

  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.
  • modules/dev-env/scripts/git-hooks/pre-commit is the shared secret-scanner hook. Opt in by setting lnbits-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.

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 is example|changeme|placeholder).
  • Files under secrets/ matching *.yaml that lack a sops: 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):

  1. Add their public key to .sops.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:
    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 via devEnv.gitHooks.enable = true) catches the obvious cases by checking for a sops: metadata block + mac: ENC[…] line — both are required for a real sops-encrypted file. Always verify with cat secrets/<file>.yaml that 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.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.