· 7 min read ·

The Store Path Is the Feature: How unnix Delivers Nix Dev Shells Without Installing Nix

Source: lobsters

When someone shares a shell.nix or flake.nix with you, they’re offering a description of a software environment that can be reproduced exactly, anywhere, by anyone with Nix installed. The catch is that last phrase. Getting Nix installed on a machine you control but don’t administer, on a CI runner where you have a few seconds to set up a build environment, or on a corporate laptop where IT policy predates the concept of reproducible builds, turns a trivial nix develop into a bureaucratic project.

unnix by figsoda attempts to collapse that gap. It lets you enter a Nix shell environment without having Nix installed at all. That sounds like it ought to be straightforward (just download the packages, set PATH, done), but there is a fundamental property of how Nix-built binaries are structured that makes the problem genuinely hard. Understanding that property explains why unnix and the tools like it exist on a spectrum, each making different trades to solve it.

Why Nix Binaries Are Tied to /nix/store

Every binary produced by a Nix build has its dynamic linker and shared library paths embedded as absolute paths beginning with /nix/store/. On Linux, this is encoded in the ELF binary’s RPATH and PT_INTERP fields. A Nix-built Python interpreter might have an interpreter path like:

/nix/store/vg99vj8d4a6gl6a3wv0amrgzmj8rd7fq-glibc-2.38/lib64/ld-linux-x86-64.so.2

And its shared libraries will be found via RPATH entries pointing to other /nix/store/... paths. The hardcoded paths are the mechanism through which Nix achieves isolation and reproducibility: each package gets its own copy of its dependencies at a path derived from a cryptographic hash of all its inputs, so packages can coexist without interference, and the same hash always refers to the same binary.

The consequence is that you cannot simply copy a directory of Nix-built binaries to ~/my-nix-packages/bin/ and expect them to work. The binaries will look for their dynamic linker at a path that either does not exist or points to something else. On macOS, the same principle applies through LC_RPATH and @rpath in Mach-O binaries.

The central tension is this: the binary cache has the packages; those packages will only function at /nix/store; writing to /nix/store requires root. Any tool that wants to use Nix packages without a full Nix installation must resolve all three of these constraints.

How Different Tools Handle the Problem

The tools in this space have landed on three different answers.

nix-portable (by DavHau) bundles a full Nix runtime with its own evaluation and build capabilities. It ships a statically-linked Nix binary and uses proot on Linux to intercept filesystem calls and transparently remap any path access to /nix/store/... to a user-writable location like ~/.nix-portable/store/. From the perspective of the running process, the paths are correct; the kernel never sees the real paths. This works on Linux without root, but proot adds overhead to every syscall and the approach is more limited on macOS.

nix-bundle takes a different angle entirely: instead of running Nix environments at runtime, it pre-packages a derivation into a self-contained executable at build time. The resulting binary is a squashfs image that extracts and runs with its store paths intact, using a bundled runtime. This is good for distributing single applications but not for interactive dev shells.

unnix goes the other direction: minimum infrastructure, maximum dependency on the binary cache. Rather than solving the RPATH problem through syscall interception or pre-bundling, it relies on unprivileged user namespaces to make the local store directory visible at the canonical /nix/store path. On Linux, unprivileged user namespaces via unshare --mount allow a process to create its own mount namespace and bind-mount any directory to any path within it, including /nix/store, without root privileges. The processes that unnix spawns see /nix/store pointing to the locally downloaded NARs, so all the hardcoded paths resolve correctly.

What unnix Does

The workflow is: provide a shell.nix or point at a flake, and unnix contacts a Nix binary cache (defaulting to cache.nixos.org) to determine which store paths are required and downloads the corresponding .nar archives. A NAR (Nix ARchive) is a deterministic, filesystem-tree serialization format specific to Nix. Each store path is a single NAR. unnix unpacks these into a local directory, arranges the mount namespace, and drops you into a shell with the environment configured as if Nix had set it up.

The critical constraint is that it only works for derivations whose outputs are already in a binary cache. Nix’s reproducibility guarantees let you write a shell.nix that references a local C project and builds it as part of the shell setup; unnix cannot do this. Building a derivation requires the Nix evaluator, the build sandbox, and write access to the store. unnix is a binary cache consumer, not a builder.

