loading article...
loading article...
how i stopped treating my raspberry pis like little snowflakes and brought them into my nix world
over the last year and a half i've been moving more of my life into nix.
my laptop runs nixos, my work mac is on nix-darwin. both feel calm: one repo, one flake, everything pinned and reproducible.
the raspberry pis didn't. they were a mix of scripts and hand-edited files
this is why i pulled them into nixos, how i did it at a high level, and what that changed for me.
before the migration, my world was split. my laptop ran nixos and my work macbook was on nix-darwin, but my three raspberry pis were still running raspi os, serving media, hosting lab experiments, and running a few services i'd mostly forgotten about.
the big machines were boring and predictable. the pis… not so much.
a lot of stuff on the pis lived in that dangerous space between “some ansible” and “some shell history”. recovering an sd card meant re-flashing an image, re-running scripts in the right order, and hoping i remembered which config file had that one tweak that made wifi actually come up.
i wanted the same feeling i had on the laptop: a single git repo and flake that gave me a clear path from a code change to a running system. most importantly, i wanted a way to rebuild everything from scratch after i inevitably destroyed an sd card.
time to see how far nixos on a raspberry pi could get me.
i started with a single raspberry pi 4 and gave myself a small goal: do the minimum amount of work necessary to get a nix-built sd image that would:
that meant a small flake, one configuration, and as little magic as possible.
{
description = "nixos configuration for my raspberry pi 4";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixos-hardware.url = "github:NixOS/nixos-hardware";
opnix = {
url = "github:brizzbuzz/opnix";
inputs.nixpkgs.follows = "nixpkgs";
};
secrets.url = "path:./secrets";
secrets.flake = false;
};
outputs = { self, nixpkgs, nixos-hardware, opnix, secrets, ... }:
let
system = "aarch64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
nixosConfigurations.raspberrypi = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
nixos-hardware.nixosModules.raspberry-pi-4
opnix.nixosModules.default
"${pkgs.path}/nixos/modules/installer/sd-card/sd-image-aarch64.nix"
({ ... }: { _module.args.secrets = secrets; })
./configuration.nix
];
};
};
}
(first image via nix build .#nixosConfigurations.raspberrypi.config.system.build.sdImage.)
the matching configuration is where wifi, ssh, and the usual boot quirks for a pi live:
{ pkgs, lib, secrets, ... }:
{
environment.etc."wpa-secrets.env".text = builtins.readFile "${secrets}/wifi.env";
environment.etc."wpa-secrets.env".mode = "0400";
environment.etc."wpa-secrets.env".user = "root";
environment.etc."wpa-secrets.env".group = "root";
networking.wireless.secretsFile = "/etc/wpa-secrets.env";
networking.wireless.networks."@WIFI_SSID@".psk = "@WPA_PSK@";
boot.initrd.includeDefaultModules = false;
boot.initrd.availableKernelModules = lib.mkForce [
"usbhid"
"usb_storage"
"xhci_pci"
"mmc_block"
"sdhci"
"sdhci_pci"
];
boot.initrd.kernelModules = lib.mkForce [ ];
boot.kernelPackages = pkgs.linuxPackages_rpi;
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
system.stateVersion = "25.05";
networking.hostName = "nixos-pi";
networking.useDHCP = true;
networking.wireless.enable = true;
networking.wireless.interfaces = [ "wlan0" ];
services.openssh.enable = true;
services.openssh.settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
users.users.root.hashedPassword = "!";
users.users.nixos = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINeOJ6hqVsWqK7pOl2DnL4d3sONpRtqfqpdsT44yJoF3"
];
};
environment.systemPackages = with pkgs; [ vim git wget ];
}
flash the image, plug the pi in, wait for it to appear on the network, and i
can immediately ssh in as nixos. no passwords, no “enable ssh via empty file
on the boot partition” dance, no manual wifi config.
at that point i was hooked.
of course it didn't stop at one.
once i had a pi that booted from a flake-defined image, the other machines suddenly looked wrong. so the flake grew up a little.
instead of a single configuration.nix, i split things into a modules/
directory with shared pieces like users and wifi, a hosts/ directory with
one module per pi describing what's special about it, and a small common
wiring layer for nixos-hardware and secrets.
the shape ended up roughly like this:
{
outputs = { self, nixpkgs, nixos-hardware, opnix, secrets, ... }:
let
system = "aarch64-linux";
pkgs = import nixpkgs { inherit system; };
commonModules = [
opnix.nixosModules.default
({ ... }: { _module.args.secrets = secrets; })
./modules/common.nix
];
in {
nixosConfigurations.raspberrypi = nixpkgs.lib.nixosSystem {
inherit system;
modules = commonModules ++ [
nixos-hardware.nixosModules.raspberry-pi-4
./hosts/raspberrypi.nix
];
};
nixosConfigurations.raspberrypi-media = nixpkgs.lib.nixosSystem {
inherit system;
modules = commonModules ++ [
nixos-hardware.nixosModules.raspberry-pi-4
./hosts/raspberrypi-media.nix
];
};
nixosConfigurations.raspberrypi-lab = nixpkgs.lib.nixosSystem {
inherit system;
modules = commonModules ++ [
nixos-hardware.nixosModules.raspberry-pi-4
./hosts/raspberrypi-lab.nix
];
};
};
}
day to day, the deploy story is intentionally boring: i tweak something, commit it, and then push it out with a single command, for example
nixos-rebuild switch --flake .#raspberrypi --target-host nixos@172.16.16.22 --use-remote-sudo
and that's it. no more logging into a pi “just to fix one thing quickly” and forgetting what i changed.
wifi was the first place i wanted to be strict.
the actual credentials live in ./secrets/wifi.env, which is a separate
directory on disk. the flake uses a local path input to point at it, and that
directory is .gitignored so it never leaves the machine.
nix then writes the contents of that file into /etc/wpa-secrets.env with
mode 0400, owned by root, and the wireless config points at that file. the
password itself never appears in the nix code; it is referenced via
@WPA_PSK@ and substituted from the secrets file.
simple, and most importantly, not in git.
my biggest friction point was getting secrets management right. i tried both opnix and agenix for the first time, and it was a struggle to get the wiring right for passing secrets into modules that needed them without exposing them in the nix store. it took a few tries to get the mental model right, but once it clicked, it felt like a huge step up from manually copying files or using env vars.
raspberry pis can be surprisingly picky about boot setups, and nixos gives you a lot of rope.
three things mattered for me: using pkgs.linuxPackages_rpi instead of the
generic kernel, trimming the initrd modules down to what the pi actually needs
to find its storage, and skipping grub entirely for the extlinux-compatible
bootloader, which keeps the firmware happy.
those few lines in configuration.nix turned “this seems to boot most of the
time” into “this boots reliably enough that i can forget about it.”
for access, i wanted the first successful boot to already have the right ssh
setup. this meant ensuring root is effectively locked, that the nixos user
exists from the start with my ssh key baked in, and that password
authentication is disabled before the machine ever hits the network.
the result is a nice feeling: when the pi shows up in my router, i already know exactly how to log in, and i also know there isn't a default “pi/raspberry” situation lurking around.
after three pis, a pattern emerged.
the machines feel much less like individual projects and more like instances of the same story. boots are reproducible because the image comes from the same flake that defines the system. changes are safer because they go through git instead of late-night shell edits. recovery is boring, in the best way; if an sd card dies, i rebuild the image and i'm back where i started. experiments are less scary because there is always a commit i can roll back to.
the big win is mental: the raspberry pis now live in the same configuration universe as my laptop and macbook. it is one way of thinking about machines, not three. the configuration also handles docker/podman containers, networks, and the services that run on them, making it a zero-touch deployment from a single source of truth.
a few things didn't match my expectations.
nixos-hardware did more heavy lifting than i thought it would; i expected to spend more time debugging weird boot issues than i actually did. building sd images via nix felt much more pleasant than juggling vendor tools and “did i tick the right box” questions.
what did take time was rediscovering all the little hacks that had accumulated over the years. some turned out to be unnecessary under nixos, some had to be reimplemented properly as modules or options. it was work, but the kind of work that leaves you with fewer mysteries on the system.
and yes, debugging early boot on a headless pi is still annoying. but at least now the annoying part is documented in git.
now that the pis are happily living in nix-land, the obvious next step is to pull the rest of my infra into the same gravity well.
this means moving my hetzner machines into this flake, with server-specific modules next to the pi ones, and building oci images via nix, so the containers and the hosts share definitions instead of drifting apart. i also plan on tightening the module and secrets story, introducing disko for declarative disk partitioning, and adding a home-manager configuration for my user on the pis to manage dotfiles and user-level packages.
the idea is not “one flake to rule everything forever”, but one place where the shape of my infra is visible and versioned.
moving the raspberry pis to nixos didn't magically make my infra perfect, but it did change how i think about it.
instead of a pile of pets kept alive by habit and muscle memory, i now have a small herd described in code. i can rebuild them. i can break them on purpose. i can see what changed, and when.
and that's enough to know i'm not going back to hand-tuned raspi images any time soon.