Skip to content
← All notes

// notes

Nix on CachyOS: install impossible — run it rootless

Nix will not install when the root is read-only, sudo is broken and /nix is baked into ELF. Three limits and a rootless run with mount namespace and chroot

Published: 2026-10-178 min read
NixCachyOSLinuxrootlesschroot

On this host Nix will not install: the root / is mounted read-only, sudo is broken, and the /nix/store path is hard-coded in the ELF interpreter (the absolute path to the dynamic loader inside an ELF binary). A normal install needs write access to /nix and root, so below are the three constraints and a rootless run (no root, no changes to the system) through a mount namespace (an isolated tree of mount points) and chroot (swapping the root for one process)

Prerequisite: a ready store (the package store) already sits in ~/.nix. Moving the store is a separate step and is not covered here: you need a store produced on a machine where /nix is available. There is no bootstrap command, because the stock installer still requires /nix and root

Symptom

The stock install stops at the first step: the /nix directory cannot be created

$ mkdir -p /nix
mkdir: cannot create directory '/nix': Read-only file system

The second barrier is sudo: the privilege escalation policy is broken, and root cannot be obtained

$ sudo -n true
sudo: /etc/sudo.conf is owned by uid 65534, should be 0

The workaround "unpack the store into $HOME and run it from there" runs into the third error

$ ~/.nix-profile/bin/nix --version
bash: .../nix: cannot execute: required file not found

The file exists, its permissions are -rwxr-xr-x, and the kernel still refuses to execute it

~/.nix-profile is a profile, a directory of links to installed packages. The packages themselves live in ~/.nix/store, and the store lives in ~/.nix, so running from the profile still looks for files under /nix/store paths that do not exist outside

Diagnosis

Each constraint has its own check

# 1) does /nix exist and can it be created
ls -ld /nix 2>&1; mkdir -p /nix 2>&1

# 2) does sudo work
sudo -n true 2>&1

# 3) where the ELF interpreter points
file ~/.nix-profile/bin/nix | tr ',' '\n' | grep interpreter

# 4) are unprivileged user namespaces available
unshare --mount --map-root-user echo OK
cat /proc/sys/user/max_user_namespaces
cat /proc/sys/kernel/unprivileged_userns_clone

# 5) minimal chroot: where exactly the launch breaks
unshare --mount --map-root-user bash -c '
  mount -t tmpfs tmpfs /mnt
  mkdir -p /mnt/usr /mnt/nix
  mount --rbind /usr /mnt/usr
  ln -sfn "$(readlink /lib64)" /mnt/lib64
  chroot /mnt /usr/bin/ls /'

The file output contains a line like interpreter /nix/store/<hash>-glibc-2.39-52/lib/ld-linux-x86-64.so.2 — that is the cause of the refusal

The fifth check assembles a minimal new root and runs ls inside it. If it fails, the problem is not Nix but missing root symlinks

Cause

The constraints are independent, but together they add up to a single ban on the stock install

  • The root / is mounted read-only: /nix cannot be created, neither by the user nor by a process with uid 0 in the namespace
  • sudo does not work: /etc/sudo.conf belongs to nobody (uid 65534), and the privilege escalation policy is broken. In a managed environment such a restriction is agreed with the administrator, not bypassed
  • The /nix/store path is hard-coded in the ELF interpreter: the kernel looks up the dynamic loader by an absolute address, and Nix binaries are not relocatable

The third constraint explains why moving the store into $HOME does not help on its own. Even jq and bash from the store will not run outside /nix, because the kernel looks up the interpreter at the address from the header

The Nix installer sets dest="/nix" and demands either root for the multi-user mode with a daemon and the nixbld group (service build users), or write access to /nix for single-user. This host has neither

The host behaves like a container: the root is read-only, the sudo policy is broken, and /nix is absent from the disk

/nix exists only inside the mount namespace and only for the duration of the command. The directory is not on disk

Fix

