agenix
Agenix Skill
SKILL.md
| Name | agenix |
| Description | Agenix Skill |
name: agenix version: 1.0 description: Agenix Skill
Agenix Skill
Overview
agenix is a lightweight, SSH-based secrets management solution for NixOS that uses age encryption to securely store and deploy sensitive information. It provides a CLI tool for encrypting secrets and a NixOS/Home Manager module for automated decryption and deployment.
Key Features
- SSH key integration: Leverages existing SSH infrastructure for encryption/decryption
- Nix-native: Encrypted secrets stored in Nix store, decrypted during activation
- Minimal dependencies: No GPG required, small auditable codebase
- Automatic decryption: Secrets decrypt during
nixos-rebuild switchusing host keys - Multi-platform: Supports NixOS, Home Manager, and nix-darwin
- Simple workflow: Edit secrets with your
$EDITOR, auto-encrypt on save - Version control friendly: Encrypted secrets can be safely committed to Git
Why agenix?
Problem: Storing secrets (passwords, API keys, certificates) in NixOS configurations is challenging because:
- Plain text secrets in config files are insecure
- Nix store is world-readable - can't use
builtins.readFilefor secrets - Manual secret deployment is error-prone and doesn't scale
- Need reproducible, declarative secret management
Solution: agenix encrypts secrets with SSH/age public keys, stores them in your Nix configuration, and automatically decrypts them on target systems using SSH private keys during activation.
How It Works
- Encrypt: Use
agenix -e secret.ageto create/edit encrypted secrets locally - Store: Commit encrypted
.agefiles to your Git repository - Declare: Reference secrets in NixOS config via
age.secrets.<name>.file - Deploy: Run
nixos-rebuild switch- secrets decrypt to/run/agenix/<name> - Use: Services read secrets from runtime paths (never from Nix store)
Project Information
- Repository: https://github.com/ryantm/agenix
- License: CC0-1.0 (Public Domain)
- Dependencies: age, SSH keys
- Maturity: Production-ready, widely used in NixOS community
Installation
NixOS with Flakes (Recommended)
Add agenix to your flake.nix:
{
description = "NixOS configuration with agenix";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
agenix.url = "github:ryantm/agenix";
# Optional: Pin to specific version
# agenix.url = "github:ryantm/agenix/0.15.0";
};
outputs = { self, nixpkgs, agenix, ... }:
{
nixosConfigurations.hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
agenix.nixosModules.default
];
};
};
}
Install the CLI tool in your system:
# configuration.nix
{ pkgs, agenix, ... }:
{
environment.systemPackages = [
agenix.packages.x86_64-linux.default
];
}
Or run the CLI without installing:
nix run github:ryantm/agenix -- --help
nix run github:ryantm/agenix -- -e secret.age
NixOS with nix-channel
Add the agenix channel:
sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix
sudo nix-channel --update
Import in configuration.nix:
{
imports = [ <agenix/modules/age.nix> ];
environment.systemPackages = [
(import <agenix>).default
];
}
NixOS with fetchTarball
For hermetic builds without channels:
{
imports = [
"${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix"
];
environment.systemPackages = [
(import (builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz")).default
];
}
Pin to specific commit:
let
agenixCommit = "298b235f664f925b433614dc33380f0662adfc3f";
agenixSha256 = "0000000000000000000000000000000000000000000000000000";
in {
imports = [
"${builtins.fetchTarball { url = \"https://github.com/ryantm/agenix/archive/${agenixCommit}.tar.gz\"; sha256 = agenixSha256; }}"/modules/age.nix"
];
}
NixOS with niv
Add agenix as a dependency:
niv add ryantm/agenix
Import in configuration.nix:
{
imports = [
"${(import ./nix/sources.nix).agenix}/modules/age.nix"
];
environment.systemPackages = [
(import (import ./nix/sources.nix).agenix).default
];
}
Home Manager
For user-level secrets with Home Manager:
# home.nix
{ inputs, ... }:
{
imports = [
inputs.agenix.homeManagerModules.default
];
age = {
identityPaths = [ "~/.ssh/id_ed25519" ];
secrets = {
personal-token.file = ../secrets/personal-token.age;
};
};
}
Quick Start Tutorial
Step 1: Create Secrets Directory
mkdir -p secrets
cd secrets
Step 2: Create secrets.nix
Define which public keys can decrypt each secret:
# secrets/secrets.nix
let
# User SSH keys
alice = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH alice@laptop";
bob = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJkbfFtJRq+6u/zcZWQRHqNLJoJN0UCT5qqRkUGBQnWo bob@desktop";
# System SSH host keys
server1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE root@server1";
server2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKj7H3K8JdQTTULTUi5L9l5JjqQNLq7JCqX5DWQR5sKj root@server2";
in
{
# API keys accessible by both users and server1
"api-key.age".publicKeys = [ alice bob server1 ];
# Database password only for server1
"db-password.age".publicKeys = [ alice server1 ];
# SSH private key for deployment
"deploy-key.age".publicKeys = [ alice server1 server2 ];
# User-specific secret
"alice-token.age".publicKeys = [ alice ];
}
Finding Public Keys:
# From local SSH keys
cat ~/.ssh/id_ed25519.pub
# From remote host
ssh-keyscan hostname
# From GitHub user
curl https://github.com/username.keys
# From NixOS configuration
ssh root@hostname "cat /etc/ssh/ssh_host_ed25519_key.pub"
Step 3: Create Your First Secret
# Set EDITOR if not already set
export EDITOR=vim
# Create and encrypt a secret
agenix -e api-key.age
This opens your editor. Type the secret content, save, and exit. The file is encrypted with the public keys defined in
secrets.nix.
Step 4: Add Secret to NixOS Configuration
# configuration.nix
{ config, ... }:
{
age.secrets.api-key = {
file = ./secrets/api-key.age;
mode = "440";
owner = "myservice";
group = "myservice";
};
# Use the secret in a service
systemd.services.myservice = {
script = ''
export API_KEY=$(cat ${config.age.secrets.api-key.path})
${pkgs.myapp}/bin/myapp
'';
serviceConfig = {
User = "myservice";
Group = "myservice";
};
};
}
Step 5: Deploy
nixos-rebuild switch
The secret decrypts to /run/agenix/api-key with the specified permissions.
Step 6: Edit Existing Secret
# Uses your SSH key for decryption
agenix -e api-key.age
# Or specify identity explicitly
agenix -e api-key.age -i ~/.ssh/id_ed25519
Common Use Cases
User Passwords
{ config, ... }:
{
age.secrets.alice-password.file = ./secrets/alice-password.age;
users.users.alice = {
isNormalUser = true;
hashedPasswordFile = config.age.secrets.alice-password.path;
};
}
Create the hashed password:
mkpasswd -m sha-512 | agenix -e alice-password.age
SSH Private Keys
{ config, ... }:
{
age.secrets.deploy-key = {
file = ./secrets/deploy-key.age;
mode = "600";
owner = "deploy";
};
users.users.deploy = {
isNormalUser = true;
openssh.authorizedKeys.keyFiles = [ ./deploy-key.pub ];
};
# Use for SSH connections
programs.ssh.extraConfig = ''
Host production
IdentityFile ${config.age.secrets.deploy-key.path}
'';
}
Database Credentials
{ config, pkgs, ... }:
{
age.secrets.postgres-password = {
file = ./secrets/postgres-password.age;
owner = "postgres";
group = "postgres";
};
services.postgresql = {
enable = true;
ensureUsers = [{
name = "myapp";
# Password set via secret file
}];
};
# Application reads password
systemd.services.myapp = {
script = ''
export DATABASE_URL="postgresql://myapp:$(cat ${config.age.secrets.postgres-password.path})@localhost/myapp"
${pkgs.myapp}/bin/myapp
'';
};
}
API Keys and Tokens
{ config, ... }:
{
age.secrets = {
github-token.file = ./secrets/github-token.age;
openai-api-key.file = ./secrets/openai-api-key.age;
aws-credentials.file = ./secrets/aws-credentials.age;
};
# Service with environment file
systemd.services.backup = {
script = ''
export GITHUB_TOKEN=$(cat ${config.age.secrets.github-token.path})
${pkgs.backup-script}/bin/backup
'';
};
}
TLS Certificates
{ config, ... }:
{
age.secrets = {
"tls-cert.pem" = {
file = ./secrets/tls-cert.pem.age;
owner = "nginx";
group = "nginx";
mode = "440";
};
"tls-key.pem" = {
file = ./secrets/tls-key.pem.age;
owner = "nginx";
group = "nginx";
mode = "400";
};
};
services.nginx = {
enable = true;
virtualHosts."example.com" = {
enableACME = false;
sslCertificate = config.age.secrets."tls-cert.pem".path;
sslCertificateKey = config.age.secrets."tls-key.pem".path;
};
};
}
Application Configuration Files
{ config, ... }:
{
age.secrets.app-config = {
file = ./secrets/app-config.yaml.age;
path = "/etc/myapp/config.yaml";
mode = "440";
owner = "myapp";
group = "myapp";
};
systemd.services.myapp = {
script = ''
${pkgs.myapp}/bin/myapp --config /etc/myapp/config.yaml
'';
serviceConfig = {
User = "myapp";
Group = "myapp";
};
};
}
Wireguard Private Keys
{ config, ... }:
{
age.secrets.wireguard-private = {
file = ./secrets/wireguard-private.age;
mode = "400";
};
networking.wireguard.interfaces.wg0 = {
privateKeyFile = config.age.secrets.wireguard-private.path;
ips = [ "10.0.0.2/24" ];
peers = [{
publicKey = "server-public-key";
endpoint = "vpn.example.com:51820";
allowedIPs = [ "10.0.0.0/24" ];
}];
};
}
Module Configuration Reference
NixOS Module Options
age.secrets.<name>.file
Type: path
Required: Yes
Path to the encrypted .age file.
{
age.secrets.api-key.file = ./secrets/api-key.age;
}
age.secrets.<name>.path
Type: string
Default: /run/agenix/<name>
Path where the decrypted secret will be available.
{
age.secrets.monitrc = {
file = ./secrets/monitrc.age;
path = "/etc/monitrc";
};
}
age.secrets.<name>.mode
Type: string
Default: "0400"
File permissions in chmod format (octal).
{
age.secrets.nginx-htpasswd = {
file = ./secrets/nginx.htpasswd.age;
mode = "0440"; # Owner and group can read
};
}
Common modes:
"0400": Owner read-only (most secure)"0440": Owner and group read"0600": Owner read/write"0640": Owner read/write, group read
age.secrets.<name>.owner
Type: string
Default: "root"
Username of the file owner.
{
age.secrets.postgres-password = {
file = ./secrets/postgres-password.age;
owner = "postgres";
};
}
age.secrets.<name>.group
Type: string
Default: "root"
Group name of the file.
{
age.secrets.nginx-cert = {
file = ./secrets/nginx-cert.age;
owner = "nginx";
group = "nginx";
};
}
age.secrets.<name>.symlink
Type: boolean
Default: true
Whether to use a symlink or copy the file.
{
age.secrets.elasticsearch-conf = {
file = ./secrets/elasticsearch.conf.age;
symlink = false; # Copy instead of symlink
};
}
Note: Symlinks are recommended for security (automatic cleanup). Disable only if an application cannot follow symlinks.
age.secrets.<name>.name
Type: string
Default: <attribute name>
Custom filename for the decrypted secret.
{
age.secrets.monit = {
name = "monitrc";
file = ./secrets/monitrc.age;
};
# Decrypts to /run/agenix/monitrc instead of /run/agenix/monit
}
age.identityPaths
Type: list of strings
Default: SSH host keys from config.services.openssh.hostKeys
Paths to private keys used for decryption.
{
age.identityPaths = [
"/var/lib/persistent/ssh_host_ed25519_key"
"/var/lib/persistent/ssh_host_rsa_key"
];
}
Important: Use strings, not Nix paths, to prevent copying private keys to the Nix store.
age.secretsDir
Type: string
Default: /run/agenix
Directory where secret symlinks are created.
{
age.secretsDir = "/run/keys";
}
age.secretsMountPoint
Type: string
Default: /run/agenix.d
Directory for generation-specific secrets (internal use).
{
age.secretsMountPoint = "/run/secret-generations";
}
age.ageBin
Type: string
Default: "${pkgs.age}/bin/age"
Path to the age binary.
{
# Use rage instead of age
age.ageBin = "${pkgs.rage}/bin/rage";
}
Home Manager Module Options
Home Manager options are similar to NixOS with these differences:
age.identityPaths (Home Manager)
Type: list of strings
Required: Yes (no default)
Must be explicitly configured:
{
age.identityPaths = [ "~/.ssh/id_ed25519" ];
}
age.secretsDir (Home Manager)
Default: $XDG_RUNTIME_DIR/agenix (Linux) or temporary directory (Darwin)
{
age.secretsDir = "$HOME/.secrets";
}
CLI Reference
Commands
Edit Secret
# Edit secret (creates if doesn't exist)
agenix -e secret.age
# Edit with specific identity
agenix -e secret.age -i ~/.ssh/id_ed25519
# Edit with custom rules file
RULES=./my-secrets.nix agenix -e secret.age
Rekey Secrets
Re-encrypt all secrets when public keys change:
# Rekey all secrets
agenix --rekey
# Rekey with specific identity
agenix --rekey -i ~/.ssh/id_ed25519
# Rekey with custom rules
RULES=./my-secrets.nix agenix --rekey
Decrypt Secret
Output decrypted content to stdout:
# Decrypt to stdout
agenix -d secret.age
# Decrypt with specific identity
agenix -d secret.age -i ~/.ssh/id_ed25519
# Decrypt to file
agenix -d secret.age > /tmp/secret.txt
Options
-e, --edit FILE: Edit FILE using$EDITOR-r, --rekey: Re-encrypt all secrets with updated recipients-d, --decrypt FILE: Decrypt FILE to stdout-i, --identity PATH: Private key path for decryption-v, --verbose: Enable verbose output-h, --help: Show help message
Environment Variables
EDITOR: Editor used for-eflag (defaults tocp /dev/stdinin non-interactive mode)RULES: Path to secrets.nix file (defaults to./secrets.nix)
Examples
# Create a new secret
export EDITOR=vim
agenix -e database-password.age
# Edit existing secret with nano
EDITOR=nano agenix -e api-key.age
# View secret content
agenix -d api-key.age | less
# Rekey after adding new host
agenix --rekey
# Use custom secrets.nix location
RULES=~/nixos/secrets/rules.nix agenix -e secret.age
# Non-interactive secret creation
echo "my-secret-value" | agenix -e secret.age
# Create secret from file
cat secret.txt | agenix -e secret.age
Advanced Configuration
Multiple Host Keys
Support both ED25519 and RSA keys:
# secrets.nix
let
server1-ed25519 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr...";
server1-rsa = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC5...";
in
{
"secret.age".publicKeys = [
server1-ed25519
server1-rsa
];
}
Organization-Wide Secrets
# secrets.nix
let
# Import keys from separate files
admins = import ./admin-keys.nix;
servers = import ./server-keys.nix;
# Shared secrets
allKeys = admins ++ servers;
in
{
"shared-api-key.age".publicKeys = allKeys;
"admin-password.age".publicKeys = admins;
"server-cert.age".publicKeys = servers;
}
# admin-keys.nix
[
"ssh-ed25519 AAAAC3... alice@example.com"
"ssh-ed25519 AAAAC3... bob@example.com"
"ssh-ed25519 AAAAC3... charlie@example.com"
]
Per-Environment Secrets
# secrets.nix
let
production = import ./keys/production.nix;
staging = import ./keys/staging.nix;
development = import ./keys/development.nix;
in
{
"prod-db-password.age".publicKeys = production;
"staging-db-password.age".publicKeys = staging;
"dev-db-password.age".publicKeys = development;
# Shared across environments
"shared-api-key.age".publicKeys = production ++ staging ++ development;
}
Armor Mode (ASCII Encoding)
Enable Base64 PEM format for better diff readability:
# secrets.nix
{
"armored-secret.age" = {
publicKeys = [ user1 system1 ];
armor = true; # Use ASCII armor format
};
}
Benefits:
- More readable diffs in version control
- Can be pasted in text channels
- Slightly larger file size
Custom Age Binary
Use rage (Rust implementation) instead of age:
{ pkgs, agenix, ... }:
{
environment.systemPackages = [
(agenix.packages.x86_64-linux.default.override {
ageBin = "${pkgs.rage}/bin/rage";
})
];
age.ageBin = "${pkgs.rage}/bin/rage";
}
Persistent SSH Keys
For systems with impermanence or ephemeral root:
{ config, ... }:
{
# Persist SSH host keys
environment.persistence."/persist" = {
files = [
"/etc/ssh/ssh_host_ed25519_key"
"/etc/ssh/ssh_host_ed25519_key.pub"
"/etc/ssh/ssh_host_rsa_key"
"/etc/ssh/ssh_host_rsa_key.pub"
];
};
# Point agenix to persistent keys
age.identityPaths = [
"/persist/etc/ssh/ssh_host_ed25519_key"
];
}
Secrets in initrd
For unlocking encrypted disks:
{ config, ... }:
{
age.secrets.disk-encryption-key = {
file = ./secrets/disk-key.age;
};
boot.initrd.secrets = {
"/crypto_keyfile.bin" = config.age.secrets.disk-encryption-key.path;
};
boot.initrd.luks.devices.cryptroot = {
device = "/dev/sda2";
keyFile = "/crypto_keyfile.bin";
};
}
Integration with sops-nix
Use both agenix and sops-nix in the same system:
{ config, pkgs, ... }:
{
imports = [
inputs.agenix.nixosModules.default
inputs.sops-nix.nixosModules.sops
];
# agenix for simple secrets
age.secrets.api-key.file = ./secrets/api-key.age;
# sops for complex secrets with multiple formats
sops.secrets."database/password" = {};
}
Security Best Practices
1. Use Strong SSH Keys
Generate ED25519 keys (recommended):
ssh-keygen -t ed25519 -C "your-email@example.com"
Or RSA with 4096 bits:
ssh-keygen -t rsa -b 4096 -C "your-email@example.com"
2. Protect Private Keys
# ✅ Good: Use strings to avoid store
{
age.identityPaths = [ "/var/lib/ssh/host_key" ];
}
# ❌ Bad: Nix path copies to store
{
age.identityPaths = [ /var/lib/ssh/host_key ];
}
3. Set Restrictive Permissions
{
age.secrets.sensitive-data = {
file = ./secrets/sensitive.age;
mode = "0400"; # Read-only for owner
owner = "service-user";
group = "service-group";
};
}
4. Never Read Secrets at Build Time
# ❌ WRONG: Puts plaintext in world-readable Nix store
{
services.myapp.apiKey = builtins.readFile config.age.secrets.api-key.path;
}
# ✅ CORRECT: Read at runtime
{
systemd.services.myapp = {
script = ''
export API_KEY=$(cat ${config.age.secrets.api-key.path})
${pkgs.myapp}/bin/myapp
'';
};
}
5. Rotate Secrets Regularly
# Update secret
agenix -e secret.age
# Deploy to all systems
nixops deploy
# Or use CI/CD
git commit -m "Rotate API keys"
git push
6. Use Separate Keys Per Environment
# secrets.nix
{
"prod-db.age".publicKeys = [ prod-admin prod-server ];
"dev-db.age".publicKeys = [ dev-admin dev-server ];
}
7. Audit Secret Access
# Log secret access
{
systemd.services.audit-secrets = {
script = ''
${pkgs.inotify-tools}/bin/inotifywait -m /run/agenix/ -e access |
while read path action file; do
echo "Secret accessed: $file at $(date)" >> /var/log/secret-access.log
done
'';
};
}
8. Backup Decryption Keys
- Store SSH private keys in a secure password manager
- Keep offline backups of keys
- Document key recovery procedures
- Test key recovery process
9. Limit Secret Lifetime
# Auto-cleanup old secrets
{
systemd.tmpfiles.rules = [
"d /run/agenix 0755 root root 30d"
];
}
10. Version Control Encrypted Secrets
# .gitignore - Don't ignore encrypted secrets
# *.age # DON'T DO THIS
# DO ignore decrypted secrets
secrets/*.txt
secrets/*.key
!secrets/*.age # But commit encrypted ones
Common Patterns
Environment Files
Create environment files for services:
{ config, pkgs, ... }:
let
# Generate environment file from secrets
mkEnvFile = secrets: pkgs.writeScript "load-env" ''
#!${pkgs.bash}/bin/bash
${pkgs.lib.concatMapStringsSep "\n" (s:
"export ${s.name}=$(cat ${s.path})"
) secrets}
'';
in {
age.secrets = {
api-key.file = ./secrets/api-key.age;
db-password.file = ./secrets/db-password.age;
};
systemd.services.myapp = {
script = ''
source ${mkEnvFile [
{ name = "API_KEY"; path = config.age.secrets.api-key.path; }
{ name = "DB_PASSWORD"; path = config.age.secrets.db-password.path; }
]}
${pkgs.myapp}/bin/myapp
'';
};
}
Secret Templating
Generate config files with secrets:
{ config, pkgs, ... }:
{
age.secrets = {
db-password.file = ./secrets/db-password.age;
api-key.file = ./secrets/api-key.age;
};
systemd.services.myapp = {
preStart = ''
cat > /etc/myapp/config.yaml <<EOF
database:
password: $(cat ${config.age.secrets.db-password.path})
api:
key: $(cat ${config.age.secrets.api-key.path})
EOF
chmod 600 /etc/myapp/config.yaml
'';
};
}
Conditional Secrets
Different secrets per host:
{ config, lib, ... }:
{
age.secrets = lib.mkMerge [
# Common secrets for all hosts
{
shared-api-key.file = ./secrets/shared-api-key.age;
}
# Production-specific secrets
(lib.mkIf (config.networking.hostName == "prod-server") {
prod-db-password.file = ./secrets/prod-db.age;
})
# Development-specific secrets
(lib.mkIf (config.networking.hostName == "dev-server") {
dev-db-password.file = ./secrets/dev-db.age;
})
];
}
Secrets Modules
Create reusable secret modules:
# modules/secrets.nix
{ config, lib, ... }:
with lib;
{
options.myorg.secrets = {
enable = mkEnableOption "organization secrets";
environment = mkOption {
type = types.enum [ "production" "staging" "development" ];
description = "Deployment environment";
};
};
config = mkIf config.myorg.secrets.enable {
age.secrets = {
api-key.file = ./secrets/${config.myorg.secrets.environment}/api-key.age;
db-password.file = ./secrets/${config.myorg.secrets.environment}/db-password.age;
};
};
}
Usage:
{
imports = [ ./modules/secrets.nix ];
myorg.secrets = {
enable = true;
environment = "production";
};
}
Troubleshooting
Secret Not Decrypting
Check identity paths:
{
# Verify paths are correct
age.identityPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
}
Verify key exists:
ls -l /etc/ssh/ssh_host_ed25519_key
Check public key matches:
# Get public key from private key
ssh-keygen -y -f /etc/ssh/ssh_host_ed25519_key
# Compare with secrets.nix
Test decryption manually:
age -d -i /etc/ssh/ssh_host_ed25519_key secret.age
Permission Denied
Check file permissions:
ls -l /run/agenix/
Verify owner/group:
{
age.secrets.mySecret = {
file = ./secrets/mySecret.age;
owner = "myuser"; # Make sure user exists
group = "mygroup"; # Make sure group exists
mode = "0440";
};
}
Check service user:
{
systemd.services.myservice = {
serviceConfig = {
User = "myuser"; # Must match secret owner
};
};
}
Rekeying Fails
Ensure you have decryption access:
# Test with your SSH key
agenix -d secret.age -i ~/.ssh/id_ed25519
Check all secrets can be decrypted:
# Rekey with specific identity
agenix --rekey -i ~/.ssh/id_ed25519
Verify secrets.nix syntax:
nix-instantiate --eval secrets.nix
Secret Not Found
Check file path:
{
# Use correct relative path
age.secrets.api-key.file = ./secrets/api-key.age; # Relative to config file
# Or absolute path
age.secrets.api-key.file = /etc/nixos/secrets/api-key.age;
}
Verify file exists:
ls -l secrets/api-key.age
SSH Key Format Issues
Convert to age-compatible format:
# ED25519 keys work directly
ssh-keygen -t ed25519
# RSA keys need conversion
ssh-keygen -t rsa -b 4096
Use ssh-to-age for conversion:
nix-shell -p ssh-to-age
ssh-keygen -y -f ~/.ssh/id_rsa | ssh-to-age
Secrets in Nix Store
Check for accidental store inclusion:
# Search for secrets in store
nix-store -q --references /run/current-system | xargs -I {} nix-store -q --tree {} | grep -i secret
Fix: Use runtime reading:
# ❌ Wrong
config.password = builtins.readFile config.age.secrets.password.path;
# ✅ Correct
systemd.services.app.script = ''
export PASSWORD=$(cat ${config.age.secrets.password.path})
'';
Home Manager Secrets Not Working
Set identityPaths explicitly:
{
age.identityPaths = [ "~/.ssh/id_ed25519" ];
}
Check XDG_RUNTIME_DIR:
echo $XDG_RUNTIME_DIR
ls -l $XDG_RUNTIME_DIR/agenix/
Verify home-manager activation:
home-manager switch --show-trace
Integration Examples
NixOps Deployment
{
network = {
description = "Production deployment with secrets";
};
webserver = { config, pkgs, ... }:
{
deployment.targetHost = "web.example.com";
imports = [ inputs.agenix.nixosModules.default ];
age.secrets = {
ssl-cert.file = ./secrets/ssl-cert.pem.age;
ssl-key.file = ./secrets/ssl-key.pem.age;
};
services.nginx = {
enable = true;
sslCertificate = config.age.secrets.ssl-cert.path;
sslCertificateKey = config.age.secrets.ssl-key.path;
};
};
}
Colmena Deployment
{
meta = {
nixpkgs = import <nixpkgs> {};
nodeNixpkgs = {
server = import <nixpkgs> {};
};
};
server = { config, pkgs, ... }:
{
deployment = {
targetHost = "server.example.com";
targetUser = "deploy";
};
imports = [ inputs.agenix.nixosModules.default ];
age.secrets.deploy-key = {
file = ./secrets/deploy-key.age;
mode = "600";
owner = "deploy";
};
};
}
Deploy-rs Integration
{
deploy.nodes.server = {
hostname = "server.example.com";
profiles.system = {
path = deploy-rs.lib.x86_64-linux.activate.nixos self.nixosConfigurations.server;
};
};
nixosConfigurations.server = nixpkgs.lib.nixosSystem {
modules = [
agenix.nixosModules.default
{
age.secrets.deploy-key.file = ./secrets/deploy-key.age;
}
];
};
}
GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy with Secrets
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: cachix/install-nix-action@v18
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Rekey secrets if needed
run: |
nix run github:ryantm/agenix -- --rekey
- name: Deploy
run: |
nixos-rebuild switch --flake .#server --target-host root@server.example.com
Docker Container
{ config, pkgs, ... }:
{
age.secrets.app-env = {
file = ./secrets/app-env.age;
mode = "644";
};
virtualisation.oci-containers.containers.myapp = {
image = "myapp:latest";
volumes = [
"${config.age.secrets.app-env.path}:/app/.env:ro"
];
};
}
Migration Guide
From Manual Secret Management
Before:
{
services.myapp.apiKey = "hardcoded-secret"; # Insecure!
}
After:
{
age.secrets.api-key.file = ./secrets/api-key.age;
systemd.services.myapp = {
script = ''
export API_KEY=$(cat ${config.age.secrets.api-key.path})
${pkgs.myapp}/bin/myapp
'';
};
}
From sops-nix
- Export secrets from sops:
sops -d secrets.yaml > secrets.txt
- Convert to agenix:
agenix -e secret.age < secrets.txt
- Update configuration:
# Before (sops)
{
sops.secrets.api-key = {};
}
# After (agenix)
{
age.secrets.api-key.file = ./secrets/api-key.age;
}
From git-crypt
- Unlock and export:
git-crypt unlock
cat secrets/api-key > /tmp/api-key
- Encrypt with agenix:
agenix -e api-key.age < /tmp/api-key
shred -u /tmp/api-key
- Update configuration to use agenix
Best Practices Summary
✅ Do
- Version control encrypted secrets - Commit
.agefiles to Git - Use strings for key paths - Prevent copying private keys to store
- Set restrictive permissions - Use mode
0400or0440 - Read secrets at runtime - Never use
builtins.readFileon secret paths - Rotate keys regularly - Update secrets periodically
- Backup private keys - Store recovery keys securely
- Use descriptive names -
db-password.agenotsecret1.age - Separate secrets per environment - Different keys for prod/staging/dev
- Document key ownership - Track who can decrypt what
- Test rekeying process - Ensure you can update secrets
❌ Don't
- Don't use Nix paths for private keys - Use strings instead
- Don't read secrets at build time - Puts plaintext in Nix store
- Don't share private keys - Each user/system should have unique keys
- Don't commit decrypted secrets - Only commit
.agefiles - Don't ignore backup - Always have key recovery plan
- Don't use weak permissions - Avoid mode
0777or0644for secrets - Don't hardcode secrets - Always encrypt with agenix
- Don't skip rekeying - Update secrets when keys change
- Don't use password-protected SSH keys for automation - Causes issues with rekeying
- Don't assume secrets are authenticated - Age provides confidentiality, not authentication
Security Considerations
Threat Model
What agenix protects against:
- ✅ Accidental exposure in version control
- ✅ Unauthorized access to secrets at rest
- ✅ Secrets in build artifacts
- ✅ Accidental logging of secrets
What agenix does NOT protect against:
- ❌ Compromised private keys (attacker can decrypt)
- ❌ Root access on target system (can read
/run/agenix/) - ❌ Supply chain attacks on age/agenix
- ❌ Side-channel attacks
- ❌ Post-quantum attacks (age is not PQ-safe as of 2024)
Unauthenticated Encryption
Age provides confidentiality but not authentication:
- Attackers with write access to
.agefiles can modify encrypted content - Configuration changes are easier to audit than secret content
- Use file integrity monitoring for production secrets
- Consider additional authentication layers for critical secrets
Post-Quantum Safety
As of 2024, age is not post-quantum safe:
- "Harvest now, decrypt later" attacks are viable
- Don't store long-term secrets in public repositories
- Rotate secrets regularly
- Monitor post-quantum age development
Recommendations
- Rotate secrets regularly - At least quarterly for critical secrets
- Monitor access - Log and audit secret access patterns
- Limit key distribution - Minimum necessary access principle
- Use hardware security modules - For critical infrastructure keys
- Plan for key compromise - Document incident response procedures
- Keep agenix updated - Security fixes and improvements
- Audit regularly - Review who has access to which secrets
- Test recovery - Ensure you can restore access if keys are lost
Resources
Official Documentation
- GitHub: https://github.com/ryantm/agenix
- Issues: https://github.com/ryantm/agenix/issues
- Discussions: https://github.com/ryantm/agenix/discussions
Related Projects
- age: https://age-encryption.org/
- rage: https://github.com/str4d/rage (Rust implementation)
- sops-nix: Alternative secrets management
- git-crypt: Alternative for Git-based secrets
Community Resources
- NixOS Wiki: Search for "agenix"
- NixOS Discourse: Community discussions and help
- r/NixOS: Reddit community
Learning Resources
- Age specification: https://age-encryption.org/v1
- SSH key management: Best practices and guides
- NixOS secrets management: Comparison of different approaches
Quick Reference
Installation (Flakes)
{
inputs.agenix.url = "github:ryantm/agenix";
outputs = { nixpkgs, agenix, ... }:
{
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
modules = [ agenix.nixosModules.default ];
};
};
}
Basic Secret
# secrets.nix
{
"secret.age".publicKeys = [ user-key host-key ];
}
# configuration.nix
{
age.secrets.secret.file = ./secrets/secret.age;
}
CLI Commands
-
Edit Secret
agenix -e secret.age # Create/edit secret -
Rekey Secrets
agenix --rekey # Re-encrypt all secrets -
Decrypt Secret
agenix -d secret.age # Decrypt to stdout
Common Options
{
age.secrets.secret = {
file = ./secrets/secret.age; # Required
path = "/run/agenix/secret"; # Default
mode = "0400"; # Default
owner = "root"; # Default
group = "root"; # Default
};
}
Finding SSH Keys
-
Local user key
cat ~/.ssh/id_ed25519.pub -
Remote host key
ssh-keyscan hostname -
GitHub user keys
curl https://github.com/user.keys
This comprehensive skill covers everything you need to securely manage secrets in NixOS with agenix!