sops-nix setup on NixOS (Raspberry Pi 4 from macOS)#
Migrate a plain configuration.nix to Nix Flakes and encrypt secrets using
sops-nix, deploying from macOS to a Raspberry Pi 4.
1. Overview#
1.1 Goal#
Reproducible NixOS config for the Pi with secrets kept encrypted in git and deployed remotely from the laptop.
1.2 Stack#
- sops — encrypts secrets in git
- age — encryption backend (derived from existing ED25519 SSH keys via
ssh-to-age) - sops-nix — NixOS module that decrypts secrets at boot
- deploy-rs — deploys from macOS to the Pi over SSH with automatic rollback
1.3 Hosts#
| Alias | Role | Machine |
|---|---|---|
| Sauce | Laptop (admin) | macOS (aarch64-darwin) |
| Mikan | Target | Raspberry Pi 4 (aarch64-linux) |
2. Initial setup#
2.1 Sauce — derive age identity from SSH key#
mkdir -p $HOME/.config/sops/age/
read -s SSH_TO_AGE_PASSPHRASE; export SSH_TO_AGE_PASSPHRASE
nix run nixpkgs#ssh-to-age -- -private-key -i $HOME/.ssh/id_ed25519 -o $HOME/.config/sops/age/keys.txt
Get the age public key:
nix shell nixpkgs#age --command age-keygen -y $HOME/.config/sops/age/keys.txt
2.2 Mikan — get the host age public key#
cat /etc/ssh/ssh_host_ed25519_key.pub | nix run nixpkgs#ssh-to-age
2.3 Sauce — create .sops.yaml#
keys:
- &admin_you age1... # from 2.1
- &raspberry age1... # from 2.2
creation_rules:
- path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
key_groups:
- age:
- *admin_you
- *raspberry
2.4 Sauce — create and edit secrets#
mkdir -p secrets
nix run nixpkgs#sops secrets/raspberry.yaml
3. Consuming secrets in configuration.nix#
Sops secrets are files on disk at runtime, not Nix strings. They cannot be
interpolated with ${} at build time. Each type of secret needs a different
approach:
| Secret | How to consume it |
|---|---|
| User password | hashedPasswordFile = config.sops.secrets.X.path |
| WiFi credentials | networking.wireless.secretsFile + @VARIABLE@ placeholders |
| SSH public key | Plain text in config — it's public, no need to encrypt |
3.1 User password#
The secret must have neededForUsers = true so it is available before user
activation runs:
sops.secrets.USER_PASSWORD = { neededForUsers = true; };
users.users.alice = {
hashedPasswordFile = config.sops.secrets.USER_PASSWORD.path;
};
3.2 WiFi credentials#
WiFi credentials need a single file containing both values:
# secrets/raspberry.yaml
WIFI_CREDENTIALS: |
WIFI_SSID=YourNetwork
WIFI_PASSWORD=YourPassword
networking.wireless = {
enable = true;
secretsFile = config.sops.secrets.WIFI_CREDENTIALS.path;
networks."@WIFI_SSID@".psk = "@WIFI_PASSWORD@";
};
3.3 SSH public keys#
Public keys are not sensitive — inline them in the config instead of routing through sops (see also 4.2).
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA... you@sauce"
];
4. Troubleshooting#
4.1 sops — missing age key file on decrypt#
Did not find keys in locations 'SOPS_AGE_KEY_FILE', '/Users/.../.ssh/id_rsa'
Cause: sops looks for id_rsa by default and doesn't find keys.txt.
Fix: export the variable pointing to the age keys file and add it to the shell config:
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
4.2 Nix — /run is forbidden in pure evaluation mode#
error: access to absolute path '/run' is forbidden in pure evaluation mode
Cause: openssh.authorizedKeys.keyFiles was set to
config.sops.secrets.SSH_PUBLIC_KEY.path, which resolves to /run/secrets/....
Nix evaluates this path at build time, but /run only exists at runtime.
Fix: SSH public keys are not sensitive. Remove SSH_PUBLIC_KEY from sops
and put the key directly in the config (see 3.3).
4.3 deploy-rs — target architecture mismatch#
error: Cannot build '/nix/store/lc2x2l1wvzwlv48sh2vdp4khynvldlmf-builder.pl.drv'.
Reason: required system or feature not available
Required system: 'aarch64-linux' with features {}
Current system: 'aarch64-darwin' with features {apple-virt, benchmark, big-parallel, nixos-test}
Cause: deploy-rs was trying to build a aarch64-linux target on the
aarch64-darwin laptop (Macbook M1).
Fix: Enable remote builds so the Pi builds its own closure.
outputs.deploy.nodes.raspberry = {
remoteBuild = true;
}
5. Clarifications#
5.1 age.sshKeyPaths points to a file on the Pi — do I need it locally?#
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
No. This path is read at activation time on the target (Mikan), not
during local evaluation. sops-nix converts the host's SSH ed25519 key into an
age key and uses it to decrypt /run/secrets/* when the system boots.
Two different keys are at play:
| Where | Key | Purpose |
|---|---|---|
| Sauce | ~/.config/sops/age/keys.txt → &sauce |
Encrypt/edit secrets locally with sops |
| Mikan | /etc/ssh/ssh_host_ed25519_key → &mikan |
Decrypt secrets at boot via sops-nix |
Both public keys are listed in .sops.yaml so the encrypted file carries two
recipients. Missing the file locally is fine; missing it on the Pi would fail
activation with "no age key found".
5.2 How does deploy.nodes.mikan.profiles.system.path work?#
path = deploy-rs.lib.aarch64-linux.activate.nixos
self.nixosConfigurations.mikan;
deploy-rs.lib.aarch64-linux.activate.nixosis a function from the deploy-rs flake, specific to the target architecture. It wraps a NixOS configuration into an activation script that swaps the system profile, runsswitch-to-configuration, and rolls back on failure.self.nixosConfigurations.mikanis the argument —selfrefers to this flake, so it picks up themikanconfig defined above.- In Nix,
f x(juxtaposition) is function application. The two lines are a single call spread across lines. - The architecture
aarch64-linuxmust matchnixosSystem.system, otherwise deploy-rs will try to activate a closure built for the wrong platform.