Skip to content
tentaflake docsv0.4

Agenix Secrets — Encrypted Agent Credentials

Source: docs/04-agenix-secrets.md at 2081f31a099e · docs version 0.4.0

Security-profile boundary: agenix keeps plaintext out of Git and the Nix store, but passing a decrypted file into an agent still gives that agent the real credential. balanced and strict reject envFile and agenixFile. The direct-agent examples below are retained only for the explicit dev compatibility profile. Secure profiles use agenix for host-side brokers, Grafana credentials, Git auto-push, and backup credentials; the agent should receive only a scoped, revocable virtual broker key.

For a balanced agent, declare the decrypted provider value as a single secret file and point only the LLM broker at it:

age.secrets.openai-key = {
file = ./secrets/openai-key.age;
owner = "root";
group = "root";
mode = "0400";
};
tentaflake.broker.agents.hermes-coding = {
enable = true;
subnet = "10.203.20.0/30";
gateway = "10.203.20.1";
llm = {
enable = true;
upstreamBaseUrl =
"https://api.openai.com/v1/";
providerCredentialFile =
config.age.secrets.openai-key.path;
allowedModels = [
{
name = "gpt-5-mini";
inputMicrousdPerMillion = 250000;
outputMicrousdPerMillion = 2000000;
}
];
};
};

systemd copies the value into the broker’s private credential directory. The agent receives only its random virtual key. The longer direct-container environment examples below apply only to dev.

This guide covers encrypting agent API keys and tokens — for either the Hermes or ZeroClaw runtime — with agenix so they can be committed to Git safely and decrypted only at NixOS activation time.

Approach Secrets in Git? Secrets in Nix Store? Complexity
Plain .env files ❌ (must gitignore) ❌ (if mounted correctly) Low
Agenix ✅ (encrypted .age files) ❌ (decrypted at runtime) Medium
builtins.readFile SECRETS IN STORE Low (dangerous)
External vault (Vault, Doppler) N/A N/A High

Agenix gives you the best balance: secrets encrypted in Git, decrypted only at activation, never in the Nix store, and no external vault dependency.

┌─────────────────────────────────────────────────────────────┐
│ Git Repo (public/private) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ secrets/hermes-coding.env.age (encrypted) │ │
│ │ secrets/hermes-research.env.age (encrypted) │ │
│ │ secrets.nix (recipients) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ nixos-rebuild switch
┌─────────────────────────────────────────────────────────────┐
│ Nix Store (world-readable) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ /nix/store/...-hermes-coding.env.age (copied) │ │
│ │ /nix/store/...-hermes-research.env.age (copied) │ │
│ │ ↑ STILL ENCRYPTED ↑ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ agenix activation script
┌─────────────────────────────────────────────────────────────┐
│ Runtime (root-only, tmpfs) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ /run/agenix/hermes-coding-env (plaintext, 0600) │ │
│ │ /run/agenix/hermes-research-env (plaintext, 0600) │ │
│ │ ↑ NEVER IN STORE ↑ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ Docker --env-file mount
┌─────────────────────────────────────────────────────────────┐
│ Docker Container (isolated) │
│ │ OPENROUTER_API_KEY=sk-or-... │
│ │ TELEGRAM_BOT_TOKEN=... │
└─────────────────────────────────────────────────────────────┘

Uncomment the agenix input and module import:

