Nix on Android: A Real Shell on Your Phone
Two Android phones, one with a locked bootloader running nix-on-droid under proot, the other unlocked with Nix running natively at /nix. Both get the same zsh, Neovim and CLI tools as my Mac, built from one flake.
Ask AI about this post
I have two Android phones. One has a locked bootloader and runs stock firmware. The other is unlocked, rooted and running a custom ROM. Both run Nix, and both get the same shell, editor and CLI tools as my Mac and my NixOS machines, built from the same repository.
This post is about setting up nix-on-droid on a locked phone, running Nix natively on a rooted phone without nix-on-droid, and sharing home-manager modules between them and every other machine I own. All the code lives in my infra.msdqn.dev repository, the same flake that configures my Macs, PCs and servers. If you want the full picture of how one flake runs everything, see the previous post.
Two phones, two strategies
HONOR X9c (honor) | POCO F3 (poco-f3) | |
|---|---|---|
| Bootloader | Permanently locked | Unlocked |
| ROM | Stock MagicOS | crDroid 16 (Android 16) |
| Root | None | KernelSU-Next |
| Nix runs via | nix-on-droid (proot) | Natively at /nix |
| Config type | nixOnDroidConfigurations | homeConfigurations |
| nixpkgs | Pinned to 25.11 | Follows unstable |
The locked phone has no choice but proot. The unlocked phone can skip proot entirely, which removes every problem proot causes. Both get the same user environment: zsh with oh-my-zsh and syntax highlighting, Starship prompt, Neovim with full LSP, and a shared set of CLI tools including ripgrep, fd, bat, eza, lazygit, httpie and language toolchains for Rust, Go, Node.js and Python.
The locked phone: nix-on-droid
nix-on-droid installs Nix inside a Termux-like app and runs everything through proot, a userspace tool that intercepts syscalls to fake a root filesystem. No root access needed, no bootloader unlock, nothing permanent.
Setup
Install the nix-on-droid APK from F-Droid or its GitHub releases. Once it opens, you have a terminal with Nix. My configuration is a flake target:
git clone https://github.com/maulanasdqn/infra.msdqn.dev ~/.config/nix
cd ~/.config/nix
nix-on-droid switch --flake .#honor
The flake entry is short. It points at the shared base, sets zsh as the shell, pins nixpkgs in the registry so nix shell nixpkgs#fastfetch works, and imports the same home-manager modules the desktops use:
{
pkgs,
nixvim,
nixpkgs,
claude-code,
...
}:
{
imports = [ ../default.nix ];
user.shell = "${pkgs.zsh}/bin/zsh";
nix.registry.nixpkgs.flake = nixpkgs;
nix.nixPath = [ "nixpkgs=${nixpkgs}" ];
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
backupFileExtension = "backup";
extraSpecialArgs = {
inherit nixvim claude-code;
enableLaravel = false;
};
config = {
imports = [
../../../modules/home/zsh/hm.nix
../../../modules/home/starship/hm.nix
../../../modules/home/neovim/hm.nix
../../../modules/home/packages/cli.nix
];
home.stateVersion = "24.05";
};
};
}
The shared base (hosts/android/default.nix) only adds what proot needs on top: the JetBrainsMono Nerd Font for the terminal, ncurses and coreutils for clear and tput, and the timezone. It stays minimal because the real tooling comes from the shared cli.nix that every machine imports.
Why it must be pinned to 25.11
Everything else in my flake follows nixpkgs-unstable. This phone is the exception, and it needs separate stable inputs for nixpkgs, home-manager and nixvim. Three independent bugs forced the pin:
- A glibc 2.42 change in Nix 2.31.3 broke builds under proot (nix-on-droid issue #495). The 25.11 release predates it.
- nixvim's
mainbranch pins Neovim 0.12, which links against glibc 2.42 and freezes at TUI startup under proot. - home-manager
masterrequires nixpkgs-unstable internals that 25.11 does not have.
The flake declares nixpkgs-stable, home-manager-stable and nixvim-stable as separate inputs and uses them only for the honor target:
honor = mkNixOnDroid {
hostModule = ./hosts/android/honor;
pkgsSrc = nixpkgs-stable;
extraSpecialArgs = {
inherit claude-code;
nixvim = nixvim-stable;
nixpkgs = nixpkgs-stable;
};
};
A side benefit of 25.11: its aarch64 binaries are fully cached by Hydra. The phone substitutes every package instead of compiling on-device, which is the difference between a five-minute switch and a five-hour one.
The hm.nix split
The home-manager modules that the phone imports are specifically the hm.nix files, not the default.nix files. This split exists because default.nix wraps hm.nix under the system's home-manager.users.<name>, which is how NixOS and nix-darwin mount a home config. nix-on-droid has its own single-user home-manager, so it imports hm.nix directly.
The rule is simple: anything that works on a phone goes in hm.nix. Anything that needs a desktop (Hyprland keybindings, VS Code, Docker) stays in default.nix. The phone, the Mac and the NixOS PC all get the same Neovim and zsh because they all import the same hm.nix.
The unlocked phone: native Nix without proot
The POCO F3 is a different situation. It is unlocked, running crDroid 16, rooted with KernelSU-Next and running a custom kernel with CONFIG_USER_NS enabled. Nix runs natively, with a real build sandbox, on a real /nix mount.
This phone deliberately does not use nix-on-droid. It is a plain homeManagerConfiguration:
homeConfigurations = {
poco-f3 = home-manager.lib.homeManagerConfiguration {
pkgs = import nixpkgs {
system = "aarch64-linux";
config.allowUnfree = true;
};
extraSpecialArgs = {
inherit claude-code nixvim;
enableLaravel = false;
};
modules = [ ./hosts/android/poco-f3 ];
};
};
It tracks the same nixpkgs-unstable as the desktops, because the glibc 2.42 regression is a proot bug and proot is gone.
Making /nix exist on Android
Android's root filesystem is read-only ext4 with dm-verity (disabled on crDroid), and there is no /nix directory. The solution is a KernelSU module called nixbind that runs at post-fs-data:
- Briefly remounts
/read-write tomkdir /nix. - Bind-mounts
/data/nix(on the f2fs user data partition, about 104 GB free) over it. - Remounts
/read-only. - Writes
/etc/resolv.confpointing at1.1.1.1, because Android has no resolver config file and glibc binaries fail every DNS lookup without one.
The module is built by Nix itself:
stdenvNoCC.mkDerivation {
pname = "magisk-nixbind";
version = "1.0";
buildPhase = ''
mkdir -p mod/META-INF/com/google/android
cat > mod/module.prop <<PROP
id=nixbind
name=Nix native store bind-mount
...
PROP
cp ${./post-fs-data.sh} mod/post-fs-data.sh
substituteInPlace mod/post-fs-data.sh \
--replace-fail '@store@' '${store}' \
--replace-fail '@resolv@' '${resolv}'
'';
installPhase = ''
( cd mod && zip -qr "$out/nixbind.zip" . )
'';
}
The /nix mountpoint does not survive a crDroid OTA, so it is recreated on every boot rather than once. That is why this is a post-fs-data script and not an install-time operation.
The deploy problem
You cannot run home-manager switch on the phone. The POCO F3 has 5.5 GB of RAM, and nix-env -i home-manager-path (the buildEnv that unions roughly 1,108 packages) hangs indefinitely on-device.
The solution is hm-deploy.sh, which builds on a Mac and activates on the phone:
- Build the
activationPackageon Colima (an aarch64-linux VM on the Mac). - Export the closure and push it to the phone over ADB.
- Import the closure with
nix-store --import. - Point the profile at the pre-built
home-manager-pathwithnix-env --set, which does zero building. - Hand-link the generation's dotfiles into
$HOME.
nix run .#poco-f3-hm-deploy
The script refuses to run unless ro.product.device is alioth and KernelSU su works, so it cannot fire at the wrong phone.
Config gotchas on native Nix
Several things that work on a real Linux box break on Android:
No /dev/shm. Android uses ashmem instead. Python's multiprocessing module, which nixos-render-docs uses to build the home-manager man pages, dies with FileNotFoundError in SemLock. The nixbind module mounts a tmpfs at /dev/shm at boot, and the home-manager config disables the man pages anyway:
manual.manpages.enable = false;
news.display = "silent";
No /etc/passwd. glibc's getpwuid(0) returns null, and zsh needs it to set $HOME and $USER. The deploy script writes minimal passwd and group files and bind-mounts them over /etc/passwd and /etc/group.
HOME defaults to /. When you call su, Android's su sets HOME=/. If the shell tries to create ~/.cache, it fails because / is read-only. Every script that touches HOME must set it unconditionally, not with a ${HOME:-default} fallback.
Locale is ASCII. Android's default locale under glibc is the C locale (ASCII). Starship's Unicode glyphs break iconv on every prompt. Setting LANG=C.UTF-8 and LC_ALL=C.UTF-8 fixes it. C.UTF-8 is built into glibc, so no locale archive is needed:
home.sessionVariables = {
LANG = "C.UTF-8";
LC_ALL = "C.UTF-8";
};
Termux as the entry point
The phone's terminal is still Termux. The deploy script writes a .bashrc that makes Termux drop straight into the native Nix environment:
if [ -z "$NIX_ENTERED" ] && [ "$(id -u)" -ge 10000 ]; then
if su -c 'true' 2>/dev/null; then
export NIX_ENTERED=1
exec su -c 'export NIX_ENTERED=1 HOME=/data/local/nixhome; \
. /nix/etc/profile.sh; \
exec /data/local/nixhome/.nix-profile/bin/zsh -l'
fi
fi
Open Termux, grant root once in KernelSU's Superuser panel, and every future session goes straight to zsh with Starship, Neovim and the full CLI toolset. SELinux blocks non-root domains from reading /nix, so the su escalation is not optional.
sandbox = true
The stock crDroid kernel ships without CONFIG_USER_NS, which means Nix cannot create a build sandbox. On the stock kernel, sandbox = false is the only option, and worse, local builds hang in uninterruptible D-state.
The custom kernel (msdqn-kernel) enables CONFIG_USER_NS and CONFIG_PID_NS, so /nix/etc/nix.conf sets sandbox = true and builds work correctly. You can verify it:
unshare -U -r --map-root-user id
# uid=0(root)
What you get
On both phones, the result is the same interactive environment. Open Termux and you are in zsh with oh-my-zsh, autosuggestions and syntax highlighting. The prompt is Starship. The editor is Neovim with Treesitter, Telescope, LSP for Nix, TypeScript, Rust and more, the same keybindings and the same Rose Pine theme as my Mac. CLI tools like ripgrep, fd, bat, lazygit, gh, jq and httpie are all there. Rust, Go, Node.js, Python and their toolchains are on PATH.
The locked phone takes about five minutes to switch on a decent connection, because everything is cached. The unlocked phone takes two minutes for the Colima build and ADB push. Both are re-runnable: change the flake, run the command, get the new config.
The commit history tells the real story
Setting this up was not smooth. Here is the order of commits for the honor host alone:
Add nix-on-droid Android configuration— initial setup.Fix neovim conflict: remove from environment.packages, keep in home-manager— nix-on-droid and home-manager both tried to install Neovim.Simplify android home.nix to avoid proot PTY build errors— the first of many proot issues.Remove home-manager from android config (proot sandbox incompatible)— gave up on home-manager entirely.Slim down android packages to speed up initial build— the phone was trying to build too much.Fix PTY error on Android by bypassing nix-env in activation— proot's PTY emulation brokenix-env.Fix PTY error: inject nix-env wrapper via activationBefore— the bypass was not enough.Add honor nix-on-droid host— started over with a proper host structure.Drop fixNixEnvPty hack that broke first switch— the PTY hack from step 6 and 7 caused more problems than it solved.Reuse zsh/starship/neovim home config on honor— brought home-manager back, this time using thehm.nixsplit.Build honor on nixpkgs 25.11 for cached aarch64 binaries— the pin that finally made it reliable.
Eleven commits before it was stable. The lesson: proot is fragile and its bugs are hard to diagnose, because the error messages come from the syscalls it intercepts, not from proot itself. The 25.11 pin was the fix that resolved everything at once by avoiding the glibc version that broke proot.
The POCO F3 was easier, because native Nix does not have proot's failure modes. The hard part was the kernel: building a custom kernel with CONFIG_USER_NS, getting the /nix bind mount to survive boots, and working around Android's missing POSIX infrastructure (/dev/shm, /etc/passwd, /etc/resolv.conf).
Should you do this
If your phone's bootloader is locked, nix-on-droid works. Pin to a stable nixpkgs release, use the hm.nix split to share your config, and expect to spend time debugging proot. The result is good enough for SSH sessions, writing, quick edits and running CLI tools on the go.
If your phone is unlocked and rooted, skip proot entirely. A KernelSU module to bind-mount /nix, a custom kernel with user namespaces, and a deploy script that builds on a real machine gives you a native Nix environment that is as fast and reliable as a desktop. The phone becomes another machine in the flake, not a workaround.
The full configuration, including the KernelSU modules and the deploy script, is at github.com/maulanasdqn/infra.msdqn.dev under hosts/android/.
Ask AI about this post