Skip to content

SOPS Secret Management with Ledger

This guide explains how to use SOPS (Secrets OPerationS) for secure secret management in your Nix configuration, using your Ledger hardware wallet for encryption and decryption.

SOPS is a tool for managing secrets in Git repositories. It encrypts files using GPG, age, or cloud KMS, allowing you to safely commit encrypted secrets to version control.

  • Git-friendly: Encrypted secrets can be safely committed to repositories
  • Hardware security: Uses your Ledger GPG key for encryption/decryption
  • Declarative: Integrates with Nix for reproducible secret deployment
  • Selective encryption: Only encrypts values, not keys (readable structure)
  • Multi-format: Supports YAML, JSON, ENV, INI, and binary files

In this configuration:

  • Encryption key: Stored on Ledger hardware (never exposed to computer)
  • Physical confirmation: Every decrypt operation requires Ledger button press
  • Git safety: Only encrypted data is committed to repository
  • Backup: Keys recoverable via Ledger’s 24-word recovery phrase
┌─────────────────────────────────────────────────────────────┐
│ Your Workflow │
├─────────────────────────────────────────────────────────────┤
│ 1. Edit secret: sops secrets.yaml │
│ 2. SOPS decrypts using Ledger GPG key │
│ 3. Opens in editor (plaintext in memory only) │
│ 4. You save changes │
│ 5. SOPS re-encrypts with Ledger GPG key │
│ 6. Safe to commit encrypted file │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Encryption Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Plain text secret │
│ │ │
│ ▼ │
│ SOPS encrypts ──────> Ledger GPG signs │
│ │ │ │
│ │ ▼ │
│ │ [Confirm on device] │
│ │ │ │
│ ▼ ▼ │
│ Encrypted file (safe for Git) │
│ │
└─────────────────────────────────────────────────────────────┘

Your Nix configuration includes:

