Agenix Skill
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.
nixos-rebuild switch using host keys$EDITOR, auto-encrypt on saveProblem: Storing secrets (passwords, API keys, certificates) in NixOS configurations is challenging because:
builtins.readFile for secretsSolution: 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.
agenix -e secret.age to create/edit encrypted secrets locally.age files to your Git repositoryage.secrets.<name>.filenixos-rebuild switch - secrets decrypt to /run/agenix/<name>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
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
];
}
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"
];
}
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
];
}
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;
};
};
}
mkdir -p secrets
cd secrets
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"
# 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.
# 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";
};
};
}
nixos-rebuild switch
The secret decrypts to /run/agenix/api-key with the specified permissions.
# Uses your SSH key for decryption
agenix -e api-key.age
# Or specify identity explicitly
agenix -e api-key.age -i ~/.ssh/id_ed25519
{ 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
{ 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}
'';
}
{ 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
'';
};
}
{ 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
'';
};
}
{ 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;
};
};
}
{ 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";
};
};
}
{ 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" ];
}];
};
}
Type: path
Required: Yes
Path to the encrypted .age file.
{
age.secrets.api-key.file = ./secrets/api-key.age;
}
Type: string
Default: /run/agenix/<name>
Path where the decrypted secret will be available.
{
age.secrets.monitrc = {
file = ./secrets/monitrc.age;
path = "/etc/monitrc";
};
}
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 readType: string
Default: "root"
Username of the file owner.
{
age.secrets.postgres-password = {
file = ./secrets/postgres-password.age;
owner = "postgres";
};
}
Type: string
Default: "root"
Group name of the file.
{
age.secrets.nginx-cert = {
file = ./secrets/nginx-cert.age;
owner = "nginx";
group = "nginx";
};
}
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.
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
}
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.
Type: string
Default: /run/agenix
Directory where secret symlinks are created.
{
age.secretsDir = "/run/keys";
}
Type: string
Default: /run/agenix.d
Directory for generation-specific secrets (internal use).
{
age.secretsMountPoint = "/run/secret-generations";
}
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 options are similar to NixOS with these differences:
Type: list of strings
Required: Yes (no default)
Must be explicitly configured:
{
age.identityPaths = [ "~/.ssh/id_ed25519" ];
}
Default: $XDG_RUNTIME_DIR/agenix (Linux) or temporary directory (Darwin)
{
age.secretsDir = "$HOME/.secrets";
}
# 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
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
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
-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 messageEDITOR: Editor used for -e flag (defaults to cp /dev/stdin in non-interactive mode)RULES: Path to secrets.nix file (defaults to ./secrets.nix)# 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
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
];
}
# 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"
]
# 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;
}
Enable Base64 PEM format for better diff readability:
# secrets.nix
{
"armored-secret.age" = {
publicKeys = [ user1 system1 ];
armor = true; # Use ASCII armor format
};
}
Benefits:
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";
}
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"
];
}
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";
};
}
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" = {};
}
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"
# ✅ 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 ];
}
{
age.secrets.sensitive-data = {
file = ./secrets/sensitive.age;
mode = "0400"; # Read-only for owner
owner = "service-user";
group = "service-group";
};
}
# ❌ 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
'';
};
}
# Update secret
agenix -e secret.age
# Deploy to all systems
nixops deploy
# Or use CI/CD
git commit -m "Rotate API keys"
git push
# secrets.nix
{
"prod-db.age".publicKeys = [ prod-admin prod-server ];
"dev-db.age".publicKeys = [ dev-admin dev-server ];
}
# 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
'';
};
}
# Auto-cleanup old secrets
{
systemd.tmpfiles.rules = [
"d /run/agenix 0755 root root 30d"
];
}
# .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
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
'';
};
}
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
'';
};
}
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;
})
];
}
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";
};
}
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
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
};
};
}
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
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
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
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})
'';
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
{
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;
};
};
}
{
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.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/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
{ 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"
];
};
}
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
'';
};
}
sops -d secrets.yaml > secrets.txt
agenix -e secret.age < secrets.txt
# Before (sops)
{
sops.secrets.api-key = {};
}
# After (agenix)
{
age.secrets.api-key.file = ./secrets/api-key.age;
}
git-crypt unlock
cat secrets/api-key > /tmp/api-key
agenix -e api-key.age < /tmp/api-key
shred -u /tmp/api-key
.age files to Git0400 or 0440builtins.readFile on secret pathsdb-password.age not secret1.age.age files0777 or 0644 for secretsWhat agenix protects against:
What agenix does NOT protect against:
/run/agenix/)Age provides confidentiality but not authentication:
.age files can modify encrypted contentAs of 2024, age is not post-quantum safe:
{
inputs.agenix.url = "github:ryantm/agenix";
outputs = { nixpkgs, agenix, ... }:
{
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
modules = [ agenix.nixosModules.default ];
};
};
}
# secrets.nix
{
"secret.age".publicKeys = [ user-key host-key ];
}
# configuration.nix
{
age.secrets.secret.file = ./secrets/secret.age;
}
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
{
age.secrets.secret = {
file = ./secrets/secret.age; # Required
path = "/run/agenix/secret"; # Default
mode = "0400"; # Default
owner = "root"; # Default
group = "root"; # Default
};
}
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!