For a while I have been running a self-hosted n8n on my office NUC, a Geekom IT12 with plenty of CPU and RAM. However, I tinker with it from time to time. And, inevitably, I (or Claude) break something, and then I have to wait until the next morning to reboot it when I arrive at my office. So I decided to move it to a spare Raspberry Pi 4B I had collecting dust in my closet. That way, instead of having to pause whatever I was working on wait until the next day, I could just walk over and pull the plug when things went sideways.
Since I’m now using NixOS, I figured the move would be straightforward. Just move a few flakes around, switch from x86_64 to aarch64, and we’re laughing. The reality was not quite as smooth. The first build overheated and shut off the Pi (a 20€ fan case from Amazon fixed that). The second build got further, but then the kernel OOM killer terminated it. The build had eaten through all 8GB of RAM, and since NixOS doesn’t configure any swap by default, there was nowhere for the overflow to go.
Swap has been the standard way to deal with memory exhaustion since I first started using Linux 30-odd years ago. But staring at the logs, I found myself wondering: why do I never have to think about this on my Mac or my Windows machine? Both of those handle memory pressure gracefully, even on 8GB machines. Something was happening behind the scenes on those OSes that wasn’t happening on NixOS.
In this article, I’ll walk through what that “something” is, and show the three NixOS options (plus one n8n-specific overlay) that turned my Pi from a crash-prone headless server into one that can survive heavy builds without intervention.
Why your Mac handles this and NixOS doesn’t
macOS has had compressed memory baked into the kernel since Mavericks (2013). When RAM fills up, the OS compresses inactive pages in-place before touching the swap file, buying time without disk I/O. Windows 10 does the same thing. Both also have aggressive OOM handling that kills individual processes before the whole system locks up.
On Linux, equivalent tools exist. Fedora has shipped with zram (compressed swap in RAM) enabled by default since Fedora 33, and systemd-oomd (a userspace OOM killer) since Fedora 34. Ubuntu Desktop enables systemd-oomd by default as of 22.04, though it still doesn’t ship zram.
NixOS ships with none of this. No zram, no disk swap, and while the oomd daemon technically runs by default since NixOS 24.05, it doesn’t monitor any slices by default, so it just sits there doing nothing. On a machine with 32GB this isn’t a huge problem. On a Pi with 8GB running a heavy Nix build, the kernel OOM killer kicks in too late (if at all), the system thrashes itself into a coma, and sometimes your only option is to walk over and pull the plug.
zram: making 8GB feel like 16
zram creates a compressed block device in RAM and uses it as swap. With zstd compression (the NixOS default), you get roughly a 2-3x compression ratio. So 4GB of RAM allocated to zram gives you 8-12GB of effective swap space, all at memory speed instead of disk speed. On a Pi, where the “disk” is a USB SSD or an SD card, this difference is enormous.
Linux handles the prioritization automatically. zram swap gets a higher priority than disk swap, so the kernel uses the fast compressed RAM first and only falls back to disk when zram fills up.
zramSwap = {
enable = true;
memoryPercent = 50;
};
This allocates 50% of physical RAM (4GB on an 8GB Pi) to the zram device.
Disk swap: the boring safety net
zram is fast but limited by how much RAM you’re willing to dedicate to it. When it fills up, you need somewhere else to go. That’s where a traditional swapfile comes in.
NixOS has no swap by default. If you added some at install time, you probably set it to 2GB and forgot about it. For a Pi running memory-heavy builds, the swapfile should be at least as large as RAM to act as a real fallback.
swapDevices = [{
device = "/var/lib/swapfile";
size = 8192; # 8 GiB
}];
This is your second tier: slower than zram, but not limited by RAM size. The kernel will prefer zram (higher priority) and only touch disk swap when zram is full.
systemd-oomd: kill the build, not the system
The kernel OOM killer works, but it’s a blunt instrument. It kicks in late, after the system has already been thrashing for minutes, and it picks which process to kill based on heuristics that don’t know anything about what you actually care about. On my Pi it killed the build, which was fine, but it could just as easily have killed SSH or Tailscale, and on a headless box that means pulling the power cable.
systemd-oomd monitors memory pressure via PSI (pressure stall information) and kills processes before the system starts thrashing. More importantly, it lets you control what gets killed and when, with graduated thresholds per slice.
systemd.oomd = {
enable = true;
enableRootSlice = true; # -.slice at 80%
enableSystemSlice = true; # system.slice — overridden to 60%
enableUserSlices = true; # user slices — overridden to 40%
};
systemd.slices."system".sliceConfig.ManagedOOMMemoryPressureLimit = "60%";
systemd.slices."user".sliceConfig.ManagedOOMMemoryPressureLimit = "40%";
systemd.services.sshd.serviceConfig.ManagedOOMPreference = "avoid";
systemd.services.tailscaled.serviceConfig.ManagedOOMPreference = "avoid";
The enable option has been true by default since NixOS 24.05, but the daemon doesn’t actually monitor anything unless you also enable it on specific slices. The three enable*Slice options tell oomd to watch the root, system, and user slices. The NixOS module defaults all slices to 80% pressure, which is more conservative than Fedora’s default of 60%.
On an 8GB Pi, a flat 80% threshold is too generous. By the time memory pressure hits 80%, the system is already deep into swap and thrashing. Instead, we use graduated thresholds: user processes (like nix-build) get killed at 40%, system services (like n8n) get more headroom at 60%, and the root slice stays at 80% as a broad safety net.
The ManagedOOMPreference = "avoid" lines protect SSH and Tailscale. On a headless box, losing remote access means you’re pulling the power cable. This tells oomd to skip these services unless they’re the only candidate left.
The n8n twist
With zram, swap, and oomd in place, the Pi could survive a build without locking up. But the n8n build kept failing. The error was Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory, and the GC efficiency metric (mu) in the logs was 0.115, meaning Node was spending about 88% of its time garbage collecting and only 12% doing actual work. It would thrash like this for ages before finally giving up with exit code 134.
I asked Claude Code to dig into it, and we found the culprit: n8n’s editor-ui build script sets NODE_OPTIONS="--max-old-space-size=8192", telling Node it can use up to 8GB of heap for the frontend build. But the Nix sandbox doesn’t inherit environment variables set in package.json scripts, so Node runs with its default heap limit (~2GB), which is nowhere near enough.
The fix is an overlay that sets NODE_OPTIONS as a derivation attribute so it’s available inside the Nix sandbox:
n8n = prev.n8n.overrideAttrs (old: {
NODE_OPTIONS = "--max-old-space-size=8192";
});
This isn’t Pi-specific per se. It only bites you on machines where 8GB is all you’ve got.
Putting it all together
Here’s the full config. The overlay goes in your nixpkgs.overlays, and the memory options go in your system configuration:
{
# n8n overlay: let Node use the memory it needs
nixpkgs.overlays = [
(final: prev: {
n8n = prev.n8n.overrideAttrs (old: {
NODE_OPTIONS = "--max-old-space-size=8192";
});
})
];
# Layer 1: compressed swap in RAM
zramSwap = {
enable = true;
memoryPercent = 50;
};
# Layer 2: disk swap as fallback
swapDevices = [{
device = "/var/lib/swapfile";
size = 8192; # 8 GiB
}];
# Layer 3: kill the process, not the system
systemd.oomd = {
enable = true;
enableRootSlice = true;
enableSystemSlice = true;
enableUserSlices = true;
};
systemd.slices."system".sliceConfig.ManagedOOMMemoryPressureLimit = "60%";
systemd.slices."user".sliceConfig.ManagedOOMMemoryPressureLimit = "40%";
systemd.services.sshd.serviceConfig.ManagedOOMPreference = "avoid";
systemd.services.tailscaled.serviceConfig.ManagedOOMPreference = "avoid";
}
The priority ordering happens automatically: the kernel uses zram first (fast, compressed, in RAM), then falls back to the disk swapfile (slow, but not limited by RAM). If memory pressure gets bad enough that even swap can’t keep up, oomd steps in and kills the offending process before the system starts thrashing.
Checking it’s working
After a nixos-rebuild switch, a few commands to verify everything is in place.
zramctl shows the zram device, its size, and the compression ratio:
$ zramctl
NAME ALGORITHM DISKSIZE DATA COMPR TOTAL STREAMS MOUNTPOINT
/dev/zram0 zstd 4G 1.2G 389.7M 448M 4 [SWAP]
swapon --show confirms both swap tiers and their priorities:
$ swapon --show
NAME TYPE SIZE USED PRIO
/dev/zram0 partition 4G 1.2G 5
/var/lib/swapfile file 8G 512M -2
systemctl status systemd-oomd confirms the daemon is running, and journalctl -u systemd-oomd shows whether it has killed anything.
The bottom line
NixOS on a Pi needs active memory management that it doesn’t ship with. Three config blocks (zram, swap, oomd) turn a headless server that thrashes and kills builds unpredictably into one that handles memory pressure gracefully, kills the right processes at the right time, and keeps SSH alive so you never lose access.