Managing Homelab Secrets with NixOS and agenix
21 May 2026
The base NixOS host guide gets you to a machine that can boot, accept SSH, and rebuild cleanly. The Podman service guide adds one real app without pretending state is somebody else’s problem.
That still leaves one awkward question behind: where do the real secrets go once the placeholders stop being placeholders?
If the answer is “in a .nix file for now” or “in the repo, but in a separate file, so that probably counts,” the answer is wrong. The Nix store is readable to all local users. A secret that drifts into ordinary Nix evaluation is not being managed. It is being misplaced with extra confidence.
The working pattern is simple: keep an encrypted .age file in the repo, let the host decrypt it during activation, and hand the resulting runtime path to the service. The cleartext never needs to become a Nix string.
What This Guide Covers
This guide covers the smallest useful path between Building a Minimal NixOS Homelab Host and Adding a Homelab Service Declaratively with NixOS and Podman: one encrypted file in the repo, one runtime secret on the host, and one service consuming it.
In scope:
- why cleartext secrets do not belong in the Nix store
- what
agenixactually solves - one small
agenixlayout for a rebuildable homelab - one host-side secret path
- one service-side handoff using the
linkdingexample from the Podman service guide - validation without printing secrets back to the terminal
Out of scope:
sops-nixversusagenix- cloud KMS or Vault
- remote-builder trust design
- container-internal decryption
- secret rotation policy for a larger team
This is not a secrets-tool tournament. It is the smallest fix for the moment a password shows up.
The Problem Is Boring And Real
The Nix manual is blunt about this: the store is readable to all users on the system. That is enough on its own to rule out cleartext secrets inside ordinary Nix evaluation.
The mistake is common because it looks tidy: put a password in a Nix string, hide it in a second Nix file, or readFile something local and pretend the secret stayed outside the declarative path. It did not.
If the value gets pulled into evaluation in cleartext, you have turned “managed in Git” into “leaked with structure.”
What agenix Actually Does
agenix is a small NixOS/Home Manager tool built around age encryption and SSH keys.
What matters here is the deployment shape:
- encrypted
.agefiles can live in the repo - those encrypted files can move through the normal Nix deployment path
- the target host decrypts them during activation
- the running system consumes a runtime file path such as
/run/agenix/<name>
The repo stays versioned and reproducible without pretending secrets are configuration trivia.
Keep The Public Part Public
Not every value that a service can read from an env file deserves secret treatment.
For the linkding example from the Podman service guide:
publicBaseUrlis public configuration- loopback listen address is public configuration
- image name and host data path are public configuration
- bootstrap credentials are secret material
That split matters because stuffing every knob into one encrypted blob is only a more elaborate form of laziness.
Keep public settings in Nix. Keep secret values in the encrypted file. Let the service read the decrypted file at runtime.
The Smallest Useful Layout
Keep the launch version narrow:
.
├── flake.nix
├── hosts/
│ └── example-host/
│ └── configuration.nix
├── modules/
│ └── linkding.nix
└── secrets/
├── secrets.nix
└── linkding-bootstrap-env.age
The easy mistake here is treating secrets/secrets.nix like a host module. It is not. It only tells the agenix CLI which public keys should be able to decrypt which secret file later.
There are only four moving parts here:
secrets.nixsays who is allowed to open the encrypted file laterlinkding-bootstrap-env.ageis the encrypted file itselfage.identityPathstells the host which private key it should use to decrypt at activation timeconfig.age.secrets.<name>.pathis the runtime file path the service reads
The working order is:
- add
agenixto the flake and import its NixOS module - tell the host which private key it should use at activation time
- define recipients in
secrets/secrets.nix - create the encrypted
.agefile withagenix -e - declare that file under
age.secrets.<name> - point the service
envFileatconfig.age.secrets.<name>.path - rebuild the host and check the runtime file under
/run/agenix/
Two machines do two different jobs:
- your admin machine edits the encrypted file
- the target host decrypts it during activation and hands the runtime path to the service
Add agenix To The Host First
With flakes, the straightforward import path is:
{
inputs.agenix.url = "github:ryantm/agenix";
outputs = { self, nixpkgs, agenix, ... }: {
nixosConfigurations.example-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./hosts/example-host/configuration.nix
agenix.nixosModules.default
];
};
};
}
That is the structural step. It does not yet decide which key decrypts what.
For the launch example, the host uses its SSH host key as the runtime identity:
age.identityPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
It is the smallest pattern that fits this article.
Define One Synthetic Secret Cleanly
A public-safe secrets.nix can stay this small:
let
admin-workstation = "ssh-ed25519 AAAA... admin-workstation";
example-host = "ssh-ed25519 AAAA... example-host";
in
{
"linkding-bootstrap-env.age".publicKeys = [
admin-workstation
example-host
];
}
This says two things clearly:
- the admin machine can edit or rekey the secret later
- the host can decrypt it during activation
The plaintext file before encryption is intentionally tiny:
LD_SUPERUSER_NAME=replace-me
LD_SUPERUSER_PASSWORD=replace-me-with-a-long-random-password
That is enough to teach the pattern without dragging public URLs, ports, or other non-secret values into the encrypted blob.
Create or edit the encrypted file with agenix:
agenix -e secrets/linkding-bootstrap-env.age
Wire The Secret Into The Service Pattern
The host-side wiring is the real handoff. Keep the secret as a runtime file:
{
age.secrets.linkding-bootstrap-env = {
file = ../secrets/linkding-bootstrap-env.age;
owner = "root";
group = "root";
mode = "0400";
};
suiteDespair.services.linkding = {
enable = true;
dataDir = "/srv/linkding";
envFile = config.age.secrets.linkding-bootstrap-env.path;
publicBaseUrl = "https://bookmarks.example.test";
};
}
Read that snippet in order:
age.secrets.linkding-bootstrap-env.filepoints at the encrypted file committed in the repoowner,group, andmodedefine the runtime permissions of the decrypted filesuiteDespair.services.linkding.envFilepasses the runtime path into the service modulepublicBaseUrlstays as ordinary configuration because it is not secret material
The filename is not the point. The handoff is:
- the encrypted
.agefile is tracked - the decrypted file appears at runtime
- the service reads the runtime path
- Nix never needs the cleartext value as a string
That is why the agenix docs steer readers toward config.age.secrets.<name>.path.
It is also why builtins.readFile is the wrong instinct here. If you read the decrypted contents back into evaluation, you can push the cleartext right back into the world-readable store you were trying to avoid.
Validate The Chain, Not The Secret
Check the Nix side first:
nix flake check
Then check the runtime shape on the host:
stat /run/agenix/linkding-bootstrap-env
systemctl status podman-linkding.service --no-pager
curl -fsSI http://127.0.0.1:9090/
What you are confirming:
- the decrypted file exists at the runtime path
- ownership and mode are what you intended
- the service starts normally
- the app responds on the loopback address it was meant to use
What a public guide does not need to do:
catthe secret back to the terminal- dump the whole container environment
- take a screenshot with the credentials sitting in it like an unattended confession
What Stays Mutable
The declarative boundary is simple:
Declared:
- which module is imported
- which encrypted file exists
- which recipients can decrypt it
- which service consumes the runtime path
Mutable:
- the secret values themselves
- future recipient rotation
- bootstrap credentials after first start
That line matters because a declarative homelab is not meant to remove all mutable reality. It is meant to stop reality from being scattered across shell history, sticky notes, and improvised side channels.
Why This Is Enough
For a small homelab, this is enough. The repo keeps an encrypted secret file. The host decrypts it at activation time. The service reads a runtime path without dragging cleartext back through evaluation.
That is already better than leaving credentials in Nix files or in repo-adjacent clutter and calling it managed.