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 had a flake building 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 to see the thing? Skip the technical Nix details and jump straight to the finished cyberdeck. The photo is the payoff; the rest of the post is the war story. (No judgment โ I respect the impatience.)
๐ 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: not optimized for writing specifically, but for everything I'd do on my workstation โ terminal kit, agents, SSH'd shells across my Tailscale fleet โ on a 6-inch e-ink screen with a week of battery life, in my pocket.
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 is one closure with a known hash. If it works on my laptop, it works on the Kobo.
The "Cross-Compile" Trick 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 this absurdly easy:
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 โ 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." So when Nix builds an armv7l package, the ARM gcc binary itself actually runs, inside qemu-user, which translates ARM instructions to x86 on the fly and emulates ARM syscalls against the host kernel. The compiler doesn't know it's not on real hardware. The output is bit-identical to what a real Cortex-A9 gcc would produce. Just slower, because every instruction goes through translation.
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. You point KOReader โ Tools โ More Tools โ Terminal Emulator โ Shell command at /root/.nix-profile/bin/cyberdeck-shell, tap "Terminal" inside KOReader, and you're in fish + tmux with the full kit. (Launcher recipe in the cookbook.)
The tmux new-session -A -s cyberdeck incantation is doing real work: you can close the terminal, reopen it, sleep the device, wake it two hours later, and your cursor is exactly where you left it. That continuity is what makes the Kobo feel like a coherent computer instead of a chat-with-the-shell window. The whole hack rests on someone else's five years of e-ink refresh tuning. I just had to wire fish and tmux into the right entry point.
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
๐ Updated 2026-05-31. I ran the build. Several things broke. Five rounds of notes later, the closure is 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. Same one-line fix every time: doCheck = false in an overlay. The variety is wild. rhash captures subprocess stdout and sees empty strings. boehmgc's gctest SIGABRTs because qemu's siginfo_t doesn't match a real kernel's. libuv's IPv6 multicast test gets ENOPROTOOPT for a syscall that works fine on real ARM. meson test 254 times out at 65 seconds because qemu's I/O loop is slow. fish's pexpect tests hang because qemu's pty emulation doesn't deliver prompts the way termios expects. tailscale's XDP test wants CAP_BPF, which the Nix sandbox blocks regardless of qemu. About twelve distinct flavors. All one-line skips.
After the third one I stopped trying to understand each failure individually and started just classifying. The rule: if the package compiles and links, and only a test fails, it's an emulation gap, not a real bug. Skip and move on. The cookbook has the full flavor catalog with per-flavor "how worried should I be about this skip" 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 โ 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 rather than 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.
Still pending
- No KFMon launcher entry yet. You still have to open KOReader and tap "Terminal" to enter the cyberdeck.
- Loop mounts don't survive reboot โ
bootstrap.shcurrently has to re-run on boot. KFMon-on-launch is the clean fix. - Pull-from-harmonia (the local nixpkgs cache on my server) instead of push-from-build-host โ better fit for a device that's often asleep or behind a captive portal.
Show Me the Thing ๐ท
Photo coming soon โ once the cyberdeck is in a setup worth photographing, the picture lands here. Until then: the closure builds, the activation script runs, the tmux session persists across sleep, and the fish prompt shows up exactly the way it does on my workstation. The proof is in the post above; the glamour shot is pending.
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 isn't the compute, it's the portal. SSH plus Tailscale gets me into my whole fleet โ workstation, GPU box, home server โ from a pocket-sized e-ink device with a week of battery life. When I want to run Claude Code, I SSH to the GPU box and run Claude Code there. When I want to use Ollama, I SSH to the AI server and use Ollama there. The Cortex-A9 doesn't need to be fast; it just needs to render the terminal and not die. e-ink is great at "render the terminal and not die," and Tailscale's userspace mode reaches every machine I own without me caring what network I'm on.
The second answer underneath that is: a cyberdeck isn't a device. It's a Nix profile. The device is interchangeable. What makes any random Linux box into "my cyberdeck" is the declarative kit I've cured into a portable profile โ same fish config, same tmux bindings, same neovim plugins, same SSH key access via Tailscale. The Kobo is just the latest thing that happens to satisfy the profile.
And the orthogonal-upgrade angle is what makes the Nix part actually pay off. Every time I improve the shared neovim/tmux/fish modules โ a better statusline, a fresh LSP config, a fish prompt tweak, a new fzf binding โ those changes propagate to the Kobo on the next nix copy for free. I don't maintain a separate "e-reader config" to keep current. There's one source of truth for "how I use a terminal," and every device I own pulls from it. The Kobo earns its keep precisely because it doesn't get its own bespoke setup โ it inherits the same upgrades the workstation gets, the day I make them.
One last angle I didn't expect to matter as much as it did: the whole build was a stress test of how far an agent-paired ops workflow can carry an exotic-arch Nix project. Eighteen qemu-user emulation gaps, a meson-hash cascade through half of nixpkgs, the FAT-can't-store-symlinks revelation โ none of those have clean StackOverflow answers. Each one became a tight back-and-forth of "here's the error, here's the suspected layer, here's the one-line fix" until the closure built. And the resulting closure runs offline: fish, tmux, neovim, ripgrep, fzf, all there even when there's no network and no agent to ask. That's the part I like most. The Kobo is useful when I'm connected (portal into the fleet), useful when I'm not (full local terminal kit), and the path to making both true was itself a real exercise of my Nix understanding plus a probe of how far agent-paired ops can go. Three useful things from one build.
That framing means the next armv7l device is one new profile file in the same flake. A different arch โ Pinebook Pro, riscv64 SBC, whatever โ is one new profile file plus a fresh round of qemu-user gap-hunting. The model holds; the per-arch tax is real.
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 you use Nix, this is a fun afternoon project โ and the full recipe lives in Nix on a Kobo: The Cookbook: prerequisites, hardware spec sheet, the cross-compile module, the overlay, the bootstrap script, the deploy sequence, references. The shortest possible getting-started 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.
Good enough Nix on an e-reader > a perfect cyberdeck you never build. Ship the flake, fix the broken plugin later.
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 here. Original ideas and editorial decisions by Justin Quaintance.