flake.nix
{
inputs = {
# ...
agenix = {
url = "github:ryantm/agenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, agenix, ... }@inputs:
# ...
nixosConfigurations.tentaflake = nixpkgs.lib.nixosSystem {
modules = [
inputs.agenix.nixosModules.age # <-- add this
./configuration.nix
];
};
}

2. Create secrets.nix (recipients + secret declarations)

Section titled “2. Create secrets.nix (recipients + secret declarations)”

Copy secrets.nix.example to secrets.nix and edit:

Terminal window
cp secrets.nix.example secrets.nix

Edit secrets.nix to set your agent names and SSH public keys:

secrets.nix
{ config, lib, pkgs, ... }:
let
# Your agent names (match mkHermesAgent / mkZeroClawAgent calls)
agentNames = [
"hermes-coding"
"hermes-research"
"zeroclaw-assistant"
];
# SSH public keys of machines that can decrypt.
# NEVER put private keys here.
recipients = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..." # Your dev machine
# Host key auto-detected on NixOS — add others as needed
];
mkSecret = name: {
"${name}-env" = {
file = ./secrets/${name}.env.age;
owner = name;
group = name;
mode = "0600";
};
};
in
{
age.secrets = lib.mergeAttrsList (map mkSecret agentNames);
}

Import it in configuration.nix:

configuration.nix
{
imports = [
./modules/...
./secrets.nix # <-- add this
];
}
Terminal window
# One-time:
nix profile install nixpkgs#agenix
# Or use ad-hoc:
nix shell nixpkgs#agenix -c agenix --help
Terminal window
# Create the secrets directory
mkdir -p secrets
# Create encrypted secret files
echo "OPENROUTER_API_KEY=sk-or-..." | agenix -e secrets/hermes-coding.env.age --stdin
echo "OPENROUTER_API_KEY=sk-or-..." | agenix -e secrets/hermes-research.env.age --stdin
# Or edit interactively:
agenix -e secrets/hermes-coding.env.age

Each .age file contains the environment variables for one agent:

OPENROUTER_API_KEY=sk-or-v1-abc123...
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234...

The same works for a ZeroClaw agent — see zeroclaw.env.example for its ZEROCLAW_<section>__<sub>__<key> env-var convention (double underscores):

Terminal window
echo "ZEROCLAW_providers__models__openrouter__default__api_key=sk-or-..." \
| agenix -e secrets/zeroclaw-assistant.env.age --stdin

This section applies only when the host explicitly selects:

tentaflake.security.profile = "dev";

In my-agents.nix, use agenixFile instead of envFile. Both runtimes accept it the same way — mkHermesAgent and mkZeroClawAgent:

my-agents.nix
{ mkHermesAgent, mkZeroClawAgent }:
let
hermesAgents = [
{
name = "coding";
agenixFile = "/run/agenix/hermes-coding-env"; # ← matches secrets.nix path
settings = {
model.default = "openrouter/anthropic/claude-sonnet-4";
toolsets = [ "terminal" "memory" "file" "skills" ];
};
}
{
name = "research";
agenixFile = "/run/agenix/hermes-research-env";
settings = {
model.default = "openrouter/deepseek/deepseek-v4-flash";
toolsets = [ "terminal" "web" "memory" "file" "skills" ];
};
}
];
zeroclawAgents = [
{
name = "assistant";
agenixFile = "/run/agenix/zeroclaw-assistant-env";
hostPort = 9246;
servePort = 9145;
# Trimmed for brevity — see the zeroclawAgents entry in
# my-agents.nix.example for the full required settings
# (runtime_profiles, agents.main, risk_profiles, …).
settings.schema_version = 3;
settings.providers.models.openrouter.default.model = "anthropic/claude-haiku-4.5";
}
];
in
map mkHermesAgent hermesAgents ++ map mkZeroClawAgent zeroclawAgents

The runtime path follows this convention:

/run/agenix/<name-from-secrets.nix>
└─ mkSecret creates "${name}-env"
→ path = /run/agenix/hermes-coding-env

On the target NixOS machine, ensure the host’s SSH key is available:

# In your flake.nix params or configuration.nix:
age.identityPaths = [
"/etc/ssh/ssh_host_ed25519_key"
];

This is typically auto-detected on NixOS. If you use impermanence, point to the persistent path:

age.identityPaths = [
"/persist/etc/ssh/ssh_host_ed25519_key"
];
Terminal window
sudo nixos-rebuild switch --flake /etc/nixos#tentaflake

Agenix decrypts the .age files during activation and places plaintext at /run/agenix/. Only the explicitly selected dev compatibility profile passes such a file to an agent as --env-file; balanced supplies individual files to host-side services through systemd credentials instead.

After rebuild, verify secrets are wired without revealing contents:

Terminal window
# Check file exists with correct permissions
ls -l /run/agenix/
# Expected: -rw------- 1 hermes-coding hermes-coding ... hermes-coding-env
stat -c '%U %G %a %n' /run/agenix/hermes-coding-env
# Expected: hermes-coding hermes-coding 600 .../hermes-coding-env
# Check the agent container has the env file mounted
docker inspect hermes-coding | jq '.[0].Mounts[] | select(.Source | startswith("/run/agenix"))'

Never run cat /run/agenix/* or agenix -d to verify contents — use permissions and process checks instead.

If multiple people need to edit secrets, add their SSH public keys to secrets.nix recipients and rekey:

Terminal window
# After adding new recipients to secrets.nix:
cd secrets
agenix --rekey

The .age format supports multiple recipients — each can decrypt independently.

When an API key or token changes (provider rotation, suspected leak, expiry), edit the .age file, rebuild, and restart the agent — the container only reads its --env-file at start:

Terminal window
agenix -e secrets/hermes-coding.env.age # replace the old key
sudo nixos-rebuild switch --flake /etc/nixos#tentaflake
tentaflake restart coding # pick up the new env file

Don’t forget to revoke the old key at the provider (OpenRouter, Telegram, …).

Rotating or Removing a Compromised Recipient Key

Section titled “Rotating or Removing a Compromised Recipient Key”

If a recipient’s SSH private key is compromised (lost laptop, leaked key):

Terminal window
# 1. Remove or replace the key in secrets.nix `recipients`
# 2. Re-encrypt every .age file for the new recipient set
agenix --rekey
# 3. Commit, rebuild, and revoke the old key everywhere else it grants
# access (GitHub, authorized_keys on servers, ...)

Rekeying does not rewrite Git history — old ciphertext remains decryptable by the compromised key. Treat every secret value it could read as exposed and rotate those too (see above).

Decryption needs any one listed recipient, so a reinstalled host is recoverable from any surviving recipient machine (e.g. your dev machine):

Terminal window
# On the surviving recipient machine:
# Get the reinstalled host's public key (awk strips the leading hostname —
# recipients need the bare "ssh-ed25519 AAAA..." form):
ssh-keyscan -t ed25519 <new-host> | awk '{print $2, $3}'
# Add it to secrets.nix `recipients`, then:
agenix --rekey
# Commit, then deploy to the new host as usual

If the only recipient key is lost, the secrets are unrecoverable — there is no passphrase fallback. Always keep at least one offline recovery recipient (e.g. an admin age key stored off-machine) in recipients.

This template sets no policy — pick one in your fork. A common example: rotate agent API keys every 90 days, and rekey immediately whenever a recipient machine is decommissioned or a key is suspected compromised.

  • agenix input uncommented in flake.nix
  • inputs.agenix.nixosModules.age imported in host modules
  • secrets.nix created from secrets.nix.example
  • No builtins.readFile config.age.secrets.*.path anywhere in Nix
  • age.identityPaths uses runtime strings (not Nix paths)
  • owner/group/mode set to restrict access per agent
  • .env files excluded via .gitignore (secrets/*.env)
  • .age files tracked in Git
  • No private keys or decrypted secrets committed to Git
  • Agent containers read agenixFile not envFile when using agenix
Symptom Likely Cause Fix
agenix: command not found CLI not installed nix shell nixpkgs#agenix
.age file not decrypting Recipient key missing or wrong Check secrets.nix recipients match ~/.ssh/id_ed25519.pub
Agent can’t read env file Wrong owner/mode Set owner = "agent-name" and mode = "0600" in secrets.nix
/run/agenix/ empty after rebuild Module not imported Verify inputs.agenix.nixosModules.age in host modules
error: path ... is not in the Nix store Private key referenced as Nix path Use string: age.identityPaths = [ "/etc/ssh/..." ]