# 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.

- Author: Maulana Sodiqin (https://msdqn.dev)
- Published: Oct 8, 2026
- Updated: Oct 8, 2026
- URL: https://blog.msdqn.dev/blog/nix-on-android
- Tags: nix, android, nix-on-droid, home-manager, developer-experience

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](https://github.com/nix-community/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](https://github.com/maulanasdqn/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](/blog/one-nix-flake-for-every-machine).

## 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:

```sh
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:

```nix
{
  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:

1. 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.
2. nixvim's `main` branch pins Neovim 0.12, which links against glibc 2.42 and freezes at TUI startup under proot.
3. home-manager `master` requires 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:

```nix
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`:

```nix
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`:

1. Briefly remounts `/` read-write to `mkdir /nix`.
2. Bind-mounts `/data/nix` (on the f2fs user data partition, about 104 GB free) over it.
3. Remounts `/` read-only.
4. Writes `/etc/resolv.conf` pointing at `1.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:

```nix
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:

1. Build the `activationPackage` on Colima (an aarch64-linux VM on the Mac).
2. Export the closure and push it to the phone over ADB.
3. Import the closure with `nix-store --import`.
4. Point the profile at the pre-built `home-manager-path` with `nix-env --set`, which does zero building.
5. Hand-link the generation's dotfiles into `$HOME`.

```sh
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:

```nix
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:

```nix
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:

```sh
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:

```sh
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:

1. `Add nix-on-droid Android configuration` — initial setup.
2. `Fix neovim conflict: remove from environment.packages, keep in home-manager` — nix-on-droid and home-manager both tried to install Neovim.
3. `Simplify android home.nix to avoid proot PTY build errors` — the first of many proot issues.
4. `Remove home-manager from android config (proot sandbox incompatible)` — gave up on home-manager entirely.
5. `Slim down android packages to speed up initial build` — the phone was trying to build too much.
6. `Fix PTY error on Android by bypassing nix-env in activation` — proot's PTY emulation broke `nix-env`.
7. `Fix PTY error: inject nix-env wrapper via activationBefore` — the bypass was not enough.
8. `Add honor nix-on-droid host` — started over with a proper host structure.
9. `Drop fixNixEnvPty hack that broke first switch` — the PTY hack from step 6 and 7 caused more problems than it solved.
10. `Reuse zsh/starship/neovim home config on honor` — brought home-manager back, this time using the `hm.nix` split.
11. `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](https://github.com/maulanasdqn/infra.msdqn.dev) under `hosts/android/`.
