Secrets in a Public NixOS Repo with sops-nix and a YubiKey
11/05/2026 - ~12 Minutes
The Gap in the Flake
My NixOS config
declares almost everything about my machines, but until recently it stopped
at ~/.ssh and ~/.aws/credentials. Those got copied over by hand after every reinstall, because the obvious ways of
declaring them are both bad. The
repo
is public, and anything that passes through
home.file or programs.awscli.credentials lands in the world-readable Nix store.
sops-nix
closes that gap. My private SSH keys and AWS credentials now live in the public
repo, encrypted, and a plain nixos-rebuild switch puts them in place. Getting there came down to four things: how the secret
files are structured, what the YubiKey is for, why all my hosts share one decryption key, and when decryption actually happens.
The Shape of the YAML Files
There are two secret files, plus a config file that says who can read them:
.sops.yaml # who can decrypt
secrets/ssh.yaml # SSH private keys and the shared machine key
secrets/aws.yaml # ~/.aws/credentials
Unlike a fully encrypted blob, a sops file is still valid YAML that you can read. The keys stay in plaintext and only the values
are encrypted. Here’s secrets/ssh.yaml with the long values trimmed:
ssh:
id_ed25519: ENC[AES256_GCM,data:GlGrCHqXpgSd...,iv:...,tag:...,type:str]
id_ed25519_sk: ENC[AES256_GCM,data:zWynb1bQJoPA...,iv:...,tag:...,type:str]
id_rsa: ENC[AES256_GCM,data:BwwOV2WQ2/lO...,iv:...,tag:...,type:str]
id_rsa2048: ENC[AES256_GCM,data:7w48g6RqozpY...,iv:...,tag:...,type:str]
machine_key: ENC[AES256_GCM,data:BHfL6BXXwXuk...,iv:...,tag:...,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
recipient: age1yubikey1qgxmss0rlj0uxekhghzpsn6uyk35w0ndfc4m3w7xxz8yns4nnsqu7ku6v7t
- enc: |
...
recipient: age1gnsa8u2fv2mcjuq0nrprfvfjsf723w0aw2ltcswzz9vljzrw0y4s5nfxv7
- enc: |
...
recipient: age1ue2f9clydgy3k3525qqfhkymqd5mafr5rzsvkf33amwz84qv6c8quuspsf
lastmodified: "2026-11-03T18:17:57Z"
mac: ENC[AES256_GCM,data:...]
unencrypted_suffix: _unencrypted
version: 3.13.3
The file has two parts:
- The data. Each value is encrypted with AES-256-GCM under a single data key. A multi-line value, like a PEM private key, is just one string.
- The
sops:block. This is the metadata, and it has an entry for each recipient. Eachencis that same data key, encrypted to that recipient with age . Any one recipient can recover the data key and read everything. Themaccovers the values, so tampering with the ciphertext gets noticed at decryption.
That structure has a cost in a public repo: anyone can read the key names. The world knows I have something called id_rsa2048.
I can live with that, but a key name shouldn’t itself be sensitive.
secrets/aws.yaml is even simpler, because it’s one key holding the entire credentials file as a single string:
aws_credentials: ENC[AES256_GCM,data:VuPzHALQP7Y9...,iv:...,tag:...,type:str]
sops:
# ...the same three recipients
Who Can Read Them
.sops.yaml names the recipients, using YAML anchors, and a rule applies them to everything in secrets/:
keys:
# Touch-only, with no PIN: activation has no terminal to ask for one (see sops.nix).
- &yubikey age1yubikey1qgxmss0rlj0uxekhghzpsn6uyk35w0ndfc4m3w7xxz8yns4nnsqu7ku6v7t
# Kept offline, so losing the YubiKey doesn't also lose the ability to edit.
- &backup age1gnsa8u2fv2mcjuq0nrprfvfjsf723w0aw2ltcswzz9vljzrw0y4s5nfxv7
# Shared by all hosts, in /var/lib/sops-nix/key.txt. Decrypts at boot.
- &machines age1ue2f9clydgy3k3525qqfhkymqd5mafr5rzsvkf33amwz84qv6c8quuspsf
creation_rules:
- path_regex: secrets/.*\.yaml$
key_groups:
- age:
- *yubikey
- *backup
- *machines
All three sit in one key group, which means any one of them is enough. (Separate groups would instead require a key from each group, which isn’t what I want here.)
This file is only the policy. What actually decides who can decrypt is the sops: block inside each secret file, so after
changing the list, each file needs re-encrypting to match:
sops updatekeys secrets/ssh.yaml
sops updatekeys secrets/aws.yaml
From YAML Keys to Files
On the Nix side, a / in a secret’s name walks down through the nested YAML, and it also becomes the path under /run/secrets:
sops = {
defaultSopsFile = ./secrets/ssh.yaml;
age = {
inherit keyFile; # /var/lib/sops-nix/key.txt
plugins = [ pkgs.age-plugin-yubikey ];
# Stop sops-nix falling back to the SSH host keys it uses by default.
sshKeyPaths = [ ];
};
gnupg.sshKeyPaths = [ ];
secrets =
lib.genAttrs
[ "ssh/id_ed25519" "ssh/id_ed25519_sk" "ssh/id_rsa" "ssh/id_rsa2048" ]
(_: {
owner = "brian";
mode = "0400";
})
// {
machine_key = { };
aws_credentials = {
sopsFile = ./secrets/aws.yaml;
owner = "brian";
mode = "0400";
};
};
};
So ssh/id_ed25519 reads ssh: → id_ed25519: from the YAML and becomes /run/secrets/ssh/id_ed25519. aws_credentials is the
one secret that needs its own sopsFile, since it isn’t in the default file.
The YubiKey
age-plugin-yubikey
generates an age key inside one of the YubiKey’s PIV slots.
The private half never leaves the device. What you get back is a recipient (age1yubikey1...) to encrypt to, and an identity
line (AGE-PLUGIN-YUBIKEY-...), which is only a pointer telling the plugin which key to ask for. That identity goes in the
place sops looks by default, ~/.config/sops/age/keys.txt. It’s safe to leave world-readable, because it contains no key material.
For day-to-day use, the YubiKey is how I edit:
sops secrets/aws.yaml # the key blinks; touch it; $EDITOR opens the plaintext
Two pieces have to be in place for that to work. age-plugin-yubikey must be on PATH, because sops finds age plugins by name.
And PIV goes through PC/SC, so the machine needs services.pcscd.enable = true. My FIDO2 SSH key works without pcscd, which
makes this an easy requirement to miss.
Why There’s No PIN
My first identity used the default PIN policy, which was fine at a terminal. Then I decided the YubiKey should also bootstrap new
hosts during nixos-rebuild switch (more on that below), and that breaks a PIN: activation scripts have no terminal to prompt on.
So I regenerated the identity as touch-only:
age-plugin-yubikey --generate --force --slot 1 --name sops-nix \
--pin-policy never --touch-policy always > ~/.config/sops/age/keys.txt
--force destroys the old key in that slot, and this is where the offline backup key earned its place. With the old YubiKey
identity gone, the backup key was what let me run sops updatekeys and move the files over to the new recipient.
This is a real trade-off. The ciphertext is public and there’s no PIN, so someone holding my YubiKey needs only a touch to decrypt these files. Touch does still stop a remote attacker or a stray process from using the key silently, and my SSH keys have their own passphrases on top. The AWS credentials don’t have that second layer, so a lost YubiKey means rotating them.
One Shared Key to Decrypt Them All
The YubiKey can’t be the thing that decrypts at boot, because it isn’t always plugged in and nobody’s there to touch it. Each host
needs a key on disk. The usual sops-nix approach is to derive an age recipient from each host’s SSH host key, and I started down
that road. The trouble is reinstalls: a new install means a new host key, which means a new recipient, which means updating
.sops.yaml and re-encrypting the files before the fresh host can read anything.
So instead, all my hosts (beast, stealth, and nixie) share a single age key at /var/lib/sops-nix/key.txt. Reinstalling a
host changes nothing about the recipients, and the secret files never need touching.
That raises the question of how the key gets onto a fresh machine. I tried a helper script to run from the installer CD, then
threw it out, because the answer turned out to be sitting in the file format: the machine key is itself a secret in
secrets/ssh.yaml. It’s the machine_key entry above, encrypted to the YubiKey, the backup, and, a little circularly, itself.
The machine key can’t decrypt itself until it’s installed, which is where the YubiKey comes back. sops.nix adds two activation
steps around sops-nix’s own setupSecrets:
system.activationScripts = {
# 1. No machine key yet? Point sops-nix at the YubiKey for this activation.
sopsMachineKeyBootstrap.text = ''
if ! grep -qs '^AGE-SECRET-KEY-' ${keyFile} && [ -S /run/pcscd/pcscd.comm ]; then
identity=$(timeout 10 ${pkgs.age-plugin-yubikey}/bin/age-plugin-yubikey --identity </dev/null 2>/dev/null || true)
if [[ $identity == *AGE-PLUGIN-YUBIKEY-* ]]; then
echo "sops-nix: no machine key yet, so using the YubiKey. Touch it when it blinks."
mkdir -p ${dirOf keyFile}
(umask 077; printf '%s\n' "$identity" >${keyFile})
else
echo "sops-nix: no machine key yet, and no YubiKey found." >&2
fi
fi
'';
# 2. setupSecrets decrypts everything, including machine_key.
setupSecrets.deps = [ "sopsMachineKeyBootstrap" ];
# 3. Copy machine_key over the temporary identity.
sopsMachineKeyInstall = {
deps = [ "setupSecrets" ];
text = ''
if [ -e /run/secrets/machine_key ] && ! ${pkgs.diffutils}/bin/cmp -s /run/secrets/machine_key ${keyFile}; then
install -m 400 /run/secrets/machine_key ${keyFile}.tmp
mv ${keyFile}.tmp ${keyFile}
echo "sops-nix: installed ${keyFile}. The YubiKey is no longer needed to boot or rebuild."
fi
'';
};
};
The temporary key.txt holds only the YubiKey’s identity pointer, so sops-nix ends up asking the YubiKey to decrypt without
knowing anything special happened. Step 3 then replaces the pointer with the real key. It has to be a copy to disk: sops-nix’s own
path option would only link into /run/secrets, which is empty at the next boot, exactly when the key is needed to fill it.
And because step 3 compares on every activation, replacing machine_key in the YAML rolls the new key out to every host on its
next rebuild.
From beast, the whole thing looks like this:
activating the configuration...
setting up /etc...
sops-nix: no machine key yet, so using the YubiKey. Touch it when it blinks.
adding secret: machine_key
sops-nix: installed /var/lib/sops-nix/key.txt. The YubiKey is no longer needed to boot or rebuild.
Reinstalling Is Two Passes
The installer has no pcscd, so nixos-install skips the bootstrap and logs a sops error. That’s expected, and the system still
boots with password login. After that, I plug in the YubiKey, run nixos-rebuild switch, and touch the key when it blinks. A host
switching to this config for the first time may need one more run, because pcscd isn’t up until the first switch finishes.
I tested this for real by reinstalling stealth and then SSHing between it and beast in both directions.
What Sharing Costs
Per-host keys have a real security advantage that I gave up. Root on any of my machines can read all of these secrets, and I can’t revoke one host on its own. That’s acceptable for three personal machines that all hold the same SSH keys anyway; the desktops stay put, and the laptop has full disk encryption. It wouldn’t be the right call for a fleet, or for hosts that don’t share a trust level.
A public repo also adds a catch to rotation. Old commits still hold the old ciphertext, encrypted to the old recipients, forever. If the machine key ever leaks, replacing it isn’t enough. Everything it could decrypt has to be treated as exposed, which means new SSH keys and new AWS credentials too.
Decrypting at Run Time
This is the part I find most elegant about sops-nix, and it’s what makes all of the above safe: secrets are never decrypted while building. Nix evaluation and the Nix store only ever see ciphertext. The real values come into existence when the system activates, on the machine that’s going to use them.
At Build Time
nixos-rebuild copies secrets/ssh.yaml into the Nix store as is. That’s fine, since it’s just as public there as it is on
GitHub. It also generates a manifest.json, which lists each secret’s source file, key, owner, mode, and target path. Nix options
deal in those paths and never in the secret values, so nothing in the config can accidentally interpolate a secret into the store.
The build does check the manifest, though, and because key names are in plaintext, it can do that with no keys at all. I got to
see this work when I wired up machine_key before adding it to the YAML:
sops-install-secrets: manifest is not valid: secret machine_key in
/nix/store/...-ssh.yaml is not valid: the key 'machine_key' cannot be found
The rebuild failed right there, rather than leaving a half-working system after activation.
At Activation
The activation script setupSecrets runs on every switch and every boot, after users and groups exist, so that owner = "brian"
can resolve. It sets HOME and a PATH containing the configured age plugins, then runs a small Go program, sops-install-secrets,
against the manifest. That program:
- Mounts a
ramfsat/run/secrets.d, if it isn’t there already. - Points sops at the age key in
key.txtand decrypts every secret into memory. - Creates a new numbered generation directory, like
/run/secrets.d/4, and writes the secrets there with their owners and modes. - Atomically repoints the
/run/secretssymlink at the new generation. - Prunes older generations, by default keeping only the current one.
A few nice properties come from that design:
- Plaintext stays in memory.
ramfsis never swapped to disk, and it’s gone at power-off. Every boot starts from an empty/runand decrypts again. The only sensitive file at rest iskey.txtitself. - Failures leave things as they were. Decryption happens before anything is written, and the symlink swap is atomic, so a
failed switch doesn’t leave
/run/secretshalf-updated. - The system still comes up. A failed decrypt is a logged activation error, not a failed boot. That’s the reason the two-pass reinstall works at all.
Getting Them Where They’re Needed
home.nix then links the decrypted files into place with mkOutOfStoreSymlink, so the Nix store holds a path but not a secret:
".ssh/${name}".source = config.lib.file.mkOutOfStoreSymlink "/run/secrets/ssh/${name}";
".aws/credentials".source = config.lib.file.mkOutOfStoreSymlink "/run/secrets/aws_credentials";
I use the NixOS module rather than sops-nix’s home-manager one so that everything runs unattended at boot from the one shared key.
It also changes how the secrets behave day to day. ~/.aws/credentials is now a symlink into a read-only location, so aws configure
can’t change it. The new workflow is sops secrets/aws.yaml, commit, and rebuild on each host. And sops-nix only manages files, so
anything loaded from those files is still on me: after a reinstall, I have to ssh-add the keys back into gpg-agent, since Nix
doesn’t manage the contents of ~/.gnupg.
Where It Ended Up
For a day-to-day rebuild, the YubiKey isn’t involved at all, and neither am I. A brand-new install takes one extra nixos-rebuild switch and
one touch of the key, and after that the machine has everything: SSH keys, AWS credentials, and the means to decrypt them again on every
boot. Those keys are finally in the flake with everything else, and the public repo only ever sees ciphertext.
Like the last few posts, this one was written by Claude Code, working from the nixos repo, the session where we set this up, and the
sops-nix source pinned in flake.lock.
When a Pinned Hyprland Meets a Moving Mesa
10/02/2026 - ~4 Minutes
The Symptom
A routine nix flake update, a nixos-rebuild switch, everything fine. A couple of days later I rebooted, typed my password
into SDDM, and never got a desktop. The greeter accepted the login and then the hand-off to Hyprland just didn’t land.
SDDM was where the problem showed up, so that’s where the investigation started. But SDDM turned out to be fine: it was doing its job and handing off to Hyprland correctly. Hyprland was the one dying, before it drew a single frame, and its log had two lines that mattered:
GLIBC_2.43 not found (required by .../libgallium-....so)
CBackend::create() failed
The delay is the sneaky part. A switch doesn’t restart the compositor you’re already sitting in, and that compositor already
has the old Mesa mapped into memory. The broken combination only gets exercised on the next cold start, so the update that
caused it and the crash that reveals it can be days apart.
Two nixpkgs, One Driver
In the last NixOS post I mentioned that Hyprland is pinned to a specific commit, because a later change broke Waybar’s workspace module. What I didn’t think hard about at the time is everything else that pin freezes. Hyprland’s flake brings its own nixpkgs, and pinning Hyprland’s commit pins that nixpkgs along with it. So the system ends up with two package sets living side by side:
- My nixpkgs, which moves every time I run
nix flake update. After the latest one, it was on glibc 2.43. - Hyprland’s nixpkgs, frozen at the pin. Still on glibc 2.42.
Most of the time that’s fine. Two package sets can coexist in the Nix store without ever touching each other. The graphics
driver is the exception. NixOS doesn’t give each program its own Mesa. It installs one system-wide copy at
/run/opengl-driver, and every GL/Vulkan client loads that copy at runtime: Hyprland, SDDM’s greeter, Steam, the browser,
everything. That copy came from hardware.graphics.package, which by default means my nixpkgs.
Once my nixpkgs moved to a Mesa built against glibc 2.43, Hyprland, which is linked against 2.42, tried to load a library that needed symbols its libc doesn’t have. Dead on arrival.
Keeping Everything in Sync
glibc is backward compatible but not forward compatible. A library built against an older glibc runs fine in a process linked to a newer one. The reverse doesn’t work. So the one shared driver has to be built against the oldest glibc of anything that loads it. With the Hyprland pin in place, that’s Hyprland’s.
The obvious first idea was to go the other way and make Hyprland follow my nixpkgs:
hyprland = {
url = "github:hyprwm/Hyprland/<pinned commit>";
inputs.nixpkgs.follows = "nixpkgs";
};
That doesn’t build. The pinned Hyprland’s hyprutils is too old for the hyprtoolkit that current nixpkgs ships. The pin
exists precisely because I can’t move Hyprland forward yet, so dragging its dependencies forward runs into the same wall.
The fix that does work is the one the Hyprland wiki documents: leave Hyprland alone and take Mesa from Hyprland’s nixpkgs. Flake inputs expose their own inputs, so that nixpkgs is right there:
hardware.graphics =
let
hyprPkgs = inputs.hyprland.inputs.nixpkgs.legacyPackages.${pkgs.stdenv.hostPlatform.system};
in
{
enable = true;
enable32Bit = true;
package = hyprPkgs.mesa;
package32 = hyprPkgs.pkgsi686Linux.mesa;
};
Now Hyprland gets a Mesa built against exactly the glibc it was linked with. Everything else on the system, SDDM’s greeter
included, is built from my newer nixpkgs, and it loads that older-glibc Mesa without complaint. The driver is pinned to the
lowest common denominator, which happens to be the one input I’d already pinned. package32 matters too: Steam and Proton
load the 32-bit driver, and leaving it on my nixpkgs would just reopen the same mismatch in 32-bit land.
The Catch
This override only makes sense while the Hyprland pin exists. The day Waybar catches up and Hyprland goes back to following
nixpkgs, the two package sets merge back into one and the override turns into a quiet downgrade: an old Mesa on an otherwise
current system. So the comment above it in configuration.nix says exactly that: drop this once the Hyprland pin is gone.
The project notes beside the Hyprland pin say the same thing from the other direction. The pin and the override need to come
out together, so each one points at the other.
The broader lesson: pinning a flake input pins its entire dependency closure, not just the one package you cared about. Usually that’s invisible. It stops being invisible the moment something from the pinned side has to share a runtime-loaded library with everything else.
Like the last couple of posts, this one was written by Claude Code, working from the commit in the nixos repo.