The idea behind the nix-enter wrapper (an example name): assemble a temporary root in tmpfs (an in-memory filesystem), put the system directories and ~/.nix underneath, then chroot. Everything lives in a user namespace (a namespace of user and group IDs) and disappears with it; the disk is untouched

  • create a tmpfs in a temporary directory
  • rbind (recursive bind mount) usr, etc, var, run, dev, proc, sys, home, tmp, opt, srv, boot
  • recreate the root symlinks bin, lib, lib64, sbin — without them nothing at all starts inside chroot
  • mount ~/.nix as /nix
  • run chroot and execute the command

A trimmed working fragment of ~/.local/bin/nix-enter. Below are three parts of one file, split up for the walkthrough

The first part finds the package that contains nix itself in the store and picks the highest version

#!/usr/bin/env bash
set -euo pipefail

NIX_ROOT="${NIX_ROOT:-$HOME/.nix}"
[ -d "$NIX_ROOT/store" ] || { echo "no store in $NIX_ROOT" >&2; exit 1; }

NIX_PKG=""
for d in "$NIX_ROOT"/store/*-nix-[0-9]*; do
    [ -x "$d/bin/nix" ] || continue
    case "$d" in *.drv) continue ;; esac
    if [ -z "$NIX_PKG" ]; then
        NIX_PKG="$d"
    else
        NIX_PKG="$(printf '%s\n%s\n' "$NIX_PKG" "$d" | sort -V | tail -1)"
    fi
done
[ -n "$NIX_PKG" ] || { echo "nix not found in $NIX_ROOT/store" >&2; exit 1; }

The loop skips .drv entries and checks that bin/nix is executable. sort -V compares versions as numbers, so 2.24.10 does not lose to 2.9

The second part creates the temporary root and starts the namespace, inside which the system directories are mounted

CALLER_PWD="$PWD"
TMPROOT="$(mktemp -d "${TMPDIR:-/tmp}/nix-enter.XXXXXX")"

cleanup() {
    mountpoint -q "$TMPROOT" && umount -R "$TMPROOT"
    rm -rf --one-file-system "$TMPROOT"
}
trap cleanup EXIT

unshare --mount --map-root-user -- bash -c '
set -euo pipefail
TMPROOT="$1"; NIX_ROOT="$2"; NIX_PKG="$3"; CALLER_PWD="$4"; shift 4

mount -t tmpfs tmpfs "$TMPROOT"

for d in usr etc var run dev proc sys home tmp opt srv boot; do
    [ -e "/$d" ] || continue
    mkdir -p "$TMPROOT/$d"
    mount --rbind "/$d" "$TMPROOT/$d"
done

for l in bin lib lib64 sbin; do
    [ -L "/$l" ] || continue
    ln -sfn "$(readlink "/$l")" "$TMPROOT/$l"
done

mkdir -p "$TMPROOT/nix"
mount --bind "$NIX_ROOT" "$TMPROOT/nix"

unshare --mount --map-root-user creates a new mount namespace and uid 0 inside it. rbind carries directories over together with nested mounts. The root symlinks are recreated before chroot: otherwise /usr/bin/ls will not find lib64, and the message will be the same required file not found

The third part assembles the environment and does the chroot

NIX_BIN="/nix/${NIX_PKG#"$NIX_ROOT"/}/bin"
exec chroot "$TMPROOT" /usr/bin/env \
    HOME="$HOME" \
    USER="${USER:-$(id -un)}" \
    LOGNAME="${LOGNAME:-${USER:-$(id -un)}}" \
    TERM="${TERM:-xterm-256color}" \
    PATH="$NIX_BIN:/usr/local/bin:/usr/bin:/bin" \
    NIX_STORE_DIR=/nix/store \
    NIX_STATE_DIR=/nix/var/nix \
    NIX_CONF_DIR=/nix/etc/nix \
    NIX_REMOTE= \
    NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
    /usr/bin/bash -c "cd \"\$1\" 2>/dev/null || cd /; shift; exec \"\$@\"" \
    _ "$CALLER_PWD" "$@"
' -- "$TMPROOT" "$NIX_ROOT" "$NIX_PKG" "$CALLER_PWD" "$@"

NIX_CONF_DIR points at nix.conf inside the store: without it Nix will not read the settings, including sandbox = false. CALLER_PWD preserves the calling directory: after chroot the previous working directory is unavailable, so it is restored before the command runs. The ${USER:-$(id -un)} substitution is needed because of set -u: without it a non-interactive run without USER would fail

The two bash -c calls do different jobs. The outer one runs the body in the namespace, where mounts are possible. The inner one, already after chroot, executes the user command. The _ argument takes the $0 slot, so the first argument stays in $1

cleanup checks whether TMPROOT is mounted and unmounts it recursively, and only it. rm -rf --one-file-system does not cross filesystem boundaries, so it cannot walk into the rbind copies of /home and /etc

nix-enter is neither a sandbox nor a privilege escalation: uid 0 exists only inside the namespace, and /home, /etc and the network are passed through as they are. Do not run untrusted code this way

Why not overlay or pivot_root

  • pivot_root in tmpfs: after the switch /usr/bin/ls stops starting unless the lib and lib64 symlinks are recreated; chroot is simpler and does the job
  • mount -o remount,rw on an rbind of the root: mount: permission denied, the read-only flag is not cleared in a user namespace
  • overlayfs (one directory over another) with lowerdir=/ (the lower layer is the root /): wrong fs type, bad option, bad superblock; overlay over a read-only root does not come up in an unprivileged namespace, although overlay on tmpfs works
  • a bind mount over an existing directory: /nix is not among the existing ones, and it cannot be created

Check

The first check shows that /nix is visible only inside the wrapper

$ ls -d /nix/store
ls: cannot access: No such file or directory
$ nix-enter ls -d /nix/store
/nix/store

The second one shows that nix starts and sees the store

$ nix-enter nix --version
nix (Nix) 2.24.10
$ nix-enter bash -c 'test -d /nix/store && echo inside'
inside

The third one shows that the disk has not changed: after leaving the namespace /nix is gone again, and findmnt shows no new mounts

$ test -d /nix/store || echo outside
outside
$ findmnt -rno TARGET | grep -x /nix || echo none
none

If your shell is fish, re-execing inside the namespace is covered separately: Nix devShell in fish: PATH is silently ignored

For the case where direnv tries to raise a nested namespace, there is a separate breakdown: nix-direnv: "unshare failed: Operation not permitted"

Pitfalls

  • lib and lib64 are not directories but symlinks to usr/lib. They must be recreated explicitly, otherwise chroot starts no binary at all, and the error is the same: required file not found while the file exists
  • The Nix profile is unreliable: nix profile install <package> overwrites default, and nix itself may disappear from there. Look the package up in the store with a glob and take the highest version, as in the fragment
  • unshare --map-root-user gives uid 0 but does not clear the read-only flag on the root: these are different mechanisms
  • Inside the namespace ls -la shows uid 0 on files: a consequence of the mapping, not an error
  • The namespace is ephemeral: after exit /nix disappears, and GUI editors and background processes started outside will not see Nix
  • If unprivileged user namespaces are disabled (max_user_namespaces = 0 or unprivileged_userns_clone = 0), the wrapper will not work: unshare fails, and there is no way around it without an administrator
  • The path to CA certificates is better resolved in the store than hard-coded with a hash the garbage collector may delete. The fragment uses the system /etc/ssl/certs/ca-certificates.crt as a fallback

Takeaway

In an environment with a read-only root and broken sudo the stock Nix install is impossible: three independent constraints get in the way, and the main one is the /nix/store path baked into ELF. A rootless run through a mount namespace and chroot gets around all three without touching the disk, but it requires a ready store in ~/.nix

Need this solved on your project?