How to git track sensitive secrets without using env vars?
When you track your NixOS configuration in git, or any config for that matter, your entire system is version-controlled and reproducible. Any API keys or passwords in those files, however, would be pushed to your repo in plaintext for anyone to see. You can't just .gitignore them either, because NixOS needs those values at build/activation time. I use NixOS because it allowed me to have a code defined laptop environment that I can easily bring with me. I was tired of my Linux distros breaking, and I wasn't happy with Fedora Atomics way of doing things with rpm-ostree and RedHat image bootc feature.
sops-nix solves this: you encrypt secrets with SOPS and commit the encrypted files to git. At system activation, sops-nix decrypts them using keys that only exist on your machine (never in the repo). Your dotfiles stay fully reproducible and pushable, with zero secret leakage.
The first secret I needed to manage is EXA_API_KEY for Exa.ai, which powers web research and crawling capabilities through an MCP server.
This guide covers a simple single-machine setup using age keys derived from SSH keys, based on Michael Stapelberg's approach. But with some fixes to outdated methods.
Preparation: Add Trusted User (optional, recommended)
To avoid build warnings, add yourself as trusted user in configuration.nix (optional):
nix.settings.trusted-users = [ "@wheel" "noor" ];
Then rebuild:
sudo nixos-rebuild switch
This lets nix run use additional substituters (binary caches) without warnings. Speeds up the next steps and future ones too.
Step 1-3: Create age key and get both recipients
Prepare:
mkdir -p ~/.config/sops/age/
Enter a nix shell with all needed tools:
nix shell nixpkgs#ssh-to-age nixpkgs#age
Inside the shell:
Create an age identity file derived from your SSH private key.
If your SSH key is passphrase-protected, ssh-to-age will prompt you for it.
ssh-to-age -private-key -i ~/.ssh/id_ed25519 -o ~/.config/sops/age/keys.txt
Lock down permissions (recommended):
chmod 600 ~/.config/sops/age/keys.txt
Get your personal age recipient (copy the output, e.g., age1xxxxx...)
age-keygen -y ~/.config/sops/age/keys.txt
Check if system SSH host keys exist
ls /etc/ssh/ssh_host_ed25519_key
- If not found, generate them:
sudo ssh-keygen -A
Get your system's SSH host key recipient (copy the output, e.g., age1yyyyy...)
cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age
Exit the shell
exit
You'll need both recipients for .sops.yaml in the next step.
Step 4: Create .sops.yaml in nixos-dotfiles root
Create the file at ~/nixos-dotfiles/.sops.yaml:
keys:
- &admin age1xxxxx... # Your personal age recipient
- &system age1yyyyy... # Your system's SSH host key
creation_rules:
- path_regex: secrets/[^/]+\.yaml$
key_groups:
- age:
- *admin
- *system
Why both keys?
-
Your personal key (
&admin): Lets you decrypt and edit secrets as a regular user, no sudo or repeated SSH passphrase needed -
System SSH host key (
&system): Lets the system decrypt secrets at boot automatically and make them available in/run/secrets/without your intervention
With both:
- System boots and decrypts secrets automatically (no passphrase needed)
- You can edit secrets locally without root
- Single secrets file works for both use cases
- Most flexible and practical approach
Caveat: If you ever rotate/regenerate your SSH host key (/etc/ssh/ssh_host_ed25519_key), you must re-encrypt your secrets to the new host recipient, otherwise boot-time decryption will fail.
Step 5: Create the secrets file at ~/nixos-dotfiles/secrets/secrets.yaml
Create the secrets directory and open the encrypted editor
cd ~/nixos-dotfiles
mkdir -p secrets
nix run nixpkgs#sops -- secrets/secrets.yaml
When the editor opens, add this content:
EXA_API_KEY: "your_actual_api_key_here"
Save and exit. File is now encrypted.
Verify it's encrypted:
cat secrets/secrets.yaml
You'll see encrypted gibberish (values start with ENC[AES256_GCM...). This proves the secret is encrypted.
Only you can decrypt it because you have the private key at ~/.config/sops/age/keys.txt. Without it, the file is useless to anyone else.
To edit or view it again:
nix run nixpkgs#sops -- secrets/secrets.yaml
Only works because sops finds your private key and decrypts it automatically.
Step 6: Add sops-nix to flake.nix
Add sops-nix to inputs and modules:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, home-manager, sops-nix }:
let
system = "x86_64-linux";
in {
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
sops-nix.nixosModules.sops
./configuration.nix
home-manager.nixosModules.home-manager
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.noor = {
imports = [
sops-nix.homeManagerModules.sops
./home.nix
];
};
}
];
};
};
}
Important: Include sops-nix.homeManagerModules.sops in home-manager imports so the sops options are available in home.nix.
Step 7: Configure sops-nix in home.nix
In ~/nixos-dotfiles/home.nix, add:
{ config, ... }:
{
# sops-nix secret management
# Decrypt secrets and expose as environment variables
sops.age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
sops.defaultSopsFile = ./secrets/secrets.yaml;
sops.secrets.EXA_API_KEY = { };
home.sessionVariables = {
EXA_API_KEY = "${config.sops.secrets.EXA_API_KEY.path}";
};
}
Note: EXA_API_KEY will contain the path to the decrypted secret file (e.g., /run/user/1000/secrets/EXA_API_KEY), not the key value itself. Use cat $EXA_API_KEY to read the actual key. This is how sops-nix works: secrets are files, and programs that support reading secrets from file paths will work seamlessly.
If a program expects the raw key value in EXA_API_KEY (not a file path), you’ll need to either (a) configure that program to read from a file (some support *_FILE patterns), or (b) wrap/launch it in a way that reads the file contents into an environment variable.
Important: After rebuilding, close all existing terminal windows. The environment variables won't update in shells that were open before the rebuild. Open a fresh terminal to get the new variables.
Step 7b: Enable sops in configuration.nix
Also add this in ~/nixos-dotfiles/configuration.nix:
{ ... }:
{
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
}
Why? This is a system-level setting that tells the NixOS sops module which SSH key to use for decryption. It must go in configuration.nix (NixOS-level), not home.nix. The home-manager sops module uses its own sops.age.keyFile (set in Step 7). This system-level setting is needed so the system's SSH host key can also decrypt the secrets, enabling decryption at boot without user interaction and supporting future system-level secrets if you add any.
Step 8: Rebuild NixOS
sudo nixos-rebuild switch --flake ~/nixos-dotfiles#nixos
Step 9: Reboot (or restart home-manager service)
Option A: Reboot (simplest)
sudo reboot
Option B: Restart the service. Don't remember this working for me though.
systemctl --user daemon-reload
systemctl --user restart sops-nix.service
(Due to a known home-manager issue, the service may not auto-restart during nixos-rebuild switch if it wasn't previously active.)
Step 10: Test it works
Open a fresh terminal and verify:
echo $EXA_API_KEY
# Should print the path, e.g., /run/user/1000/secrets/EXA_API_KEY
cat $EXA_API_KEY
# Should print your actual API key value
Editing secrets later
cd ~/nixos-dotfiles
nix run nixpkgs#sops -- secrets/secrets.yaml
Edit, save, commit, rebuild.
Git workflow
Commit everything:
git add flake.nix configuration.nix home.nix .sops.yaml secrets/secrets.yaml
git commit -m "Add EXA_API_KEY secret with sops-nix"
git push
Your age private key (~/.config/sops/age/keys.txt) is never committed. It lives outside the repo. The encrypted secrets file (secrets/secrets.yaml) is safe to commit since it's useless without the private key.
Summary
We set up sops-nix so API keys can live in our git-tracked NixOS dotfiles without leaking:
-
Derived an age key from our existing SSH key (
ssh-to-age), stored at~/.config/sops/age/keys.txt(never committed) -
Configured
.sops.yamlwith two recipients: personal age key (for editing) and system SSH host key (for boot-time decryption) -
Created an encrypted secrets file (
secrets/secrets.yaml) usingsops, committed to git and only readable with the private key -
Wired sops-nix into the flake: NixOS module in
configuration.nix, Home Manager module inhome.nix -
Exposed the secret as an env var (
$EXA_API_KEY) pointing to the decrypted file path at/run/user/1000/secrets/EXA_API_KEY
The encrypted file is safe to push. The private key never leaves the machine. (In a feature article, I'll show to to leverage sops in a kubernetes cluster inside yaml files.)
Top comments (0)