For the common case of wanting a dev shell that pulls a specific set of packages from nixpkgs, this constraint is often not a problem. nixpkgs packages are built by Hydra and available on cache.nixos.org for x86_64-linux and aarch64-linux. If your shell.nix looks like:

{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = [ pkgs.go pkgs.gopls pkgs.delve ];
}

Every package in that closure is almost certainly in the cache.

The Nix Installation Barrier Is Partly Governance

The technical details above explain why the implementation is hard, but the reason tools like unnix get built is not purely technical. Nix’s installer has improved substantially, and on a personal machine with root access it works without issues on both Linux and macOS. The barrier is frequently organizational.

Corporate-managed machines often enforce policies that prevent modifications to /etc and the creation of system-level services, which the recommended multi-user Nix installation requires. Shared HPC clusters are a common source of frustration: researchers want reproducible environments for their computations, Nix is a natural fit, but cluster admins are not going to install a daemon for one user’s workflow. GitHub Actions runners can have Nix installed (the DeterminateSystems/nix-installer-action works reliably), but it adds latency and occasionally fails in sandboxed environments.

On macOS specifically, the situation is more involved. Since Catalina, / is a read-only system volume. Nix works around this by creating a separate APFS volume that mounts at /nix via /etc/synthetic.conf, which requires a reboot and interacts awkwardly with MDM profiles. Tools like devbox take the approach of managing a Nix installation automatically on behalf of the user, hiding the complexity but still requiring the underlying system to support it. unnix tries to avoid that requirement entirely.

Where unnix Sits in the Ecosystem

figsoda is not a newcomer to Nix tooling. They are the author of nix-update, nix-init, and several other utilities in common use across the Nix community. unnix fits a specific niche: lighter than nix-portable (no proot dependency, no full Nix runtime), narrower than devbox (no managed installation, no CLI abstraction layer), and more interactive than nix-bundle (you get a shell, not a single bundled executable).

The approach of downloading NARs directly and bypassing the Nix installer also connects to how some CI configurations already handle caching. Tools like magic-nix-cache speed up CI by saving store paths as build artifacts and restoring them before the next run. unnix applies that same caching pattern to user environments rather than build pipelines.

For completeness, direnv is sometimes mentioned in this context, but it solves a different problem: automating the activation of a Nix shell when you enter a directory. It still requires Nix to be installed and does nothing to address the installation barrier itself.

What It Trades Away

Using unnix instead of a full Nix installation means accepting several constraints. Custom derivations are out. Source builds are out. NixOS options and modules are out. If you want Nix’s full capability to reproducibly build software from source, you need the real installer.

Cache availability is a related concern. cache.nixos.org carries pre-built packages for a limited set of platforms and nixpkgs channel versions. If your shell.nix pins an older nixpkgs commit and the binary cache has since evicted those store paths, unnix will fail where a full Nix installation would fall back to building from source.

Dynamically-linked packages with runtime dependencies on system libraries can also behave unexpectedly in subtle ways. Nix-built binaries link against their own copies of glibc and other libraries, so the core linking chain is usually handled correctly, but edge cases exist around X11 sockets, D-Bus, font configuration, and other system services that some Nix packages assume are available at standard paths outside the store.

Unprivileged user namespaces are also not available everywhere. Some hardened kernels and container runtimes disable them via kernel.unprivileged_userns_clone=0. In those environments, the mount namespace approach that unnix likely depends on will not work, and the tool would need to fall back to something like proot or fail outright.

When This Makes Sense

unnix makes most sense in environments where you need a reproducible set of command-line tools, language toolchains, or build utilities, and installing Nix is not feasible. A Go project with a shell.nix that provides a specific go version, golangci-lint, and protoc is exactly the kind of environment unnix handles well. The entire closure will be in the cache, the tools are self-contained, and the environment is reproducible without touching /etc or running a daemon.

For more complex environments (those with custom overlays, local builds, or system-level integration), nix-portable or a full Nix installation remains the appropriate choice. The line between what unnix can handle and what it cannot is drawn by the RPATH constraint and the binary cache dependency, which is another way of saying it is drawn by the same design choices that make Nix’s reproducibility guarantees meaningful in the first place. The store path is not a limitation to be engineered around; it is the guarantee. unnix works by finding the narrowest possible path through the constraints it cannot change.

Was this interesting?