Secrets Management
Secrets are managed with agenix, which encrypts secrets at rest using age and decrypts them at NixOS activation time. Secrets never enter the Nix store.
Source: server/secrets/secrets.nix, server/modules/restic.nix (identity paths)
How agenix Works
- Secrets are encrypted with
ageusing one or more public keys (SSH Ed25519 keys) - Encrypted
.agefiles are committed to the Git repository - At NixOS activation, agenix decrypts them using the corresponding private key on the host
- Decrypted secrets are placed in
/run/agenix/(tmpfs) with restricted file permissions
┌──────────────────────────────────────────────┐
│ Developer workstation │
│ age -e -R secrets.nix secret.txt │
│ └── secret.age (committed to git) │
└──────────────┬───────────────────────────────┘
│ git push
┌──────────────▼───────────────────────────────┐
│ Server (NixOS activation) │
│ agenix reads /persist/etc/ssh/ssh_host_* │
│ └── decrypts to /run/agenix/secret │
│ owner: service user │
│ mode: 440 │
└──────────────────────────────────────────────┘Identity Paths
Because the server uses impermanence (root is wiped on every boot), SSH host keys live on the persistent volume:
age.identityPaths = [ "/persist/etc/ssh/ssh_host_ed25519_key" ];This is configured in server/modules/restic.nix and applies globally. Without this, agenix would look for keys at /etc/ssh/ which is wiped on boot.
Critical Path
If /persist/etc/ssh/ssh_host_ed25519_key is lost, all secrets become undecryptable. This key is backed up by Restic (the /persist/etc/ssh directory is in the backup paths). Keep an offline copy of this key.
Public Keys
Two public keys can encrypt secrets:
let
PublicKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBjCf1PpvoMshFkoyjFOYUJ6/pLexwEFqr29COJawkoB"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILhLtSav5LaQK7/F79Kg+xAZUty68E4sf2gPNUgfu7IP"
];Both keys can decrypt any secret. This allows re-keying from either the server itself or a separate admin machine.
Secret Inventory
All secrets and their consuming services:
| Secret File | Service | Owner | Purpose |
|---|---|---|---|
restic_password.age | Restic | root | Backup repository encryption passphrase |
cloudflare_dns_token.age | Traefik | Container | Cloudflare API token for DNS-01 ACME challenges |
cloudflare_tunnel_token.age | Cloudflared | cloudflared | Tunnel authentication token |
authelia_jwtSecretFile.age | Authelia | Container | JWT signing secret |
authelia_sessionSecretFile.age | Authelia | Container | Session encryption secret |
authelia_storageEncryptionKeyFile.age | Authelia | Container | SQLite storage encryption key |
authelia_oidcIssuerPrivateKeyFile.age | Authelia | Container | OIDC token signing private key |
authelia_oidcHmacSecretFile.age | Authelia | Container | OIDC HMAC secret |
authelia_users_database.age | Authelia | Container | User credentials YAML file |
nextcloud_client_secret.age | OpenCloud | Container | OIDC client secret for Nextcloud/OpenCloud |
nextcloud_adminpassFile.age | OpenCloud | Container | Admin password |
Secret Permissions
Each secret specifies ownership and permissions:
age.secrets.cloudflare_tunnel_token = {
mode = "440";
owner = "cloudflared";
group = "cloudflared";
file = ../secrets/cloudflare_tunnel_token.age;
};| Field | Value | Effect |
|---|---|---|
mode | "440" | Owner and group can read; no one else can access |
owner | Service user | Only the service user can read the secret |
group | Service group | Group members can also read (useful for containers) |
file | Relative path | Path to the encrypted .age file in the repo |
Secrets for Containers
Secrets consumed by NixOS containers are decrypted on the host and bind-mounted into the container's filesystem. The container never has direct access to the age private key.
Adding a New Secret
- Create the secret file:
echo -n "my-secret-value" > /tmp/new_secret.txt- Add the secret to
secrets.nix:
"new_secret.age".publicKeys = PublicKeys;- Encrypt with agenix:
cd server/secrets
agenix -e new_secret.age < /tmp/new_secret.txt
rm /tmp/new_secret.txt- Reference in your module:
age.secrets.new_secret = {
mode = "440";
owner = "myservice";
group = "myservice";
file = ../secrets/new_secret.age;
};- Use in service config:
serviceConfig.EnvironmentFile = config.age.secrets.new_secret.path;
# or
ExecStart = "... --secret-file ${config.age.secrets.new_secret.path}";Re-keying Secrets
If you need to change which public keys can decrypt secrets (e.g., after rotating the server's SSH host key):
cd server/secrets
agenix -r # re-encrypts all secrets with the keys listed in secrets.nixThis requires access to at least one of the current private keys to decrypt the secrets before re-encrypting them with the new key set.