inputs = {
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
darwinConfigurations."wikigen-mac" = darwin.lib.darwinSystem {
modules = [
sops-nix.darwinModules.sops
# ...
];
};

3. SOPS Configuration (nix/modules/secrets/sops.nix)

Section titled “3. SOPS Configuration (nix/modules/secrets/sops.nix)”
sops = {
gnupg.home = "~/.gnupg-ledger";
defaultSopsFile = ./nix/secrets/secrets.yaml;
};
environment.variables = {
GNUPGHOME = "/Users/wikigen/.gnupg-ledger";
};
creation_rules:
- path_regex: .*\.(yaml|json|env|ini)$
pgp: >-
D2A7EC63E350CC488197CB2ED369B07E00FB233E
Config/
├── .sops.yaml # SOPS configuration
├── nix/
│ └── secrets/
│ ├── README.md # Usage documentation
│ ├── secrets.yaml.example # Template
│ ├── secrets.yaml # Encrypted secrets (safe to commit)
│ └── test-secret.yaml # Test file
└── docs/
└── sops.md # This guide

Before using SOPS, ensure your environment is configured:

Terminal window
# Set GPG home directory to Ledger keyring
export GNUPGHOME=/Users/wikigen/.gnupg-ledger
# Verify ledger-gpg-agent is running
pgrep -f ledger-gpg-agent
# If not running, start it:
ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &

Pro tip: Add to your ~/.zshrc:

Terminal window
export GNUPGHOME=/Users/wikigen/.gnupg-ledger
Terminal window
# Copy the example
cp nix/secrets/secrets.yaml.example nix/secrets/my-secrets.yaml
# Edit and encrypt in one step
sops nix/secrets/my-secrets.yaml
Terminal window
# SOPS will create an empty file, open editor, then encrypt
sops nix/secrets/my-secrets.yaml

What happens:

  1. SOPS contacts your Ledger via ledger-gpg-agent
  2. Ledger displays “Sign message”
  3. Press button to confirm
  4. Editor opens with decrypted content
  5. Make your changes and save
  6. SOPS re-encrypts on save
Terminal window
sops nix/secrets/secrets.yaml

SOPS will:

  1. Decrypt the file (requires Ledger confirmation)
  2. Open in your $EDITOR (defaults to vim)
  3. Re-encrypt when you save and exit
Terminal window
# Decrypt and print to stdout
sops -d nix/secrets/secrets.yaml
# View specific key
sops -d --extract '["example"]["api_key"]' nix/secrets/secrets.yaml
Terminal window
# JSON
sops secrets.json
# Environment file
sops .env
# INI file
sops config.ini
# Binary file
sops --input-type binary --output-type binary secret.bin
Terminal window
# In-place encryption
sops --encrypt --in-place secrets.yaml
# Create encrypted copy
sops --encrypt secrets.yaml > secrets.enc.yaml

In your host configuration (e.g., hosts/wikigen-mac.nix):

{
# Declare a secret
sops.secrets."example/api_key" = {};
# The secret will be available at:
# /run/secrets/example/api_key
}
sops.secrets."example/api_key" = {
path = "/run/secrets/my_api_key";
};
sops.secrets."database/password" = {
owner = "wikigen";
mode = "0400"; # Read-only for owner
};
{
# Declare the secret
sops.secrets."app/jwt_secret" = {};
# Reference in a launchd service
launchd.user.agents.myapp = {
config = {
ProgramArguments = [
"${pkgs.myapp}/bin/myapp"
"--jwt-secret-file"
config.sops.secrets."app/jwt_secret".path
];
};
};
}

In your user config (e.g., home/users/wikigen.nix):

{ config, ... }:
{
# Create a config file with secret
home.file.".config/myapp/config.json".text = builtins.toJSON {
api_key_file = config.sops.secrets."example/api_key".path;
};
}
{
sops.secrets."aws/credentials" = {};
# Read secret content into environment
environment.variables = {
AWS_CREDENTIALS_FILE = config.sops.secrets."aws/credentials".path;
};
}

SOPS supports nested YAML structures:

# Development secrets
dev:
database:
host: localhost
port: 5432
password: dev_password_here
api_key: dev_api_key_here
# Production secrets
prod:
database:
host: prod-db.example.com
port: 5432
password: prod_password_here
api_key: prod_api_key_here
# AWS credentials
aws:
access_key_id: AKIAIOSFODNN7EXAMPLE
secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Application secrets
app:
jwt_secret: super-secret-jwt-signing-key-32-bytes
encryption_key: another-32-byte-encryption-key

After encryption, this becomes:

dev:
database:
host: ENC[AES256_GCM,data:bG9jYWxob3N0,iv:...]
port: ENC[AES256_GCM,data:NTQzMg==,iv:...]
password: ENC[AES256_GCM,data:ZGV2X3Bhc3N3b3JkX2hlcmU=,iv:...]
api_key: ENC[AES256_GCM,data:ZGV2X2FwaV9rZXlfaGVyZQ==,iv:...]
sops:
pgp:
- fp: D2A7EC63E350CC488197CB2ED369B07E00FB233E
# ... metadata ...

Notice:

  • Structure is visible: You can see keys and organization
  • Values are encrypted: Actual secrets are protected
  • Git-diffable: Changes show which values changed, not content

To allow multiple people to decrypt secrets:

.sops.yaml
creation_rules:
- path_regex: .*\.yaml$
pgp: >-
D2A7EC63E350CC488197CB2ED369B07E00FB233E,
ANOTHERKEY1234567890ABCDEF1234567890ABCDEF

If you need to change encryption keys:

Terminal window
# Update .sops.yaml with new key(s)
# Then rotate all secrets
sops updatekeys nix/secrets/secrets.yaml

This re-encrypts the file with the new key configuration.

.sops.yaml
creation_rules:
# Development secrets
- path_regex: secrets/dev/.*\.yaml$
pgp: >-
D2A7EC63E350CC488197CB2ED369B07E00FB233E
# Production secrets (different key)
- path_regex: secrets/prod/.*\.yaml$
pgp: >-
PRODUCTIONKEY1234567890ABCDEF1234567890AB
Terminal window
# Get a single value
sops -d --extract '["database"]["password"]' secrets.yaml
# Use in scripts
DB_PASSWORD=$(sops -d --extract '["database"]["password"]' secrets.yaml)
.sops.yaml
creation_rules:
- path_regex: config\.yaml$
encrypted_regex: '^(password|api_key|secret)$'

Only keys matching the regex will be encrypted.

DO commit:

  • .sops.yaml (configuration)
  • Encrypted secret files (secrets.yaml)
  • nix/secrets/README.md
  • nix/secrets/secrets.yaml.example

DON’T commit:

  • Unencrypted secrets
  • GPG private keys
  • secrets.yaml.example with real secrets

Add to .gitignore if you have temporary unencrypted files:

# Temporary unencrypted secrets
*.dec.yaml
*.dec.json
*_decrypted.*

To ensure secrets are always encrypted:

.pre-commit-config.yaml
repos:
- repo: https://github.com/getsops/sops
rev: v3.8.1
hooks:
- id: sops-check

Problem: SOPS can’t find your Ledger GPG key.

Solution:

Terminal window
# Ensure GNUPGHOME points to Ledger keyring
export GNUPGHOME=/Users/wikigen/.gnupg-ledger
# Verify key is visible
gpg --list-keys
# Should show: D2A7EC63E350CC488197CB2ED369B07E00FB233E

Problem: SOPS can’t decrypt with your key.

Solution:

Terminal window
# Check .sops.yaml has correct key fingerprint
cat .sops.yaml
# Verify fingerprint matches your key
gpg --list-keys --fingerprint
# Ensure ledger-gpg-agent is running
pgrep -f ledger-gpg-agent || \
ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &

Problem: Agent can’t communicate with Ledger.

Solution:

  1. Check Ledger is connected and unlocked
  2. Open “SSH/GPG Agent” app on Ledger
  3. Screen should show “SSH/GPG Agent is ready”
  4. Restart agent:
    Terminal window
    killall ledger-gpg-agent
    ledger-gpg-agent --homedir ~/.gnupg-ledger --server --verbose &
    sleep 2

Problem: File encrypted with different key.

Solution:

Terminal window
# Check which keys can decrypt the file
sops -d --verbose secrets.yaml
# Re-encrypt with your current key
sops updatekeys secrets.yaml

Checklist:

  • Ledger connected via USB
  • Ledger unlocked (PIN entered)
  • SSH/GPG Agent app is open on device
  • Screen shows “SSH/GPG Agent is ready”
  • ledger-gpg-agent process is running

Test:

Terminal window
# Try a simple GPG operation
echo "test" | gpg --clearsign
# Should prompt Ledger for confirmation

Problem: SOPS opens vim but you want a different editor.

Solution:

Terminal window
# Set editor in shell config (~/.zshrc)
export EDITOR="nano" # or "code --wait", "emacs", etc.
# Or set for single command
EDITOR=nano sops secrets.yaml

Good structure:

# Group by environment and service
dev:
database:
password: xxx
redis:
password: xxx
prod:
database:
password: xxx
redis:
password: xxx

Avoid:

# Flat, unclear structure
db_password: xxx
redis_pw: xxx
prod_db_password: xxx
  • Use descriptive names: database_password not dbpw
  • Include context: prod/api_key not just key
  • Be consistent: api_key everywhere, not mixing with apiKey
nix/secrets/
├── common.yaml # Shared across all environments
├── development.yaml # Dev-only secrets
├── production.yaml # Prod-only secrets
└── personal.yaml # Your personal API keys
  1. Generate new secret value
  2. Update in SOPS file: sops secrets.yaml
  3. Rebuild system: darwin-rebuild switch --flake .#wikigen-mac
  4. Verify new secret works
  5. Revoke old secret in external system

Your secrets are encrypted with your Ledger GPG key. To ensure access:

  1. Ledger backup: Securely store your 24-word recovery phrase
  2. Alternative access: Consider adding a second GPG key (different Ledger or software key)
  3. Test recovery: Periodically verify you can recover keys from seed

What SOPS protects against:

  • ✅ Secrets leaked in Git repository
  • ✅ Secrets readable on disk
  • ✅ Secrets accidentally committed to public repos

What SOPS doesn’t protect against:

  • ❌ Malware on your computer (can read decrypted secrets in memory)
  • ❌ Physical access to running system (secrets in /run/secrets/)
  • ❌ Compromise of Ledger device itself
  • Private key never leaves Ledger: Only signs/decrypts on device
  • Physical confirmation required: Must press button for every operation
  • No remote exploitation: Can’t decrypt without physical device
  • Recovery: Keys derived from 24-word seed (keep secure!)
  1. Lock your screen when away from computer
  2. Remove Ledger when not actively using SOPS
  3. Audit secret access via Git history
  4. Rotate secrets periodically
  5. Use unique secrets per environment
Terminal window
# Setup environment
export GNUPGHOME=/Users/wikigen/.gnupg-ledger
# Create/edit secret
sops secrets.yaml
# View secret
sops -d secrets.yaml
# Extract value
sops -d --extract '["key"]["subkey"]' secrets.yaml
# Encrypt existing file
sops --encrypt --in-place file.yaml
# Rotate keys
sops updatekeys secrets.yaml
# Check which keys can decrypt
sops -d --verbose secrets.yaml
  • Configuration: .sops.yaml
  • Secrets directory: nix/secrets/
  • GPG keyring: ~/.gnupg-ledger/
  • Agent logs: ~/.local/share/ledger-gpg-agent.log
  • Decrypted secrets: /run/secrets/ (runtime only)
  • Fingerprint: D2A7EC63E350CC488197CB2ED369B07E00FB233E
  • Identity: Luc Chartier luc@distorted.media
  • Algorithm: ECDSA (NIST P-256)
  • Storage: Ledger Nano S hardware wallet

Now that SOPS is configured:

  1. Create your first real secret:

    Terminal window
    sops nix/secrets/secrets.yaml
  2. Use it in your config:

    sops.secrets."my-app/api-key" = {};
  3. Rebuild your system:

    Terminal window
    darwin-rebuild switch --flake .#wikigen-mac
  4. Verify secret is available:

    Terminal window
    ls -l /run/secrets/

Happy secret managing! 🔐