Managing Homelab Secrets with NixOS and agenix

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 agenix actually solves
  • one small agenix layout for a rebuildable homelab
  • one host-side secret path
  • one service-side handoff using the linkding example from the Podman service guide
  • validation without printing secrets back to the terminal

Out of scope:

  • sops-nix versus agenix
  • 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 .age files 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:

  • publicBaseUrl is 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.nix says who is allowed to open the encrypted file later
  • linkding-bootstrap-env.age is the encrypted file itself
  • age.identityPaths tells the host which private key it should use to decrypt at activation time
  • config.age.secrets.<name>.path is the runtime file path the service reads

The working order is:

  1. add agenix to the flake and import its NixOS module
  2. tell the host which private key it should use at activation time
  3. define recipients in secrets/secrets.nix
  4. create the encrypted .age file with agenix -e
  5. declare that file under age.secrets.<name>
  6. point the service envFile at config.age.secrets.<name>.path
  7. 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.file points at the encrypted file committed in the repo
  • owner, group, and mode define the runtime permissions of the decrypted file
  • suiteDespair.services.linkding.envFile passes the runtime path into the service module
  • publicBaseUrl stays as ordinary configuration because it is not secret material

The filename is not the point. The handoff is:

  • the encrypted .age file 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:

  • cat the 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.

Source Notes