I Have a Problem, and That Problem Is Nix
Look, I see a device. The device runs Linux. I want my Nix config on it.
This is not a useful trait. My laptop runs Nix. My server runs Nix. My GPU box runs Nix. None of this would be a problem if I could stop there. I cannot stop there. I bought a Kobo Clara 2E for, you know, reading books, and within a week I was building a flake for fish + tmux + neovim with my full plugin set + mosh + Tailscale + ripgrep/fd/fzf, all managed declaratively from the same repo that builds my workstation.
This post is the story of how, and more importantly, why it turns out to be less ridiculous than it sounds.
โ Just want the result? Skip the technical Nix details and jump to what worked, what became reusable, and where I deliberately stopped.
๐ Just want the recipes? The technical companion is Nix on a Kobo: The Cookbook: cross-compile module, qemu-user overlay flavors, FAT-safe loopback, and the full deploy sequence, no story.
I Was Originally Going to Do This on a Kindle
The plan started with a jailbroken Kindle. There is excellent prior art. Collin Dewey's Nix-on-Kindle writeup is the canonical reference, and if you want to jailbreak a Kindle to follow along, this video is a clean starting point.
Two things made me pivot off Kindle:
- Kindle Bluetooth is locked down. Amazon's firmware restricts the BT HID stack to audio profiles, for Audible. Pairing a keyboard means fighting Amazon's
lipclayer. That is a research project, not a copy-paste. - Kobo is dramatically friendlier. No jailbreak required. You sideload extensions via the standard
.kobo/folder. SSH-over-dropbear is a checkbox in the developer menu. KOReader (the third-party reader app) is fantastic, and it had already turned my Kobo halfway into "tiny Linux box" without me asking.
I had a Clara 2E on the shelf with KFMon and KOReader installed already. Path of least resistance. Pivot accepted.
The Initial Idea
The initial idea was to be similar to my nix-on-droid setup: home-manager on another Linux. Single-user Nix profile, managed declaratively, with an activation script that atomically swaps a ~/.nix-profile symlink, the same shape I already use, pointed at a different device. The Kobo isn't a "tiny NixOS host"; no bootloader, no hardware modules, no init system, none of that is happening on a vendor-frozen 2015 kernel running busybox. It's a Linux device that hosts a Nix profile alongside its native userspace. KOReader still works, Nickel (Kobo's stock reader) still works. Nix just adds a /nix/store next to everything else.
The other half of the initial idea came from veronicaexplains' It's time to talk about my writerdeck, which dropped right as I was thinking about this. Her writerdeck is an old laptop reduced to console-only Debian + vim. Small kit, no notifications, deliberate scope as the point.
The "-deck" suffix has cyberpunk-fiction roots worth naming. Cyberdeck is the term from William Gibson's Neuromancer (1984) for the portable computer "console cowboys" use to jack into cyberspace: a custom, personal rig you carry everywhere because it's your interface to the network. The modern hobbyist revival has stretched the meaning. Today's cyberdeck is usually a DIY portable computer with a deliberate aesthetic (Pi-based, mechanical-keyboarded, sometimes Pelican-cased, often e-ink) but the energy is the same: small kit, intentional form factor, your tools everywhere. veronicaexplains' writerdeck is the writing-focused instance of that energy. Mine is the more general one: a portable terminal profile with SSH access to bigger machines, on a 6-inch e-ink screen with long battery life.
The kit I actually want on top of that shape is deliberately small: fish, tmux, neovim with my plugin pack, mosh, Tailscale, ripgrep/fd/fzf. That's the whole intentional dependency set. I went to some length to keep it that way. The heavier opt-in pieces like chromium (for strudel.nvim[1]'s web view) and the desktop-only plugin extras live in separate files the cyberdeck profile doesn't import, rather than getting silently stripped from a shared file with lib.mkForce. The refactor that made that possible is in "Positive Imports Beat Negative Overrides" below.
The transitive closure you have to build to ship even that lean intentional kit on armv7l, though, is anything but lean. cache.nixos.org doesn't serve this arch, so every dependency compiles from source under qemu: several hundred derivations, hours of build-host wallclock. And roughly twenty of those transitive packages have test suites, build helpers, or test code that doesn't survive qemu-user emulation cleanly; each needed a one-line entry in a ~200-line overlay before the closure would assemble. The full punch list, with the flavor catalog of how each one broke, is in "What Actually Broke When I Ran It" below. Small intent, fat closure, real work in both halves: the shape didn't make the work small, it made the work possible at all, which is a different and better thing.
The Build Loop, Which Is the Whole Punchline
Three commands. Same shape as deploying to any other host I own.
nix build .#cyberdeckConfigurations.kobo.activationPackage
nix copy --to ssh://root@kobo.lan ./result
ssh root@kobo.lan ./result/activate
Cross-compile on the GPU box, push the closure over SSH, activate with one symlink swap. The activation is atomic. If it fails halfway, the old profile is still pointed at by the symlink, and nothing is broken.
Compare this to what people normally do on jailbroken e-readers: pile of shell scripts, manually unpacked tarballs, hope you don't brick it. This produces one closure with a known hash. It does not erase target-specific problems, but it gives the build and activation a repeatable unit.
The "Cross-Compile" Starting Point Is One Line
The Kobo is armv7l, not aarch64. cache.nixos.org does not serve binaries for armv7l. Every package has to be cross-compiled on the build host.
NixOS makes the first step pleasantly small:
boot.binfmt.emulatedSystems = [ "armv7l-linux" ];
The kernel's binfmt_misc subsystem dispatches ARM ELF bytes to qemu-user transparently. After nh os switch, my x86_64 host builds armv7l derivations as if they were native. Slower, but no manual toolchain juggling. No reboot needed either, because the binfmt registration happens on activation. Common misconception.
At this point I was at peak Shaun of the Dead Winchester-scene confidence. The plan was clean: flip the binfmt switch on the build host, kick off nix build, nix copy the closure to the Kobo, run ./activate, sip a cold pint, done.
Reader: it did not blow over.
I wrapped the binfmt line in a small cross.targets = [ "armv7l-linux" ]; module so future arches land in one place. Turn raw NixOS option values into domain language the moment you reuse them across hosts. The full module is in the cookbook; it's the kind of thing you write once and forget. What I did not forget was the surprise an hour into the first build: the binfmt line alone wasn't enough. More on that below.
It's Not Really Cross-Compile, It's Rebuild-the-World
Two facts compose into this project's actual cost shape, and they're worth naming because the rest of the post takes them for granted.
There's no substituter for armv7l. cache.nixos.org publishes binaries for x86_64-linux, aarch64-linux, and a handful of others. armv7l-linux isn't in that set, and no third-party cache I trust covers it either. So every package the Kobo closure depends on (glibc, gcc, perl, python, openssl, busybox, every header and shared library, the whole stdenv bootstrap chain) compiles from source. Not just my intentional kit. Everything underneath it too.
The compilation runs through qemu-user, not a textbook cross-compiler. What boot.binfmt.emulatedSystems = [ "armv7l-linux" ]; actually sets up isn't an x86 gcc that emits ARM code. It's a binfmt handler that tells the kernel "when you see an ARM ELF binary, route it through qemu-user instead of refusing to execute it." The ARM compiler then runs inside qemu-user, which translates instructions and emulates guest syscalls against the host kernel. The result targets armv7l, but emulation is not identical to real hardware; the test failures below are where that distinction became visible.
Standing that virtualized toolchain up the first time took about a week of build-host wallclock, the kind of week where you check in every few hours, see another stage of the bootstrap finish, and queue up the next batch. The unexpected upside: I got to watch the stdenv bootstrap actually happen. On x86 with a working cache, you nix-build something and a closure just appears; the bootstrapFiles โ stage0 glibc โ stage1 gcc โ stage2 gcc โ perl โ ... chain that produces a working Linux userland is hidden from you, baked once on Hydra and served out of the cache forever after. On armv7l with no cache, every one of those layers compiles in front of you, in order, under qemu, log scrolling past with the names of derivations I'd never seen before. Annoying as a productivity metric. Genuinely cool as an education in how a Linux toolchain actually composes, like getting the whole undergrad systems course delivered slowly over a week, by your own build host.
Compose those two facts and you get the project's real cost shape: the entire toolchain is virtualized, and any change to a low-level package's hash rebuilds the world. On x86_64 with a working cache, changing meson's hash means a fast partial rebuild from a few misses. On armv7l with no cache, changing meson's hash means gfortran 14.3.0 building itself from source under qemu (~12 hours), every numpy/scipy variant compiling from source under qemu, every meson-using build script in the closure re-running under qemu. Wallclock cost isn't proportional to the change; it's proportional to the fan-out of the change times the no-cache penalty. That's what made adding pkgs.nix to the closure, an ostensibly ~80 MB convenience, cost a multi-day rebuild instead of a coffee break. The Gentoo crowd had a term for this twenty years ago: rebuild world. On armv7l Nix, it's the default condition, and every overlay decision has to be priced against it.
The Genius Hack I Did Not Have to Invent
So how do you actually type into a shell on an e-reader?
Writing a usable terminal on e-ink is genuinely hard. Every refresh is slow, ghosts, and partial-vs-full refresh is governed by an explicit kernel ioctl (MXCFB_SEND_UPDATE on Kobo's i.MX SoCs). A naive fbterm flickers, lags, and ghosts the screen. KOReader spent something like five years tuning all of this via NiLuJe's FBInk library, and KOReader's Terminal emulator plugin reuses that tuning.
So the answer is: do not write a framebuffer terminal. Piggyback on KOReader's.
My cyberdeck profile ships a tiny cyberdeck-shell launcher that exports the right environment and execs into tmux new-session -A -s cyberdeck fish. The intended integration is to point KOReader โ Tools โ More Tools โ Terminal Emulator โ Shell command at /root/.nix-profile/bin/cyberdeck-shell. The closure and SSH worked on the device; this local terminal path still exposed a few small integration issues, so the launcher recipe in the cookbook should be read as the design I reached, not a claim that every terminal detail was polished.
The tmux new-session -A -s cyberdeck incantation is there to preserve the session when the terminal closes or the device sleeps. That continuity is what could make the Kobo feel like a coherent computer instead of a chat-with-the-shell window. The approach reuses KOReader's e-ink refresh work instead of attempting another framebuffer terminal.
The Side-Lesson: Positive Imports Beat Negative Overrides
This isn't really about the Kobo. But it came up in the project and I think it's worth thirty seconds.
I was importing my main home/neovim.nix module into the cyberdeck profile to get all my plugins. Problem: neovim.nix had chromium and nodejs in extraPackages, for strudel.nvim's web view. Those don't cross-compile to armv7l in any reasonable amount of time.
My first instinct was lib.mkForce on extraPackages from the cyberdeck side, stripping the heavy deps. It worked. It was also ugly: anyone reading neovim.nix later sees chromium listed and assumes it's used, while the cyberdeck silently overrides it from under them.
Better: move the strudel-only dependencies into the strudel-specific file that already existed.
# home/neovim.nix: the lean core
extraPackages = with pkgs; [ tree-sitter ];
# home/neovim/strudel.nix: the desktop bits next to the desktop plugin
extraPackages = with pkgs; [
tree-sitter-strudel nodejs nodePackages.npm chromium
];
Workstations that want strudel.nvim import both files. Nix-on-droid and cyberdeck import just neovim.nix. Same total LOC. Vastly better signal. The smell of lib.mkForce over an imported module is almost always "this file should have been split, and you're papering over the missing split with overrides."
What Actually Broke When I Ran It
๐ Build notes from 2026-05-31. I ran the build. Several things broke. Five rounds of notes later, the closure was on the device. The narrative version is below; the per-package punch list, the overlay code, the bootstrap script, and the layered debugging model all live in Nix on a Kobo: The Cookbook. This section is the war story.
"One line" was actually two
The binfmt module registers armv7l-linux as a build platform, but it does not advertise the gccarch-armv7-a feature flag that the low-level bootstrap derivations check for. So Nix sees a builder that claims it can build armv7l, sees a derivation that says "I need armv7l + gccarch-armv7-a," and gives up with Failed to find a machine for remote build. Cold pint, deferred. The fix is to map each emulated target to its gccarch features and advertise both. The cookbook has the full module, the mapping is mechanical, you write it once.
qemu-user emulation has gaps. About 21 of them, so far.
Once Nix could actually schedule the build, package after package's test suite failed under qemu-user. The override was often one line, doCheck = false, but deciding whether that line was justified was the real work. The variety is wild. rhash captures subprocess stdout and sees empty strings. boehmgc's gctest SIGABRTs around synthesized faults. libuv's IPv6 multicast test gets ENOPROTOOPT. meson test 254 times out because qemu's I/O loop is slow. fish's pexpect tests hang around pty behavior. tailscale's XDP test wants CAP_BPF, which the Nix sandbox blocks regardless of qemu. About twelve distinct failure flavors, each needing its own risk judgment.
After the third one I stopped treating every failure as unique and started classifying them. A package that compiles and links but fails only at a qemu-sensitive boundary is a candidate for a targeted test skip, not proof that the package is sound. I checked the failing test, the emulated boundary, and the consequence of skipping it before adding an override. The cookbook has the full flavor catalog with per-flavor risk ratings, and the ~21-override overlay with the Why: comment on each.
The genuinely surprising one: FAT can't store symlinks
Nix's store is symlinks all the way down. Every package in /nix/store is full of them. The atomic generation swap that makes "instant rollback" work is literally a symlink rename. Take symlinks away and Nix doesn't degrade. It stops working entirely.
The Kobo's 14 GB partition is FAT32. It has to be, because Kobo exposes it as USB Mass Storage when plugged in, and Windows reads FAT. FAT has no concept of symlinks. My first attempt (symlink /nix to a directory on /mnt/onboard) died the moment tar tried to extract the first symlink inside a store path: tar: can't create symlink '...libunistring.so.5': Operation not permitted.
The fix is the Wubi-on-Windows trick from the 2000s[2]. Put a regular file on the FAT partition, format the file's contents as ext4, loop-mount it. From FAT's perspective it's an opaque blob. From everything above the loop driver, it's a real ext4 with full POSIX semantics.
graph LR
subgraph EMMC ["Kobo eMMC (storage view)"]
subgraph FAT ["/mnt/onboard (FAT32, can't store symlinks)"]
F["nix.img
3500 MB opaque file"]
end
end
F -->|"mount -o loop"| N
subgraph LOOP ["Loop-mount view"]
subgraph EXT4 ["ext4 filesystem (full POSIX)"]
N["/nix"]
N --> S["/nix/store/HASH/lib/libfoo.so
โ libfoo.so.1.2.3 โ"]
end
end
Filesystems inside files inside filesystems. As Cobb would say: "We need to go deeper." The mount -o loop is the kick.
3500 MB instead of 6 GB, because FAT32 has a 4 GiB single-file ceiling. (I learned that mid-scp, of course.) The bootstrap script on the device doesn't parse mount output or whitelist filesystem names. It directly tests for symlink support by trying to make one and watching what happens. FAT, exFAT, vfat, msdos, or some future thing: one branch handles them all. Full bootstrap.sh in the cookbook.
The most expensive mistake: changing meson's hash
I added pkgs.nix to the closure as a convenience. It unlocks nix copy --to ssh:// for subsequent updates, and gives the activation script nix-collect-garbage on PATH. Cost: ~80 MB on disk. Or so I thought. Some transitive dep of nix evaluates pkgs.meson.override at eval time, my overlay had meson = prev.meson.overridePythonAttrs (...), and overridePythonAttrs strips .override from its result. The fix was a one-character swap to overrideAttrs. The cost was a multi-day rebuild cascade, because meson is in the build-input tree of a huge fraction of nixpkgs, and on armv7l there's no substituter cache.
The heuristic I wish I'd had: before changing the hash of a low-level dep (meson, gettext, perl, openssl) on a no-cache platform, run nix-store --query --referrers-closure against its path. If the fan-out is in the thousands, that's a multi-day commit. The fix shape doesn't change. The timing decision does.
Six layers, not two
By the time the closure was on the device, I had six checkpoints in my head: substituter, scheduling, emulation fidelity, emulated-native vs cross, ABI / sub-arch, transport, activation. Labeling each error to its layer is what kept debugging from feeling like whack-a-mole, because the symptoms at one layer look nothing like the symptoms at another. A Layer 1 error ("Failed to find a machine") sounds like a Nix scheduling problem. A Layer 2 error (a specific package's test output) sounds like a package bug. A Layer 6 error (EPERM on symlink) sounds like a permissions issue. They're all consequences of the same act, building for a different CPU than you're running on, but they surface at totally different boundaries. Label the boundary, and the fix file is obvious. The full six-layer table is in the cookbook; it's the most reusable thing I got out of this project.
The Win: A Repeatable Build and Deployment Boundary โ
The verified win was narrower and still substantial: the armv7l closure built on an x86_64 host, transferred to a symlink-capable filesystem on the Kobo, activated atomically, and supported a working SSH session. The local terminal integration needed another small round of fixes, so I am not counting that polish, or Mosh over Tailscale, as part of the demonstrated result.
The more durable win is the work that can be reused beyond this device: the cross-architecture build module, the catalog of qemu-user failure modes, the FAT-safe Nix-store bootstrap, the activation package, and the six-layer debugging model. Another small Linux device can start from that base, then add its own hardware profile and validation instead of starting with an entirely bespoke pile of shell scripts.
I deliberately stopped at a repeatable build, transfer, activation, and SSH boundary. A polished KOReader terminal, Mosh or Tailscale routing, automatic remounting after reboot, and pull-based updates would extend the appliance experience, but they are outside the result claimed here, not promises for a later update.
Why I Did This At All
The flippant answer is "because I couldn't help it." Nix on an e-reader sounds like a productivity own-goal. The Kobo is not going to replace my laptop. I am not going to write production code on a 6-inch e-ink screen at 1 Hz refresh.
But the actual answer is: the Kobo doesn't need to be the compute; it can be a portal. I verified SSH on the device, which is enough to move the expensive work to a workstation, GPU box, or home server. Tailscale userspace networking was part of the design, but I did not validate that path thoroughly enough to claim transparent fleet access, and Mosh's separate UDP transport needs more than an ALL_PROXY setting.
The second answer underneath that is: more of a cyberdeck can live in a Nix profile than I expected. The shared fish, tmux, neovim, and SSH configuration can stay declarative while a thin device profile owns Kobo-specific storage, kernel, and launcher constraints. That is portability by separation, not a claim that arbitrary Linux devices are interchangeable.
That separation is where the Nix part pays off. Improvements to shared neovim, tmux, and fish modules can be included in a later closure without duplicating them in an e-reader-only config. They are not automatically guaranteed to work on armv7l (the whole post is evidence of the per-architecture tax) but there remains one source of truth and one explicit place for device exceptions.
One last angle I didn't expect to matter as much as it did: the build was a stress test of how far an agent-paired ops workflow can carry an exotic-architecture Nix project. The qemu-user gaps, the meson-hash cascade, and the FAT-can't-store-symlinks revelation became a tight back-and-forth of "here's the error, here's the suspected layer, here's the smallest justified fix" until the closure built and activated. Even without claiming the unfinished terminal and overlay-network polish, that produced three durable things: a deployable closure, a reusable debugging model, and a cleaner split between shared configuration and device-specific constraints.
That framing should reduce the work for another armv7l device, because the shared profile already exists. A different device or architecture would still need its own hardware profile and validation; the model is reusable, but the amount of per-architecture work remains an open variable.
The bigger lesson I keep relearning: the right mental model doesn't make the project small. It just puts the cost somewhere you can pay it. "Deploy NixOS to an e-reader" would have been a year of fighting bootloaders I was never going to win. "Single-user Nix profile on a Linux device" was the right shape, and still cost multiple long weekends, mostly inside qemu-user emulation gaps, one FAT-vs-symlinks revelation, and the realization that to actually maintain a Nix profile on a device you have to put Nix itself on the device too. The shape was always nix-on-droid. The bill came due in the build.
Try It Today โก
If you have a Kobo (or a jailbroken Kindle) on a shelf and use Nix, treat this as an advanced multi-day build project, not an afternoon copy-paste. The implementation record lives in Nix on a Kobo: The Cookbook: prerequisites, hardware notes, the cross-compile module, the overlay, the bootstrap script, the deploy sequence, and references. The shortest starting loop:
- Enable SSH on the Kobo. Drop an empty file named
SSHinto/mnt/onboard/.koboand reboot. Dropbear comes up on port 22. - Use nix-on-droid as the structural reference. The activation-package machinery is exactly what you want; strip the Termux-specific bits.
- Follow the cookbook for the rest. Cross-compile module, qemu-user overlay flavors, FAT-safe ext4 loopback, KOReader Terminal plugin wiring. It's all there in recipe form.
The flake isn't public, but I'll happily send the cyberdeck subset to anyone who's actually trying this. Email me@jquaintance.com.
Working, repeatable Nix on an e-reader > a perfect cyberdeck roadmap. Ship the useful boundary and call it done.
strudel.nvimis a Neovim plugin for live-coding music with Strudel, the JavaScript port of TidalCycles' pattern language. It needs Chromium (the audio engine renders in a web view) and Node.js. I cover the wider live-coding-on-NixOS setup, including thestrudel.nvimconfig and what each dep is for, in Linux Music Production for People Who Hate Linux Audio Setup. โฉ- Wubi (Windows-based Ubuntu Installer) let you install Ubuntu on a Windows machine without repartitioning by stashing the entire Ubuntu filesystem inside a single loop-mounted disk-image file on the NTFS partition. Same trick, different filesystems. โฉ
Header photo by Amanz on Unsplash.
Content on this blog was created using human and AI-assisted workflows described in my standards and workflow posts. Original ideas and editorial decisions by Justin Quaintance.