In my previous article about Nix, I explained why I switched to Nix and Flakes to push the GitOps philosophy all the way: my entire system and tools are defined in versioned code.
However, as soon as you start putting everything into Git (especially in a public repository or one shared across multiple machines), you quickly hit a wall: what do we do with secrets?
The secrets paradox in Nix
On NixOS, there are two major pitfalls when handling a password, a private key, or an API token:
- The Git repository: obviously, you don’t want to commit a cleartext
token into your
flake.nixorconfiguration.nix. - The Nix Store (
/nix/store): even if a file isn’t pushed to GitHub, anything evaluated and copied into/nix/storeby Nix becomes world-readable by every user on the machine (chmod 444)!
For a long time, I tinkered with files manually copied onto each machine or
used git-crypt. However, git-crypt encrypts entire files opaquely (say
goodbye to clean git diffs), is tedious to configure per secret, and does
not solve the /nix/store permissions issue.
That is where sops-nix comes in.
Why sops-nix + age?
SOPS (Secrets OPerationS), originally developed by Mozilla, encrypts only the values in a YAML or JSON file while leaving the keys in cleartext. You always know exactly which secret changed in a commit without ever exposing its value.
Combined with age (the modern, minimalist successor to GPG) and sops-nix, the workflow becomes seamless:
- Secrets are stored encrypted inside Git (
secrets.yaml). - At boot time or during a
nixos-rebuild switch,sops-nixdecrypts the secrets on the fly into/run/secrets/(in RAM viatmpfs). - Secrets never touch
/nix/storein cleartext and get strict permissions (0400, dedicated owner).
Zero extra keys thanks to ssh-to-age
Rather than generating and backing up yet another keyring, the most pragmatic
approach is to rely on the ed25519 keys we already have:
- My personal SSH key (
~/.ssh/id_ed25519): allows me to edit and encrypt secrets from my workstation. - The machine’s host SSH key (
/etc/ssh/ssh_host_ed25519_key): allows NixOS to decrypt its secrets automatically at boot without human intervention.
Using ssh-to-age, we can derive the age public keys with a single command:
# Get my user's age public key
nix run nixpkgs#ssh-to-age -- -i ~/.ssh/id_ed25519.pub
# -> age1userxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Get the target machine's age public key
nix run nixpkgs#ssh-to-age -- -i /etc/ssh/ssh_host_ed25519_key.pub
# -> age1hostyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
To edit encrypted files with the sops CLI on my workstation, I convert my
private SSH key into an age private key once:
mkdir -p ~/.config/sops/age
nix run nixpkgs#ssh-to-age -- -private-key -i ~/.ssh/id_ed25519 \
> ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
Configuring .sops.yaml
At the root of my Nix repository, I create a .sops.yaml file that tells SOPS
which keys are allowed to decrypt which files:
keys:
- &user_julien age1userxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- &host_serveur age1hostyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
creation_rules:
- path_regex: secrets/secrets\.yaml$
key_groups:
- age:
- *user_julien
- *host_serveur
Creating and editing the vault (secrets.yaml)
To create or edit the secrets file, simply run sops:
nix run nixpkgs#sops -- secrets/secrets.yaml
SOPS opens $EDITOR with the cleartext content. Let’s store two secrets:
backup_api_token: an S3/Cloudflare token used by a system backup service.github_pat_token: my GitHub Personal Access Token used by user-level CLI tools.
backup_api_token: "cf_secret_token_987654321"
github_pat_token: "ghp_1234567890abcdefghijklmnopqrstuv"
As soon as we save and close the editor, SOPS encrypts the file. Here is what is actually written to disk and committed to Git:
backup_api_token: ENC[AES256_GCM,data:3k8f...,type:str]
github_pat_token: ENC[AES256_GCM,data:9a1b...,type:str]
sops:
age:
- recipient: age1userxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
Case #1: Injecting a system secret on NixOS
First, we add sops-nix to the inputs of our flake.nix:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, sops-nix, ... }: {
nixosConfigurations.mon-serveur = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
sops-nix.nixosModules.sops
./configuration.nix
];
};
};
}
Next, in configuration.nix, we declare where the encrypted file lives, which
host key to use, and how to expose backup_api_token:
{ config, pkgs, ... }:
{
sops = {
defaultSopsFile = ./secrets/secrets.yaml;
# Directly use the machine's SSH host key to decrypt
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
secrets."backup_api_token" = {
owner = "restic";
group = "restic";
mode = "0400";
};
};
# Example usage in a systemd service
systemd.services.mon-backup = {
description = "Daily backup";
serviceConfig = {
User = "restic";
# config.sops.secrets."backup_api_token".path resolves to /run/secrets/backup_api_token
EnvironmentFile = [ ];
ExecStart = pkgs.writeShellScript "run-backup" ''
export API_TOKEN="$(cat ${config.sops.secrets."backup_api_token".path})"
echo "Running backup with token..."
'';
};
};
}
On the next nixos-rebuild switch, /run/secrets/backup_api_token appears in
memory with 0400 permissions owned exclusively by the restic user.
Case #2: Injecting a user secret with Home-Manager
For secrets scoped to our user session (such as a GitHub token, an API key for
CLI tools, or a .config/ file), sops-nix also provides a
Home-Manager module that runs without root
privileges.
In our Home-Manager configuration (home.nix):
{ config, pkgs, ... }:
{
imports = [
# Assuming sops-nix is passed via extraSpecialArgs
sops-nix.homeManagerModules.sops
];
sops = {
defaultSopsFile = ./secrets/secrets.yaml;
# Private key generated earlier via ssh-to-age
age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
secrets."github_pat_token" = {
# Decrypted to $XDG_RUNTIME_DIR/secrets/github_pat_token
path = "${config.xdg.configHome}/gh/token";
};
};
# Example: expose the token in the shell on startup
programs.zsh.initContent = ''
if [ -f "${config.sops.secrets."github_pat_token".path}" ]; then
export GITHUB_TOKEN="$(cat ${config.sops.secrets."github_pat_token".path})"
fi
'';
}
Here, a systemd user service (sops-nix.service) automatically decrypts our
secrets upon login into $XDG_RUNTIME_DIR/secrets/ (or the target path we
defined).
Daily workflow
Once this plumbing is in place, day-to-day secret management comes down to three commands:
Add or update a secret:
sops secrets/secrets.yamlAdd a new machine in
.sops.yamland re-encrypt the vaults:sops updatekeys secrets/secrets.yamlDeploy:
sudo nixos-rebuild switch --flake .
No more forgotten .env files lying around on disk, no more fear of making a
dotfiles repository public, and full reproducibility when rebuilding a machine
from scratch.
In short, with sops-nix and age, the GitOps loop is
finally complete 🔒!