Beta: This project is in active development. APIs may change.
Declarative SOPS secrets management for Nix flakes.
secrets.nix provides a pure Nix approach to managing encrypted secrets using SOPS and age. Each secret is:
- Declared in your flake with explicit recipients
- Stored as a single encrypted file (one file per secret)
- Managed through generated shell scripts with full
--helpdocumentation
No .sops.yaml file needed - configuration is derived from your Nix expressions.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
secrets-nix.url = "github:your/secrets.nix";
};
outputs = { nixpkgs, secrets-nix, ... }: let
inherit (secrets-nix) mkSecrets mkSecretsPackages;
recipients = {
alice = {
key = "age1abc..."; # alice's public key
};
bob = {
key = "age1xyz..."; # bob's public key
};
server1 = {
key = "age1srv..."; # server's public key
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-server1-key" ''
${pkgs.ssh-to-age}/bin/ssh-to-age -private-key -i /etc/ssh/ssh_host_ed25519_key
'';
};
};
secrets = mkSecrets {
api-key = {
dir = ./secrets;
inherit recipients;
};
db-password = {
dir = ./secrets;
inherit recipients;
format = "bin"; # default
};
service-account = {
dir = ./secrets;
inherit recipients;
format = "json";
};
};
in {
packages.x86_64-linux = let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
secretsPkgs = mkSecretsPackages secrets pkgs;
in {
inherit secretsPkgs;
};
};
}# From file (secure - content not in shell history)
nix run .#secrets.api-key.encrypt -- --input <(pass show my-api-key)
# From a plaintext file
nix run .#secrets.api-key.encrypt -- --input ./plaintext.txt
# Override output location
nix run .#secrets.api-key.encrypt -- --input ./secret.txt --output ./other-dir/
# See all options
nix run .#secrets.api-key.encrypt -- --help# Create new secret (opens empty $EDITOR)
nix run .#secrets.new-secret.edit
# Edit existing secret (decrypts, opens $EDITOR, re-encrypts)
nix run .#secrets.api-key.edit.recipient.alice
# See all options
nix run .#secrets.api-key.edit -- --help# To stdout (default)
nix run .#secrets.api-key.decrypt.recipient.alice
# To file
nix run .#secrets.api-key.decrypt.recipient.alice -- --output ./plaintext.txt
# With runtime key override
nix run .#secrets.api-key.decrypt -- --sopsAgeKeyCmd "pass show age/alice"
nix run .#secrets.api-key.decrypt -- --sopsAgeKeyFile ~/.config/sops/age/keys.txt
# Pipe to other commands
nix run .#secrets.service-account.decrypt.recipient.alice | jq .field
# See all options
nix run .#secrets.api-key.decrypt -- --help# Rotate data key (content unchanged)
nix run .#secrets.api-key.rotate.recipient.alice# Update recipients to match current config (after adding/removing recipients)
nix run .#secrets.api-key.rekey.recipient.aliceEach recipient needs a public key. Optionally, provide a decryptPkg function to enable decrypt.recipient.<name>:
recipients = {
# Developer with decrypt capability
alice = {
key = "age1...";
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
pass show age/alice
'';
};
# Deploy target (encrypt-only, no decryptPkg)
production = {
key = "age1...";
};
};Supported secret formats:
| Format | Extension | Use case |
|---|---|---|
bin |
(none) | Binary/text secrets (default) |
json |
.json |
Structured JSON data |
yaml |
.yaml |
Structured YAML data |
env |
.env |
Environment files |
There are multiple ways to provide the age secret key for decryption, at both build time (Nix evaluation) and runtime (shell execution).
Configure the key source when building packages in your flake:
Define decryptPkg in your recipients to get decrypt.recipient.<name>:
recipients = {
alice = {
key = "age1...";
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
pass show age/alice
'';
};
};
# Then use:
packages.decrypt-secret = secrets.api-key.decrypt.recipient.alice;Chain builder methods for one-off configurations:
{
# String command (executed at runtime)
my-decrypt = secrets.api-key.decrypt.withSopsAgeKeyCmd "pass show age/key";
# Pre-built package (must output key to stdout)
my-decrypt = secrets.api-key.decrypt.withSopsAgeKeyCmdPkg myKeyPkg;
# Build function (pkgs -> derivation)
my-decrypt = secrets.api-key.decrypt.buildSopsAgeKeyCmdPkg (pkgs:
pkgs.writeShellScriptBin "get-key" ''
${pkgs.ssh-to-age}/bin/ssh-to-age -private-key -i ~/.ssh/id_ed25519
''
);
}Override or provide key configuration when running the command:
# Command that outputs the key (most secure)
nix run .#secrets.api-key.decrypt -- --sopsAgeKeyCmd "pass show age/key"
# Path to key file
nix run .#secrets.api-key.decrypt -- --sopsAgeKeyFile ~/.config/sops/age/keys.txt
# Direct key value (visible in ps - use only for testing)
nix run .#secrets.api-key.decrypt -- --sopsAgeKey "AGE-SECRET-KEY-1..."# Command that outputs the key
export SOPS_AGE_KEY_CMD="pass show age/key"
nix run .#secrets.api-key.decrypt
# Path to key file
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
nix run .#secrets.api-key.decrypt
# Direct key value
export SOPS_AGE_KEY="AGE-SECRET-KEY-1..."
nix run .#secrets.api-key.decryptAdd to .envrc for automatic key configuration per-project:
# .envrc
export SOPS_AGE_KEY_CMD="pass show age/myproject"When decrypting, keys are resolved in this order (first match wins):
--sopsAgeKeyflag--sopsAgeKeyFileflag--sopsAgeKeyCmdflagSOPS_AGE_KEYenvironment variableSOPS_AGE_KEY_FILEenvironment variableSOPS_AGE_KEY_CMDenvironment variable- Build-time configured key (from
decryptPkgor builder pattern)
# Password store (pass)
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs.pass}/bin/pass show age/mykey
'';
# 1Password CLI
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs._1password}/bin/op read "op://vault/age-key/secret"
'';
# SSH key via ssh-to-age
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs.ssh-to-age}/bin/ssh-to-age -private-key -i ~/.ssh/id_ed25519
'';
# HashiCorp Vault
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs.vault}/bin/vault kv get -field=key secret/age
'';
# AWS Secrets Manager
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs.awscli2}/bin/aws secretsmanager get-secret-value \
--secret-id age-key --query SecretString --output text
'';
# Bitwarden CLI
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
${pkgs.bitwarden-cli}/bin/bw get password age-key
'';
# macOS Keychain
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
security find-generic-password -s "age-key" -w
'';
# Plain file (least secure, but simple)
decryptPkg = pkgs: pkgs.writeShellScriptBin "get-key" ''
cat ~/.config/sops/age/keys.txt
'';your-project/
├── flake.nix # Secret definitions
└── secrets/
├── api-key # Encrypted secret (binary format)
├── db-password # Encrypted secret
└── service-account.json # Encrypted secret (json format)
Include secret management tools in your development shell:
{
devShells.default = pkgs.mkShell {
packages = [
pkgs.sops
pkgs.age
# Include specific decrypt commands
secretsPkgs.api-key.decrypt.recipient.alice
secretsPkgs.db-password.decrypt.recipient.alice
# Or create a wrapper with all operations for a recipient
(pkgs.symlinkJoin {
name = "secrets-alice";
paths = [
secretsPkgs.api-key.decrypt.recipient.alice
secretsPkgs.api-key.edit.recipient.alice
secretsPkgs.api-key.rotate.recipient.alice
secretsPkgs.db-password.decrypt.recipient.alice
];
})
];
# Or configure via environment
shellHook = ''
export SOPS_AGE_KEY_CMD="pass show age/alice"
'';
};
}Then in your shell:
# Decrypt directly
decrypt-api-key
# Use in scripts
API_KEY=$(decrypt-api-key)
curl -H "Authorization: Bearer $API_KEY" https://api.example.com
# Edit a secret
edit-api-key- Additional SOPS key types:
- GPG keys
- AWS KMS
- GCP KMS
- Azure Key Vault
- HashiCorp Vault Transit
- sopsnix/agenix integration
- flake-parts module