Documentation

Everything about Nixup on one page: what it is, how it works inside, and every part of a phone's config, with examples you can copy. New to Nix? Read Your first build and Nix in ten minutes first.

Nixup is in active development. Back up everything before you start. The project comes with no warranty and is not responsible for data loss.

What Nixup is

Nixup lets you describe a rooted Android phone in one text file, configuration.nix, and then makes the phone match it. The file lists the apps you want, how each app is set up, Android's own settings, programs for the terminal, background services and backups. You press Build; Nixup checks the whole file first, then changes only what is different. Every build is kept, so you can always go back.

Here is the idea. Without Nixup, on every new phone you tap through:

  • Settings → Display → Dark theme → on
  • F-Droid → search “Termux” → Install
  • Settings → Apps → Signal → Permissions → Camera → Allow

With Nixup you write it down once, and every build makes it so:

{ pkgs, ... }: {
  android.settings.display.darkMode = true;                            # dark theme on
  android.packages.fdroid = with pkgs.fdroid; [ com.termux ];          # Termux, from F-Droid
  android.apps."org.thoughtcrime.securesms".permissions.camera = true; # Signal may use the camera
}

If you know NixOS, you already know the way it works: a configuration.nix, a nixos-rebuild switch, generations you can roll back to, and NixOS's own option names wherever they make sense (networking.hostName, systemd.services, services.openssh, users.users, environment.systemPackages, …). One module file can often set up a NixOS machine and the phone alike.

Why it exists

NixOS itself runs on only a few phones. Nix-on-Droid gives you a Nix terminal on any phone, but without root it cannot manage Android itself. What was missing: installing apps from F-Droid-like repositories from a config, real app backups driven by the config, stable background services and scripts, and app settings. Nixup keeps Android, your ROM and your data, and gives Nix the rights to manage them.

What it is not

  • Not a new ROM. Android stays as it is. Nixup is a root module plus an app.
  • Not a PC tool. The phone builds its own config. A PC is optional.
  • Not a compiler. Programs come ready-made from cache.nixos.org; the phone never compiles a package.
  • Not a Play Store. Apps come from F-Droid-style repositories, developers' releases, or Google Play downloaded directly; no store app is needed.

Words used on this page

A few words come up again and again. Each one is simple:

Root
Full control of the phone. Magisk, KernelSU or APatch give it. Nixup needs it to install apps and change settings without asking each time.
Module
How Nixup is installed: a zip that Magisk installs. It brings the Nixup app, Nix, and the scripts that run at each start of the phone. (In Nix, a “module” is also one file of a config: see Splitting and sharing.)
Nix
The language the config is written in, and the tool that reads it. Nix also downloads Linux programs (“Nix packages”) ready-made from cache.nixos.org.
nixpkgs
The big collection of Nix packages and NixOS modules. pkgs.htop is the htop package from it.
Config
Your configuration.nix (or a flake.nix that points to it): the one file that describes the phone.
Flake
A folder with a flake.nix and a flake.lock. The lock file pins the exact version of everything the config uses. Optional.
Build
Reading the config, checking it, and making the phone match it. NixOS calls this a “switch”.
Dry run
A build that only says what it would change, and changes nothing.
Profile
What a build turns your config into: one JSON file (profile.json) that lists exactly what the phone should have.
Engine
The part of Nixup that changes the phone to match the profile. Its code is inside the Nixup app, but it runs as root, apart from the app.
Generation
A saved build. Each build that changes something becomes generation 1, 2, 3… You can switch back to any of them.
Service
A program that keeps running in the background, like an SSH server or Syncthing. You write them as NixOS's systemd.services.
Nixup's view
A private version of the phone's folders where Linux folders such as /nix, /etc and /home exist. Services and the terminal run in it; Android's apps never see it.
Specialisation
Another version of the config (a “work” or “travel” one), built with it, that you can switch to without building again.
Not managed
An option left out, or set to null, is not touched: the phone keeps whatever you set by hand.

Requirements

  • A rooted phone: Magisk (tested), KernelSU or APatch.
  • Android 11 or newer, on a 64-bit ARM processor (arm64). The installer stops on anything else.
  • A network connection for builds: app lists, apps and Nix packages are downloaded.
  • Free storage for the Nix packages your config uses. They are kept in /data/adb/nixup/nix.
  • Nix on a PC, only if you want to build there. It is optional.

Install

  1. Root the phone (see Requirements).
  2. Download nixup-v<version>.zip from the releases page.
  3. In Magisk, open Modules → Install from storage and pick the zip. (KernelSU and APatch have the same in their module screen.)
  4. Restart the phone.
  5. Open the Nixup app. Allow root when your root manager asks, and allow notifications.

Nothing on the phone changes yet. Until your first build, Nixup has an empty profile that manages nothing.

What the installer does, step by step
  • Stops if the phone is older than Android 11 (API 30) or is not arm64.
  • Installs the Nixup app for the phone's owner only (not in a work profile or app clone, which would not get root), and allows its notifications.
  • Makes Nix's commands runnable (nix, nix-instantiate, nix-store, nix-env, nix-shell, … are all the same nix program).
  • Adds nixup-shell, nixup-rebuild, nixup-backup, nixup-version and nixos-version to /system/bin (they work from the next restart).
  • Puts the empty starting profile in /data/adb/nixup/profile.json, unless a profile is already there.
  • Takes over an older install under Nixup's old names (Seraph, Andronix), so its records, generations and secrets carry on.
  • If the app on the phone was signed with another key, it is replaced; your root manager then asks to allow root again.

Your first build

Five minutes, start to finish. Start small: one app and one setting.

  1. Open Nixup and go to the Build screen. Press New and choose a folder (a folder of its own, like Documents/phone). Nixup saves a starter configuration.nix there and picks it as your config. (Or press Pick file for a file you already have, Git to clone your repository, or Type it for a flake address like github:you/phone.)
  2. Open the file in any text editor, on the phone or on a PC. The starter file lists every option, each with a comment. To begin, you can replace it with just this:
    { pkgs, ... }: {
      # One app from F-Droid
      android.packages.fdroid = with pkgs.fdroid; [ com.termux ];
    
      # One setting
      android.settings.display.darkMode = true;
    }
  3. Press Dry run. Nixup reads and checks the whole file and lists what a build would change. Nothing changes yet.
  4. Press Build. Termux is installed and dark mode turns on. A notification says the build made generation 1.
  5. Now delete the darkMode line and build again. Dark mode goes back to how it was before Nixup, and the build is generation 2.
  6. Changed your mind? Open Generations, pick generation 1 and switch to it.
Good to know

If anything in the config is wrong (a typo in an app's name, a wrong value, a missing file), the build stops before it changes anything, and the log says what and where.

Nix in ten minutes

You do not need to learn all of Nix to write a config. A config is mostly names and values. This is all the Nix you need for this page.

The shape of a config

{ config, lib, pkgs, ... }:   # the first line: Nixup hands the file these things
{                             # everything you set goes between these braces
  networking.hostName = "phone";
}
  • pkgs is where apps and Nix packages come from (pkgs.htop, pkgs.fdroid.com.termux).
  • lib holds helpers like lib.mkForce. config is the finished config, to read other options.
  • ... means “and anything else”. Ask only for what you use: { pkgs, ... }: is fine.

Values

{
  networking.hostName = "phone";                # text goes in double quotes
  android.settings.display.fontScale = 1.1;     # a number
  android.settings.display.darkMode = true;     # true or false
  time.timeZone = null;                         # null: not managed, leave it as it is
  android.backup.passwordFile = ./backup-password;   # a path: a file next to this one (no quotes)
}

Each setting ends with a semicolon ;. A # starts a comment, which Nix ignores.

Groups and dots

Braces group names. Dots are a shortcut for braces, so these two mean exactly the same:

android.settings.sound.touchSounds = false;

android.settings.sound = {
  touchSounds = false;
};

Lists

Square brackets, with spaces between the items (no commas):

android.packages.playstore = [ "com.whatsapp" "org.telegram.messenger" ];

with

with pkgs; lets you write htop instead of pkgs.htop in the list after it:

environment.systemPackages = with pkgs; [ htop git rsync ];
# is the same as
environment.systemPackages = [ pkgs.htop pkgs.git pkgs.rsync ];

Text with values in it, and long text

# ${ } puts a value into text: here, the folder where a package is
terminal.font = "${pkgs.jetbrains-mono}/share/fonts/truetype/JetBrainsMono-Regular.ttf";

# Two single quotes start and end text over many lines (handy for scripts)
systemd.services.hello.script = ''
  echo "hello from the phone"
  date
'';

Names with dots in them

An app's package name has dots, so where it is a name, put it in quotes:

android.apps."org.thoughtcrime.securesms".battery = "unrestricted";

In an app list, with pkgs.fdroid; [ com.termux ] works without quotes because com.termux is looked up inside pkgs.fdroid. A name that starts with a Nix word (in, let, with, if, …) cannot be written that way: put it in quotes instead, like "in.sunilpaulmathew.izzyondroid".

When two places set the same option

Nixup uses NixOS's rules. These helpers from lib decide who wins, or whether a line counts at all:

{ config, lib, ... }: {
  # Wins over a value set anywhere else (here: a NixOS module's own user)
  systemd.services.navidrome.serviceConfig.User = lib.mkForce "root";

  # Loses to a value set anywhere else: a default that other files can change
  android.settings.display.fontScale = lib.mkDefault 1.0;

  # Only when the condition is true
  services.syncthing.enable = lib.mkIf (config.networking.hostName == "phone") true;
}

More than one file

{
  imports = [ ./apps.nix ./services.nix ];   # these files are read as part of this config
}
Common mistakes
  • A missing ; at the end of a line.
  • Commas between list items: write [ "a" "b" ], not [ "a", "b" ].
  • An app name with dots as a name without quotes: write android.apps."com.termux".
  • Quotes around a path: ./key is the file next to the config; "./key" is only text, and Nix does not look for the file.

The build stops at all of these, before changing anything, and says the file and line.

The big picture

A build moves through four parts, from your file to the phone:

1. Your configconfiguration.nix or a flake, wherever you keep it.
2. NixComes with Nixup. Reads the config with Nixup's options, and checks it.
3. The profileOne JSON file: everything the phone should have.
4. The engineRuns as root. Changes the phone to match, then saves a generation.

Nix reads your config the same way NixOS reads its own: every file is a module, every option has a type, and Nixup's options are modules too. But instead of a whole operating system, the result is a profile: a plain description of the apps, settings, services and files the phone should have. For this config:

{ pkgs, ... }: {
  android.packages.fdroid = with pkgs.fdroid; [ com.termux ];
  android.settings.display.darkMode = true;
}

the profile has, among other things (shortened):

{
  "schemaVersion": 1,
  "android": {
    "darkMode": true,
    "repos": { "fdroid": { "url": "https://f-droid.org/repo", "fingerprint": "43238d51…" } },
    "packages": { "fdroid": [ "com.termux" ] },
    "units": { },
    "storePaths": [ ]
  }
}

The engine then compares the profile with the phone, changes only what differs, and reads each change back to be sure it took. Nix packages appear in the profile only as names (/nix/store/…-htop-3.4.1); the phone downloads them ready-made.

What is inside Nixup

One zip brings three things:

  • The module: shell scripts that run builds, start everything again at each start of the phone, and undo everything when Nixup is removed. They live in /data/adb/modules/nixup.
  • The app: the screens you use (Build, Updates, Backups, Services, Logs, a terminal). The engine's code is inside the app's APK too, but it never runs as the app: the module starts it as root with Android's app_process.
  • Nix: a ready-made Nix for ARM64, Nixup's option files (nix/*.nix) and the library part of nixpkgs, so a first build needs nothing else.

What a build does

Every build, from the app, a shell or a PC, goes through the same steps. The first seven only read and check. If any of them finds a problem, the build stops and nothing on the phone has changed.

  1. Find the config. The one the app's Config picked, or one given for this build only (nixup-rebuild switch --file …).
  2. Bring app lists up to date. Nix reads just the repositories from your config, and the engine refreshes their app lists (at most once a day; Upgrade always). Each list is checked against its repository's signing key.
  3. Get nixpkgs when needed. A flake gets its inputs as flake.lock pins them; a configuration.nix uses the phone's copy of nixos-unstable.
  4. Evaluate the config. Nix checks every option's name, type and value, runs the config's checks (assertions), prints its warnings, and writes the profile.
  5. Collect secrets. Files your options point to (like ./tailscale.key) are read and set aside where only root can read them. They never go into the profile.
  6. Fetch Nix packages from cache.nixos.org into Nixup's store. Small files made from the config (a service's settings file) are built on the phone. A package that would need compiling stops the build, unless you have build machines.
  7. Plan and check. The engine plans every app (is it in its repository? signed with the right key? is Google Play signed in?), every setting, secret, service and user, runs your system.preSwitchChecks, and checks free space. All problems are listed together. A dry run ends here, with the list of what would change.
  8. Nothing has changed up to this line
  9. Install and update apps. Each download is checked (size, SHA-256 and signing key) before it is installed.
  10. Apply settings: app settings, apps that came with the phone, system settings and adb. Each is read back after it is changed. Then DAVx⁵'s accounts: new ones added, passwords given.
  11. Prepare Nixup's view: users, /etc, hosts, DNS, folders (systemd.tmpfiles).
  12. Run activation scripts, then services: what left the config or changed is stopped, the rest started in order, then timers and path units.
  13. Remove apps that left the config. Their data is kept.
  14. Save a generation. The profile becomes the phone's, the packages it uses are kept safe from clean-ups, and if anything changed a new generation is saved.

The build ends with a notification that sums it up in one line, like:

Built generation 12
Changed: 2 apps installed, 1 updated, system settings, services
  • A step after the line fails? (a setting the phone refuses, a service that does not start.) The rest still runs, and the build is the phone's anyway: its services start at boot and its generation is kept. The run is marked failed and says why, as NixOS does when a unit fails. Building again tries again.
  • The phone restarts in the middle? The build starts again by itself after the next unlock.
  • One at a time. A second build, or an update, waits for the first or is refused with a message.
  • After a restart, unlock first. Android keeps apps' data locked until the first unlock, so a build waits for it.
  • Nothing changed? Then no new generation is made; the notification says “nothing changed”.

Nixup's view of the phone

Linux programs from Nix expect Linux folders: /nix for packages, /etc for users, DNS servers and certificates, /home, /var, /tmp. Android has none of them, and changing Android's own folders would be risky. So Nixup gives its programs a private view of the phone (a Linux “mount namespace”): the same phone, with these folders added.

In the viewIs reallyHolds
/nix/data/adb/nixup/nix/nixNixup's Nix store: every package
/etc/data/adb/nixup/etcusers, hosts, DNS, certificates, time zone, your environment.etc
/var/data/adb/nixup/varservices' data (/var/lib/<service>), kept
/run/data/adb/nixup/runsockets and runtime files, emptied at each start
/home/data/adb/nixup/usersnormal users' homes, ready before the first unlock
/tmpa new empty foldermade fresh for each program that runs

Everything else (/system, /data, /sdcard, /proc, /dev) is the real phone. Services, activation scripts, backups, the app's terminal, nixup-shell and SSH logins all run in this view. Android's apps never see it, and nothing in it leaks out.

adb shell ls /nix                       # Android itself: no such folder
adb shell su -c 'nixup-shell ls /nix'   # Nixup's view: the Nix store is there

Nixup writes its /etc from the profile at each build and each start:

  • passwd, group, shadow, subuid, subgid: the users and groups of your config.
  • hosts and resolv.conf: your networking.* options, or the DNS servers of the network in use (updated when you change networks).
  • ssl/certs/ca-certificates.crt: built from Android's trusted certificates. localtime and zoneinfo: from Android's time zone data, so programs keep the phone's time.
  • The shells' files (profile, bashrc, …), your environment.etc files, and Nixup's own folder /etc/nixup.

At boot and in the background

At each start of the phone

  1. Nixup waits until Android has finished starting.
  2. It sets again what Android forgets at a restart: adb's network port, and the settings some ROMs reset at start.
  3. It writes Nixup's /etc, empties /run, runs your activation scripts, and starts the services the last build left running. A service that fails at boot is tried again after 30 seconds, 1.5 minutes and 4 minutes; you are told only if the last try fails.
  4. It picks up a build, update, backup or restore that the restart broke off, once the phone is unlocked (and online, for a build or update).

Nothing is evaluated or downloaded at a start: the phone uses the profile it has.

In the background

WhenWhat
every 6 hoursOn Wi-Fi, with the battery not low: looks for app updates, installs those of sources set to update by themselves, tells you about the rest, and looks for a newer Nixup.
every 15 minutesWith the battery not low: picks up runs a restart broke off, then backs up what is due.
at your timesYour timers (systemd.timers, startAt), automatic upgrades, nix.gc and nix.optimise.

So the phone stays smooth

  • What the phone runs by itself (at boot, from a job or timer) runs on Android's background cores at the lowest priority. What you wait for (from the app, adb or ssh) runs a little lower than the app on screen.
  • Services run at a lower priority than the app on screen, on the background cores; serviceConfig.Nice changes their priority.
  • The phone stays awake while a build, update or backup runs (at most an hour), so a download is not cut off when the screen goes off.
  • A run keeps going if the adb or ssh session that started it is cut off (as when a build restarts Tailscale, which that session came through).
Runs that a restart broke off
  • Each build, update, backup and restore leaves a note in /data/adb/nixup/pending/ while it runs.
  • After a restart, the note starts the run again from its start (what was done goes fast: installed apps are up to date, restic skips what it has).
  • A build is picked up once, the others up to three times. A run broken off more than three days ago is let go.
  • Nixup also marks its own folder so Android's file-label check at each start skips it; otherwise every start would walk through every file of Nixup's store.

How changes are undone

Before Nixup changes something for the first time, it writes down how it was (in /data/adb/nixup/original/). That is what makes every change reversible:

# Build 1: Nixup notes the phone's font size (say 1.0), then sets 1.3
android.settings.display.fontScale = 1.3;

# Build 2: the line is gone, so the font size goes back to 1.0, and the note is dropped
  • Settings (system, app, adb) go back to their earlier value when their line leaves the config.
  • Apps Nixup installed are on its list (/data/adb/nixup/owned). An app taken out of the config is uninstalled, and its data kept, so installing it again brings it back. Apps that were never on that list are never removed.
  • Apps that came with the phone and were removed with debloat come back when the line goes.
  • Scripts can carry an undo, run once when the script leaves the config (see Activation scripts).
  • Services that leave the config are stopped and their files removed (their data in /var/lib stays).
  • DAVx⁵ accounts stay in DAVx⁵ when they leave the config (they hold the phone's copy of your calendars and contacts); DAVx⁵'s setting of what it selects for syncing goes back as it was.
  • Removing Nixup undoes all of it at once (see Uninstall).

Generations are the other way back: switching to an older generation applies its profile again (its settings, apps and services). Apps come back at their newest versions; Nixup does not downgrade apps.

Where things are kept

/data/adb/modules/nixup/       # the module: scripts, the app's APK, Nix, Nixup's options
/data/adb/nixup/               # Nixup's data, root only
├── source                     # the config the app picked
├── profile.json               # the profile in use
├── system.json                # the last build whole, with its specialisations
├── generations/<n>/           # each generation: profile, app versions, source, flake.lock, …
├── original/                  # how settings were before Nixup changed them
├── owned                      # the apps Nixup installed
├── repos/                     # app lists: repositories, releases, Google Play
├── secrets/                   # secret files, root only
├── nix/                       # Nixup's Nix store (/nix in its view)
├── etc/ var/ run/ users/      # /etc, /var, /run and /home in its view
├── home/                      # root's home in its view (~/.ssh, Nix's settings)
├── git/                       # a config cloned by the app's Git
├── units/<service>/log        # each service's files and log
├── events                     # what happened: the Logs screen reads it
├── logs/<run>.log             # each run's whole log (the newest 60)
├── apply.log                  # every run's output in one file
├── pending/                   # runs a restart broke off
└── incoming/                  # what a PC sent for a build (removed after)

Your config is not in this list: it stays wherever you keep it, and Nixup only reads it (a flake's build writes its flake.lock next to it).

Where the config lives

Nixup has no config folder of its own. You keep your config wherever you like, and the app's Config picks it. It can be any of these four:

What you pickHow it is built
a configuration.nixOn its own, with the phone's copy of nixpkgs (nixos-unstable), fetched the first time the config uses a Nix package.
a flake.nixIts folder is the flake, as /etc/nixos is for nixos-rebuild --flake. Nix writes flake.lock next to it.
a git repositoryThe app's Git clones it into /data/adb/nixup/git/ and builds the flake.nix (or configuration.nix) in it.
a flake addressLike github:you/phone: fetched again for each build, so the phone has no copy to edit.

The file can be in internal storage, in Download, or on an SD card. Any of them can end in #name to pick one configuration of a flake, like github:you/phone#work-phone.

A config as a flake

A flake pins the exact version of Nixup and nixpkgs in flake.lock, so a build today and a build next month use the same versions, until you upgrade. nix flake init -t github:by-architect/Nixup writes this for you:

{
  description = "My phone, configured by Nixup";

  inputs.nixup.url = "github:by-architect/Nixup";

  outputs = { nixup, ... }: {
    # Named after the phone (its device name)
    nixupConfigurations.phone = nixup.lib.nixupSystem {
      modules = [ ./configuration.nix ];
    };
  };
}
  • nixup.lib.nixupSystem is Nixup's nixpkgs.lib.nixosSystem. It takes modules and, like NixOS, specialArgs.
  • Which configuration is built: the one named after #; else the flake's only one; else the one named after the phone's device name, as NixOS picks by host name.
  • A flake's folder is copied whole into Nix's store, so give flake.nix a folder of its own (not Download). A folder over 50 MB is refused.
  • To use your own nixpkgs for both: inputs.nixup.inputs.nixpkgs.follows = "nixpkgs";
  • The app names in pkgs.fdroid and the other repositories always come from the phone's newest app lists; flake.lock does not pin them.
  • A flake made when Nixup was called Seraph (seraphConfigurations, lib.seraphSystem) still builds as it is.

A config in a git repository

Press Git on the Build screen and give the address: https://github.com/you/phone, github:you/phone, you/phone (GitHub), gitlab:you/phone, or any git address, with a branch, a folder in it, a configuration name and its submodules if you want them. Then:

  • Pull brings the repository's new commits (changes you made on the phone are put aside, then put back).
  • Pull and build does both in one go.
  • Reclone starts again from scratch (changes made on the phone are lost).

Git itself comes from nixpkgs (gitMinimal), downloaded once into Nixup's store. As with any flake in a git repository, only files git knows about are used: a brand-new file must be added to git first.

For a private repository, put a token in /data/adb/nixup/secrets/nix-access-tokens, one line per site:

github.com=ghp_your_token_here

Git and Nix get it for each command; it is never written anywhere else.

Careful

A clone made by the app lives in /data/adb/nixup/git/, which is deleted when Nixup is uninstalled. Push changes you made on the phone before you uninstall.

Which nixpkgs a configuration.nix gets

A configuration.nix on its own has no flake.lock, so the phone keeps its own copy of nixpkgs, like a NixOS channel: nixos-unstable, fetched the first time a config needs it, and fetched again with Upgrade. A config that imports Home Manager (<home-manager/nixos>) gets Home Manager the same way.

Using another nixpkgs release

Write the channel's address into /data/adb/nixup/channel (as root), then press Upgrade. For an automatic upgrade, system.autoUpgrade.channel does the same (see Automatic upgrades).

su -c 'echo https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz > /data/adb/nixup/channel'

Home Manager then follows it (its release-26.05 branch instead of master).

Splitting and sharing the config

A config can be split into several files with imports, as on NixOS. Each file is a module: the same { ... }: { ... } shape as the config itself.

# configuration.nix
{
  imports = [ ./apps.nix ./settings.nix ./services.nix ];
}

# apps.nix
{ pkgs, ... }: {
  android.packages.fdroid = with pkgs.fdroid; [ com.termux ];
}

One file for the phone and a NixOS machine

Wherever NixOS has an option for something, Nixup uses the same name and meaning. So a module that only uses those options works on both:

# common.nix: read by the laptop (NixOS) and the phone (Nixup)
{
  time.timeZone = "Europe/Istanbul";
  services.tailscale.enable = true;
  services.syncthing.enable = true;
  users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA… you@laptop" ];
}

And one flake can build both machines:

{
  inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  inputs.nixup.url = "github:by-architect/Nixup";
  inputs.nixup.inputs.nixpkgs.follows = "nixpkgs";

  outputs = { nixpkgs, nixup, ... }: {
    nixosConfigurations.laptop = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [ ./common.nix ./laptop.nix ];
    };
    nixupConfigurations.phone = nixup.lib.nixupSystem {
      modules = [ ./common.nix ./phone.nix ];
    };
  };
}
  • Nixup's modules are of their own kind (_class = "nixup"). A module marked _class = "nixos"; is refused by name, before its options are read.
  • Options only a phone has live under android.* (apps, settings, backups, adb). Put them in the phone's own file.
  • An option that moved still works under its old name, and the build says the new name (like android.services.tailscale, now services.tailscale).

Apps from repositories

F-Droid and every F-Droid-style repository (IzzyOnDroid, Guardian Project, a developer's own) work the same way: name the repository, then list its apps by package name.

{ pkgs, ... }: {
  # f-droid.org is always there, under the name "fdroid"
  android.packages.fdroid = with pkgs.fdroid; [
    com.termux
  ];

  # Another repository: a name you choose, and its address with its key (fingerprint)
  android.repos.izzyondroid.url =
    "https://apt.izzysoft.de/fdroid/repo?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A";

  android.packages.izzyondroid = with pkgs.izzyondroid; [
    at.bitfire.davdroid                 # DAVx5
    "in.sunilpaulmathew.izzyondroid"    # starts with "in", a Nix word, so it is in quotes
  ];
}

The key can also be given apart from the address:

android.repos.guardianproject = {
  url = "https://guardianproject.info/fdroid/repo";
  fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135";
};

How it works

  • Each repository's app list is downloaded and checked: it must be signed with the key you gave, and it may not be older than the list the phone already has, so nobody can slip in a fake list or roll it back to old versions.
  • The list becomes Nix names: pkgs.izzyondroid.at.bitfire.davdroid. A typo is not a name, so the build stops before anything changes, and says so.
  • Nixup installs the newest stable version that fits the phone's Android version and processor (no betas). The download is checked against the checksum and signing key in the signed list.
  • An app already on the phone must be signed with the key of the repository's builds. If it is not (say, it came from Google Play), the build stops and says so: uninstall it first, or keep it from its own source.
  • App lists are refreshed at most once a day by a build, and always by Upgrade and the update check. Offline, the last list is used.
  • An app you take out of the config is uninstalled, its data kept. Only apps that Nixup itself installed are ever removed: an app that was on the phone before (even if you list it for a while) stays.
  • App stores on your lists (the F-Droid app, Obtainium, Aurora Store) are installed before the other apps. When the F-Droid app is on the phone, apps from repositories are recorded as installed by it.
Rules for names
  • A repository's name starts with a small letter and has only letters and digits (izzyondroid, myRepo). It may not be a nixpkgs package's name, nor obtainium, playstore or pkgs.
  • Addresses must use https; the fingerprint is the repository key's SHA-256 (64 hex digits).
  • android.packages is for Android apps. Linux programs from nixpkgs go in environment.systemPackages.

Apps from developers' releases

Many apps are published only as releases on GitHub, GitLab, Codeberg or another Forgejo or Gitea server. Nixup installs and updates them the way Obtainium does:

{
  android.packages.obtainium = [
    {
      id = "dev.imranr.obtainium";                    # the app's package name
      url = "https://github.com/ImranR98/Obtainium";   # where its releases are
      apkFilter = "^(?!.*fdroid)";                    # optional: which APK (a pattern on its file name)
      signer = "B3:53:60:1F:6A:1D:5F:D6:60:3A:E2:F5:0B:E8:0C:F3:01:36:7B:86:B6:AB:8B:1F:66:24:3D:A9:6C:D5:73:62";
      prerelease = false;                             # true: pre-releases too
    }
  ];
}
  • Nixup reads the newest 15 releases (at most once a day, unless you upgrade), takes the newest one with a matching APK, and among its APKs picks the one for the phone's processor, then a universal one. If several still fit, the build asks you to set apkFilter.
  • Nothing signs a release list, so pin the app's key with signer (the SHA-256 of its signing certificate, with or without colons). Without it, Nixup accepts the installed app's key, or for a first install the key the APK brings, and prints that key in the log so you can pin it.
  • The APK's package name must be id, and GitHub's SHA-256 is checked when GitHub lists one.
  • If Obtainium itself is installed and was opened once, Nixup adds these apps to Obtainium's list (for github.com, gitlab.com and codeberg.org), and later removes only the entries it added.

To read an APK's key yourself, on a PC with the Android build tools:

apksigner verify --print-certs app.apk    # the line "certificate SHA-256 digest" is the signer

Google Play

Apps from Google Play are downloaded by the phone itself, the way Aurora Store does, then installed and updated like the other apps. No Play Store, Google Play services or GApps are needed on the phone; a Google account is.

{
  android.packages.playstore = [ "com.whatsapp" "org.telegram.messenger" ];   # package names, in quotes

  android.playstore.account = "you@gmail.com";       # optional: a build stops if another account is signed in
  android.playstore.tokenFile = ./google-play.token; # optional: the account's token, so the phone needs no sign-in

  android.settings.repo.playstore.autoupdatePackages = true;
}

Signing in

On the phone: Nixup app → ⋮ → Google Play account → Sign in. Google's own sign-in page opens in a private window (its cookies are wiped before and after). Then Show token shows the account's token, with Copy and Save to Downloads, and the two config lines to use it.

On a PC:

nix run github:by-architect/Nixup#nixup-play-login            # a sign-in window opens; saves ./google-play.token
nix run github:by-architect/Nixup#nixup-play-login -- --paste # no window: sign in in your browser, paste the token

With --paste (or when no window can open), the sign-in page opens in your browser, and you copy the oauth_token cookie (it starts with oauth2_4/) from the browser's developer tools. Either way, the file is readable only by you, and the command prints the two lines for your config.

How it works

  • A build that lists Google Play apps checks the account first. With no account, or a token Google refuses, it stops before changing anything and sends a “Sign in to Google Play” notification.
  • The phone tells Google Play what it is (Android version, processor, screen, languages), so it gets the split APKs made for it. Each file is checked against the SHA-256 Google Play lists, and its signing key against Google Play's.
  • Libraries an app needs from Google Play (like Trichrome) are installed first, by themselves.
  • Apps are recorded as installed by the Play Store if it is on the phone, else by Aurora Store.
  • The token stays on the phone, readable by root only (/data/adb/nixup/secrets/google-play); signing out removes it.
Before you use it
  • Use a Google account made for this, not your main one: Google may block accounts that download with unofficial clients (Aurora Store's FAQ warns about the same).
  • Anyone with the token can use the account. Keep copies out of any repository others can read.
  • Not supported yet: apps that need expansion files (OBB, mostly large games). Also refused: paid apps the account has not bought, and apps Google Play does not offer in your country or for your device. Such an app stops the build with a message.
  • An app installed from F-Droid (signed with another key) cannot be updated from Google Play; the build says so.
  • If the sign-in page says there is no internet while the phone is online, the Nixup app's network access is off (some ROMs switch it per app): turn it on in its App info.

Keeping apps up to date

A build always installs the newest version of every listed app. Between builds, each source can update by itself, or only tell you:

{
  android.settings.repo = {
    fdroid.autoupdatePackages = true;        # install updates by themselves
    izzyondroid.autoupdatePackages = false;  # only tell me (the default)
    obtainium.autoupdatePackages = true;     # apps from releases
    playstore.autoupdatePackages = false;    # apps from Google Play
  };
}
  • Every 6 hours, on Wi-Fi, the phone refreshes the app lists, installs the updates of sources set to true, and sends a notification about the others.
  • The Updates screen lists what is newer (Check for updates), with Update per app and Update all.
  • An update between builds saves a new generation, which keeps the versions the apps are at.
  • Nixup checks for a newer Nixup at the same time (see Updating Nixup).
adb shell su -c "sh /data/adb/modules/nixup/update.sh adb check"          # list updates
adb shell su -c "sh /data/adb/modules/nixup/update.sh adb now"            # install all of them
adb shell su -c "sh /data/adb/modules/nixup/update.sh adb now com.termux" # or only these

App settings

Settings for single apps, by package name. You can still change them by hand, but the next build sets them back to the config.

{
  android.apps."org.thoughtcrime.securesms" = {
    permissions = {
      camera = true;              # true: allowed, false: denied, "ask": asks each time
      microphone = "ask";
      location = false;           # "always", "whileUsing", false, "ask"
    };
    battery = "unrestricted";     # "unrestricted", "optimized", "restricted"
    network.backgroundData = true;
  };

  # A default app: "browser", "dialer", "sms", "home", "assistant", "callScreening"
  android.apps."org.mozilla.fennec_fdroid".defaultFor = [ "browser" ];

  # Special access, and the app's own language
  android.apps."com.termux" = {
    access.displayOverOtherApps = true;
    language = "en-US";           # "system": the phone's language
  };

  # Remove an app the phone came with (its data is kept; it comes back when the line goes)
  android.apps."org.lineageos.etar".debloat = true;

  # Disable one (hidden, cannot run)
  android.apps."com.android.contacts".enabled = false;
}

Permissions

ValueMeans
trueAllowed.
falseDenied, and the app may not ask again.
"ask"The app asks the next time it needs it.
nullNot managed (the default).

The groups: camera, microphone, location, preciseLocation, contacts, calendar, phone, callLogs, sms, nearbyDevices, physicalActivity, bodySensors, notifications, photosAndVideos, musicAndAudio, files and mediaLocation. location also takes "always" (in the background too) and "whileUsing" (the same as true); preciseLocation = false leaves the app only your approximate place. Only permissions the app asks for are changed.

Every app setting, on one app
{
  android.apps."com.example.app" = {
    permissions = {
      camera = false;                             # true: allowed, false: denied, "ask"
      microphone = "ask";
      location = "whileUsing";                    # "always", "whileUsing", false, "ask"
      preciseLocation = false;                    # false: approximate only
      contacts = true;
      calendar = false;
      phone = "ask";                              # calls, phone state
      callLogs = false;
      sms = false;
      nearbyDevices = true;                       # Bluetooth, Wi-Fi devices nearby
      physicalActivity = false;
      bodySensors = false;                        # heart rate, health sensors
      notifications = true;
      photosAndVideos = false;
      musicAndAudio = null;
      files = false;                              # shared storage (apps for Android 12 and older)
      mediaLocation = false;                      # places saved in photos
    };
    battery = "unrestricted";                     # "unrestricted", "optimized", "restricted"
    network = {
      backgroundData = false;                     # mobile data in the background
      unrestrictedData = true;                    # mobile data even with Data Saver on
      wifi = true;                                # network over Wi-Fi (LineageOS)
      mobile = false;                             # network over mobile data (LineageOS)
      vpn = false;                                # network over a VPN (LineageOS)
    };
    access = {
      displayOverOtherApps = true;                # draw over other apps
      modifySystemSettings = false;
      installUnknownApps = false;                 # install APKs
      allFilesAccess = false;
      usageAccess = false;                        # see which apps are used
      alarmsAndReminders = null;                  # exact alarms
      pictureInPicture = false;
      mediaManagement = false;                    # change media files without asking
      fullScreenNotifications = false;
      turnScreenOn = false;
      wifiControl = false;
      restrictedSettings = true;                  # restricted settings allowed (sideloaded apps)
      notificationAccess = true;                  # read all notifications
      accessibility = true;                       # control the screen
    };
    pauseIfUnused = false;                        # pause it when unused for months
    enabled = true;                               # false: disabled and hidden
    paused = false;                               # true: paused, cannot be opened
    debloat = false;                              # true: remove an app the phone came with (data kept)
    openLinks = null;                             # open its web links in it
    language = "tr-TR";                           # its own language (Android 13+); "system": the phone's
    defaultFor = [ ];                             # "browser", "dialer", "sms", "home", "assistant", "callScreening"
    backup = null;                                # see Backups
  };
}

Good to know

  • An app that is not installed is skipped and told; its settings are applied right after a build installs it.
  • The value before Nixup's first change is kept, and comes back when the setting leaves the config, or the app leaves android.apps.
  • A setting the app does not ask for (say, overlay access) is skipped with a warning. One this Android does not have stops the build with a message.
  • Each role (defaultFor) can have only one app. language needs Android 13 or newer.
  • enabled = false disables an app (hidden, cannot run); paused = true pauses it (it stays, grayed out, and cannot be opened).
  • debloat is refused for parts of Android (System UI, Settings, the phone app, the package installer, WebView, …), for the launcher and keyboard in use (unless the config gives defaultFor = [ "home" ] to another app), and for Nixup. An app cannot be debloated and listed in android.packages at once.

System settings

Almost every important Android setting has an option, grouped under android.settings. Three of them also have NixOS's names, so a module shared with a NixOS machine sets them on both.

{
  # With NixOS's names
  networking.hostName = "phone";                 # device name (also its Bluetooth and tailnet name)
  time.timeZone = "Europe/Istanbul";             # null: from the network
  i18n.defaultLocale = "en_US.UTF-8";            # the phone's language: "en-US"

  # Android's own groups
  android.settings.display = {
    darkMode = true;
    fontScale = 1.1;                             # 1.0 is normal
    screenTimeout = 60;                          # seconds
  };
  android.settings.navigation.mode = "gestures"; # "threeButton", "twoButton"
  android.settings.sound.touchSounds = false;
  android.settings.time.format24h = true;
}

How settings are applied

  • Each setting is read first and changed only if it differs. A moment later it is read again; one the phone put back counts as failed.
  • A setting the phone cannot have (it needs a newer Android, LineageOS, a sensor, a screen with more than one refresh rate, …) is skipped with a warning, so one config works on several phones. ⋮ → What this phone supports lists them.
  • The value from before Nixup's first change is kept, and comes back when the line goes, along with the values that change with it (the Bluetooth name with the device name, for example).
  • Settings that some ROMs reset at each start are set again at boot.
  • Most settings are Android's settings tables; a few use Android's own commands: dark mode (cmd uimode), display size (wm density), navigation mode, language, time zone, keyboard, Data Saver and location.

NixOS's names and Android's names

NixOS optionBecomesExample
networking.hostNameandroid.settings.deviceName"phone"
time.timeZoneandroid.settings.time.timeZone (and automatic time zone off)"Europe/Istanbul"
i18n.defaultLocaleandroid.settings.language"tr_TR.UTF-8" → "tr-TR"

When both are set, Android's name wins. Android's names can also do more: android.settings.deviceName takes any text (like "Neo's phone"), and android.settings.language = [ "en-US" "tr-TR" ]; sets several languages in order.

Every system setting, with its values
{
  android.settings.display = {
    darkMode = true;                              # dark theme
    fontScale = 1.15;                             # font size: 1.0 normal
    boldText = true;                              # bold text everywhere
    density = 320;                                # display size in dpi: smaller fits more
    screenTimeout = 30;                           # seconds until the screen goes off
    autoRotate = false;                           # turn with the phone
    autoBrightness = true;                        # brightness from the light around
    peakRefreshRate = null;                       # highest Hz (unset: the screen's highest)
    minRefreshRate = 120;                         # lowest Hz: 120 always smooth
    colorMode = "natural";                        # "natural", "boosted", "saturated", "adaptive"
    alwaysOn = null;                              # always-on display
    wakeForNotifications = false;                 # screen on for new notifications
    doubleTapToWake = false;                      # double-tap the screen to wake
    liftToWake = null;                            # wake when picked up
    nightLight = {                                # warmer screen at night
      schedule = null;                            # "none", "sunsetToSunrise", "custom"
      from = null;                                # "22:00", with schedule = "custom"
      to = null;                                  # "06:30"
      temperature = null;                         # kelvin: lower is warmer
    };
    screenSaver = { enable = false; whileCharging = true; whileDocked = false; };
  };

  android.settings.navigation.mode = "gestures";  # "gestures", "threeButton", "twoButton"

  android.settings.sound = {
    touchSounds = false;                          # tap sounds
    touchVibration = true;                        # vibrate on taps
    dialpadTones = true;
    screenLockSound = true;                       # sound on lock and unlock
    chargingSound = true;
    chargingVibration = true;
    vibrateForCalls = true;
    rampingRinger = false;                        # vibrate first, then ring louder
    keyboardVibration = true;
  };

  android.settings.time = {
    automatic = true;                             # time from the network
    automaticTimeZone = true;                     # time zone from the network
    format24h = true;                             # 24-hour clock
    # timeZone = "Europe/Istanbul";               # turns automaticTimeZone off
  };

  android.settings.keyboard = {
    default = "com.android.inputmethod.latin/.LatinIME";   # keyboard in use (`ime list -a -s` lists them)
    showWithPhysicalKeyboard = false;             # on-screen keyboard with a real one
    spellChecker = null;
  };

  android.settings.network = {
    privateDns = null;                            # "off", "automatic", or a host like "dns.quad9.net"
    dataSaver = false;                            # less background data (cuts VPN apps too)
    wifiScanning = false;                         # Wi-Fi scans for location, even when off
    bluetoothScanning = null;                     # the same for Bluetooth
    captivePortalCheck = null;                    # look for sign-in pages (asks a server)
    openWifiNotification = true;                  # tell about open networks
    wifiWakeup = true;                            # Wi-Fi on near saved networks
  };

  android.settings.location = {
    enable = false;                               # location on or off
    assistedGps = true;                           # faster GPS with network help
  };

  android.settings.lockScreen = {
    notifications = false;                        # notifications on the lock screen
    sensitiveNotifications = true;                # what they say, too
    silentNotifications = false;
    lockAfter = null;                             # seconds after the screen goes off
    wallet = true;
    deviceControls = false;                       # smart home controls
    qrCodeScanner = false;
  };

  android.settings.notifications = {
    dots = true;                                  # dots on app icons
    bubbles = true;                               # floating chat bubbles
    snooze = null;
    history = true;                               # keep a history
    popUp = false;                                # pop up on the screen (heads-up)
    light = false;                                # notification light
    flash.camera = false;                         # flash the camera light
    flash.screen = false;                         # flash the screen
    mediaResume = null;                           # keep the media player after the app closes
    mediaOnLockScreen = null;                     # media player on the lock screen
  };

  android.settings.gestures = {
    oneHandedMode = false;                        # bring the top of the screen down
    swipeDownForNotifications = false;            # swipe down at the bottom for notifications
    holdPower = "assistant";                      # "assistant" or "powerMenu"
    powerAndVolumeUp = "powerMenu";               # "nothing", "mute", "powerMenu"
    preventRinging = "vibrate";                   # "off", "vibrate", "mute"
  };

  android.settings.power = {
    batterySaverAt = null;                        # battery saver at this percent (0: never)
    adaptiveBattery = null;                       # limit apps not used often
  };

  android.settings.developer = {
    enable = true;                                # Developer options shown
    usbDebugging = true;                          # adb (false cuts it, also for nixup-rebuild)
    stayAwake = false;                            # screen on while charging
    animationScale = 1;                           # 0.5: twice as fast, 0: off
    showTaps = false;
    pointerLocation = null;                       # touch position over the screen
    windowBlurs = false;                          # blur behind panels
    mobileDataAlwaysOn = true;                    # mobile data ready while on Wi-Fi
    verifyAdbApps = false;                        # check apps installed over adb
    freeformWindows = null;                       # floating windows
    forceResizable = null;                        # every app resizable
  };

  android.settings.accessibility = {
    colorInversion = null;
    highContrastText = false;
    colorCorrection = null;                       # "off", "deuteranomaly", "protanomaly", "tritanomaly", "grayscale"
    magnification = false;                        # triple-tap to zoom
    touchAndHoldDelay = 400;                      # long press in ms: 400 short, 1000 medium, 1500 long
    monoAudio = null;
    audioBalance = null;                          # -1 left, 0 middle, 1 right
  };

  android.settings.privacy.showPasswords = null;  # show a password's last letter while typing
  android.settings.autofill.service = "com.x8bit.bitwarden/com.x8bit.bitwarden.Autofill.AutofillService";

  android.settings.quickSettings.tiles = [        # Quick Settings tiles, in order
    "internet" "bt" "flashlight" "dnd" "dark" "rotation"
    "custom(dev.theolm.wwc/.quicktile.ChatTile)"  # an app's own tile
  ];

  android.settings.statusBar.hiddenIcons = [ "rotate" ];   # status bar icons hidden
}

Any other setting

A setting without its own option can still be set by Android's own name: the name adb shell settings put takes. Android keeps settings in three tables (system, secure, global); LineageOS adds three of its own.

{
  android.settings.raw = {
    global.stay_on_while_plugged_in = 0;
    system.screen_off_timeout = 60000;           # milliseconds
    secure.ui_night_mode = 2;
    lineage.system.status_bar_clock = 2;         # LineageOS's own tables
  };
}
  • true and false are written as 1 and 0.
  • To find a name, change the setting by hand and compare adb shell settings list global (or system, secure) before and after.
  • Like every other setting, it is put back as it was when its line goes.

LineageOS settings

ROMs based on LineageOS (crDroid, …) have settings of their own, under android.settings.lineage. On another ROM they are skipped, so the same config still builds.

{
  android.settings.lineage.statusBar = {
    clockPosition = "left";                       # "right", "center", "left"
    batteryStyle = "icon";                        # "icon", "circle", "dottedCircle", "text"
    batteryPercent = "inside";                    # "hidden", "inside", "nextTo"
    doubleTapToSleep = true;                      # double-tap the status bar: screen off
  };
  android.settings.lineage.chargingControl = {
    enable = true;                                # charge less, or later, to spare the battery
    mode = "limit";                               # "auto" (full by the alarm), "custom", "limit"
    limit = 80;                                   # stop at 80%
  };
}
Every LineageOS setting
{
  android.settings.lineage.statusBar = {
    clockPosition = "left";                       # "right", "center", "left"
    clockAutoHide = false;                        # hide the clock on the home screen
    amPm = "hidden";                              # "normal", "small", "hidden"
    batteryStyle = "icon";                        # "icon", "circle", "dottedCircle", "text"
    batteryPercent = "inside";                    # "hidden", "inside", "nextTo"
    doubleTapToSleep = true;                      # double-tap the status bar: screen off
  };

  android.settings.lineage.quickSettings = {
    quickPulldown = "off";                        # one-swipe Quick Settings at an edge: "off", "right", "left"
    autoBrightnessButton = true;                  # auto-brightness button by the slider
  };

  android.settings.lineage.buttons = {
    volumeWake = false;                           # volume buttons wake the phone
    volumeAnswerCall = false;                     # volume buttons answer calls
    volumeMusicControls = true;                   # hold volume: next or last track
    swapVolumeOnRotation = false;                 # volume up stays up when turned
    torchLongPressPower = false;                  # hold power: flashlight
    torchTimeout = null;                          # flashlight off after seconds (0: never)
    partialScreenshot = true;                     # power + volume down: part of the screen
  };

  android.settings.lineage.sound = {
    increasingRing = false;                       # ringtone starts quiet
    volumePanelOnLeft = true;                     # volume panel on the left
  };

  android.settings.lineage.navigation.gestureHint = true;   # the gesture bar at the bottom

  android.settings.lineage.display = {
    proximityOnWake = null;                       # no waking in a pocket
    pureBlackDarkTheme = false;                   # black instead of dark gray
  };

  android.settings.lineage.lockScreen = {
    rotation = false;                             # lock screen turns with the phone
    scramblePin = null;                           # PIN digits in a new order each time
    mediaVisualizer = false;                      # music visualizer
  };

  android.settings.lineage.powerMenu = {
    actions = [ "emergency" "lockdown" "power" "restart" "screenshot" "bugreport" "logout" ];
    advancedRestart = true;                       # restart into recovery or bootloader
  };

  android.settings.lineage.chargingControl = {
    enable = false;                               # charge less, or later, to spare the battery
    mode = "auto";                                # "auto" (full by the alarm), "custom", "limit"
    limit = 80;                                   # stop at percent, with mode = "limit"
    start = "22:00";                              # with mode = "custom": from
    target = "06:00";                             # full by
  };

  android.settings.lineage.wakeOnPlug = false;     # screen on when the charger goes in or out
  android.settings.lineage.statsCollection = false;   # anonymous statistics to LineageOS
}

Packages and the terminal

Linux programs from nixpkgs, for the phone's shell and services, as NixOS's environment.systemPackages:

{ pkgs, ... }: {
  environment.systemPackages = with pkgs; [ htop rsync git vim python3 ];

  # The terminal's look (Nix-on-Droid's options)
  terminal.font = "${pkgs.jetbrains-mono}/share/fonts/truetype/JetBrainsMono-Regular.ttf";
  terminal.colors = { background = "#000000"; foreground = "#FFFFFF"; cursor = "#FFFFFF"; };
}

Three ways in, all to the same root shell, with these programs first on its PATH:

  • The app's terminal: the icon in Nixup's top bar. It is built into the app; no Termux needed.
  • From adb or any root shell: su -c nixup-shell, or su -c 'nixup-shell htop' for one command.
  • Over SSH, with services.openssh.
adb shell su -c nixup-shell     # a root shell in Nixup's view
htop                            # a program from environment.systemPackages
ls /sdcard/Download             # Android's folders are there as they are
pm list packages                # and so are Android's own commands
  • Packages come ready-made from cache.nixos.org; nothing is compiled on the phone.
  • They are for the shell and services, not phone apps (apps come from repositories).
  • The shell runs in Nixup's view. Root's home there is /data/adb/nixup/home. Android's commands (/system/bin) come after your packages on PATH.
  • With no Nix packages at all in the config, the shell is Android's own sh. Before the first build, nixup-shell opens Android's shell and says so.

The terminal

  • Each session is a tab; + opens another. Sessions keep running when you close the terminal, with a notification that has an Exit button.
  • A row of extra keys: ESC, TAB, CTRL, ALT, the arrows, HOME, END, PGUP, PGDN, and - / | ~ : * &.
  • Tap for the keyboard, long-press to select, copy and paste, pinch to change the text size.
  • terminal.font must be a file from a Nix package (a file of your own is refused). terminal.colors takes background, foreground, cursor and color0 to color15.

Shells and programs

The shells are set up exactly as on NixOS, with nixpkgs' own modules:

{ pkgs, ... }: {
  environment.shellAliases = { ll = "ls -l"; gs = "git status"; };
  environment.variables = { EDITOR = "vim"; };
  environment.interactiveShellInit = "set -o vi";

  programs.zsh.enable = true;
  users.defaultUserShell = pkgs.zsh;     # the shell of root and the normal users (not set: bash)

  programs.git = { enable = true; config.init.defaultBranch = "main"; };
  programs.tmux.enable = true;
  programs.starship.enable = true;
  programs.direnv.enable = true;
}
  • The shells' files are NixOS's own (/etc/profile, /etc/bashrc, /etc/zshrc, /etc/fish, …), in Nixup's /etc.
  • All of this needs Nix packages in the config. Without any, the shell is Android's sh, which still gets environment.variables and environment.extraInit.
  • Also there: environment.sessionVariables, shellInit, loginShellInit, extraInit, localBinInPath and homeBinInPath.
  • A user whose shell is zsh, fish or xonsh needs programs.<shell>.enable.

Programs

These programs.* are nixpkgs' own modules, with all their options: bash, zsh, fish, xonsh, less, autoenv, autojump, bandwhich, bat, command-not-found, direnv, fzf, git, htop, iftop, lazygit, mosh, mtr, nano, neovim, nix-index, nushell, pay-respects, proxychains, screen, skim, ssh, starship, tcpdump, television, tmux, traceroute, trippy, vim, vivid, whois, yazi and zoxide.

  • What NixOS turns on for every machine (nano, command-not-found) is off on a phone, to save downloads. Turn it on if you want it.
  • Programs NixOS gives special rights to (mtr, tcpdump, iftop) work for root only: the phone makes no setuid wrappers.
  • Every Nix shell has environment.corePackages: bash, coreutils, findutils, grep, sed and less. environment.defaultPackages is empty on a phone (NixOS's has perl, rsync and strace).
  • Another of nixpkgs' program modules can be added by hand: imports = [ "${modulesPath}/programs/foo.nix" ];.

Users

On Android, Linux user numbers (uids) belong to apps: each app has its own, and there is no list of people who log in. Nixup's users live in its view only (its /etc/passwd). Root is the main one: the terminal, adb and SSH all log in as root. You can add normal users, mostly to log in over SSH:

{ pkgs, ... }: {
  users.users.niobe = {
    isNormalUser = true;
    androidApp = "dev.nixup.manager";   # take this Android app's uid (see below)
    description = "Niobe";
    extraGroups = [ "inet" "everybody" "external_storage" ];   # network and shared storage
    packages = with pkgs; [ ripgrep ];  # the user's own packages, first on its PATH
    openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA… niobe@laptop" ];
    hashedPassword = "$6$…";            # from mkpasswd, for SSH logins with a password
  };
}

Which uid a user gets

  • androidApp = "<package>"; the user takes that app's uid (looked up at each build), and with it that app's files, storage access, network and root grant. With "dev.nixup.manager" (Nixup itself) the user can use su as Nixup does, with no other app needed.
  • uid = 10355; a number of your choosing (not both).
  • Neither: a uid from 400 to 999, which the phone keeps for that user. Android lets these uids on the network. (NixOS would give 1000 and up.)
Note

A user with androidApp has that app's network, which Android cuts while the app is in the background: nix-shell -p and nix-env -iA nixpkgs.… work anyway (the Nix daemon downloads), but nix shell nixpkgs#… then fails, as it first fetches Nix's registry itself.

Homes, groups and passwords

  • A normal user's home is /home/<name> (users.defaultUserHome), ready right after the phone starts, before the first unlock.
  • Groups have Android's numbers: inet (network), everybody, external_storage, sdcard_rw and media_rw (shared storage), audio, video, input, shell, net_raw, net_admin, readproc, wakelock. NixOS's wheel and users exist but give nothing on a phone.
  • Passwords as on NixOS: hashedPassword, password (hashed on the phone, in the profile that only root reads), hashedPasswordFile (a secret file), initialPassword and initialHashedPassword. expires = "2027-01-31"; ends logins from that day.
  • Also: enable = false (left out), shell, linger = false with users.manageLingering = true (the user's own services do not run), subUidRanges, subGidRanges and autoSubUidGidRange (/etc/subuid, /etc/subgid).
  • Root's SSH keys: users.users.root.openssh.authorizedKeys.keys.
  • Not on the phone: cryptHomeLuks and openssh.authorizedPrincipals (both stop the build).

Home Manager

Home Manager works as on NixOS, through its own NixOS module, with all of its options: programs.*, services.*, home.file, xdg.*, home.packages, home.activation. One module file can set up a user on a NixOS machine and on the phone.

{ pkgs, ... }: {
  # In a flake:  inputs.home-manager.url = "github:nix-community/home-manager";
  #              modules = [ home-manager.nixosModules.home-manager ./configuration.nix ];
  # A configuration.nix alone: the phone fetches Home Manager, as it fetches nixpkgs
  imports = [ <home-manager/nixos> ];

  users.users.niobe = { isNormalUser = true; androidApp = "dev.nixup.manager"; };

  home-manager.useGlobalPkgs = true;            # the phone's pkgs (one nixpkgs, not two)
  home-manager.backupFileExtension = "backup";  # a file of yours in the way is moved aside, not refused
  home-manager.users.niobe = { pkgs, ... }: {
    home.stateVersion = "26.05";
    home.packages = [ pkgs.ripgrep ];
    programs.git = { enable = true; settings.user.name = "Niobe"; };
    programs.bash.enable = true;
    services.gpg-agent = { enable = true; enableSshSupport = true; };
  };
}
  • Each user's Home Manager runs as that user at each build that changes it and at each start of the phone. It links the user's files into the home and runs each module's steps (onChange, home.activation). The Services screen shows it as home-manager-<name>, with its log.
  • The services Home Manager makes (every services.* module's, and your own systemd.user.services) run as the user's own, named user-<name>-<service>, after its files are linked.
  • Its packages go into the user's profile (/etc/profiles/per-user/<name>), whatever useUserPackages says.
  • Each home-manager.users.<name> needs a users.users.<name>. Taking a user out of home-manager.users takes Home Manager's links out of its home again.
  • To save downloads, some defaults differ on a phone: no manual pages, no desktop file types (xdg.mime), and only the UTF-8 locales. Each can be turned on again.
  • Not on the phone: a desktop session (what only graphical-session.target starts is not started, and the build says so), a D-Bus session bus (services that need one fail), and startAsUserService.
  • Home Manager's files are links into /nix/store, so they exist in Nixup's view (SSH, the terminal, services), not for Android's apps.

Nix on the phone

Nix itself is in the shells, as on NixOS, for programs you want without changing the config:

nix shell nixpkgs#htop          # a shell with htop (nix run nixpkgs#cowsay -- hi runs one program)
nix profile add nixpkgs#htop    # installed for you, on PATH from then on (or: nix-env -iA nixpkgs.htop)
nix-shell -p htop               # a shell with nixpkgs' build tools too (a big download the first time)
nix-collect-garbage -d          # frees up space; the config's own packages stay
  • nixpkgs there is your config's own nixpkgs (in Nix's registry and as <nixpkgs>, as on a NixOS built from a flake), so nothing fetches nixpkgs again. The phone keeps it; a build from a PC sends it once.
  • The commands are the module's own Nix, so nothing is downloaded for them, unless you set nix.package.
  • Builds run as the nixbld users (uids 400 to 999), without Nix's sandbox, which Android's kernels cannot provide. Other users (isNormalUser) use Nix through its daemon, which runs only when there are such users.

Nix's settings

{
  nix.settings = {                                # /etc/nix/nix.conf (flakes are on)
    substituters = [ "https://nix-community.cachix.org" ];   # more binary caches (cache.nixos.org is always there)
    trusted-public-keys = [ "nix-community.cachix.org-1:mB9F…" ];
  };
  nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 30d"; };
  nix.optimise.automatic = true;                  # store files kept twice become one, at night
  nix.registry = { };                             # flakes by name; nixpkgs is the config's
  # nix.enable = false;                           # no Nix in the shells at all
}

Freeing up space

⋮ → Free up space removes the Nix packages the last build does not use (older generations' packages, a service you turned off). Going back to an older generation fetches its packages again. The packages in use, the phone's nixpkgs, git, and what users installed with nix profile or nix-env stay.

Packages that cache.nixos.org does not have

The phone never compiles a package. One that cache.nixos.org does not have (changed by an overlay, or unfree, which the cache does not build) stops the build, unless you give machines that build it:

{
  nixpkgs.config.allowUnfree = true;
  nix.distributedBuilds = true;
  nix.buildMachines = [
    {
      hostName = "builder.example.org";           # its address (a tailnet name works)
      sshUser = "builder";                        # one of its nix.settings.trusted-users
      sshKey = "/data/adb/nixup/secrets/builder_ed25519";   # a key on the phone, readable by root
      systems = [ "aarch64-linux" ];              # a PC builds it with binfmt emulation
      publicHostKey = "c3NoLWVkMjU1MTkg…";        # base64 -w0 /etc/ssh/ssh_host_ed25519_key.pub on it
      maxJobs = 4;
    }
  ];
}

The small files a config makes (a service's settings file, a script) are not packages: the phone builds those itself, from what the cache has.

Your own services

Android has no systemd. Nixup runs NixOS-style systemd.services itself, with the same syntax: each service gets a small supervisor of its own that starts it, watches it, and starts it again when it should.

{ pkgs, ... }: {
  # A service that keeps running
  systemd.services.web-share = {
    description = "Download folder as a web page";
    wantedBy = [ "multi-user.target" ];          # start at boot and with each build
    after = [ "tailscaled.service" ];            # start after these
    path = [ pkgs.darkhttpd ];                   # programs on its PATH
    serviceConfig = {
      ExecStart = "${pkgs.darkhttpd}/bin/darkhttpd /storage/emulated/0/Download --addr 127.0.0.1 --port 8080";
      Restart = "on-failure";                    # "no", "on-failure", "always"
      RestartSec = 5;
    };
  };

  # A one-time job (a script): runs to its end at each build and boot
  systemd.services.say-hello = {
    wantedBy = [ "multi-user.target" ];
    serviceConfig.Type = "oneshot";
    script = ''
      echo "built at $(date)" > /var/lib/hello.txt
    '';
  };
}

How services run

  • When: a service wantedBy multi-user.target (or default.target, network.target and the other usual targets) starts at each boot and with each build.
  • Order: after and before set the order; wants and requires bring other services along. A service whose required service failed is not started.
  • On a build, a service is restarted only if it changed: its settings, its scripts, a secret it uses, or a value in restartTriggers. A one-time job runs again only if it changed or did not succeed last time.
  • Restarts: Restart says when (RestartSec waits first). There is no limit on how often: a failing service keeps being started again.
  • Stopping: ExecStop runs, then every process of the service gets a stop signal (KillMode, KillSignal), and after TimeoutStopSec (10 seconds) a kill.
  • Where: each service runs in its own copy of Nixup's view, with its own /tmp. Its processes are known by that view (there are no cgroups on the phone).
  • Environment: a clean one. PATH is the service's path, then coreutils, findutils, grep and sed, then Android's /system/bin. Scripts run with bash. The time zone is the phone's.
  • Control: the app's Services screen starts, stops and restarts them and shows their logs. That lasts until the next build or restart.

Timers

Jobs at set times, as on NixOS. The short way is startAt on a service:

{
  systemd.services.photos-copy = {
    startAt = "*-*-* 03:00:00";                  # also "daily", "Mon..Fri 07:30", "*:0/15"
    serviceConfig.Type = "oneshot";
    script = "cp -ru /storage/emulated/0/DCIM /storage/1234-5678/Photos";
  };
}

The long way is a timer of its own, for times counted from the start or from the last run:

{
  systemd.timers.cleanup = {
    wantedBy = [ "timers.target" ];
    timerConfig = {
      OnBootSec = "5min";                        # 5 minutes after each start of the phone
      OnUnitActiveSec = "1h";                    # then every hour
      Persistent = true;                         # a time missed while the phone was off comes when it is on
      RandomizedDelaySec = "10min";              # a random wait, up to 10 minutes
    };
  };
  systemd.services.cleanup = {
    serviceConfig.Type = "oneshot";
    script = "find /var/tmp -type f -mtime +7 -delete";
  };
}
  • timerConfig takes OnCalendar (systemd's calendar events, in the phone's time zone), OnBootSec, OnStartupSec, OnActiveSec, OnUnitActiveSec, OnUnitInactiveSec, Persistent, RandomizedDelaySec, FixedRandomDelay and Unit.
  • A sleeping phone runs nothing. Nixup does not wake the phone for a timer: a time that passed while it slept comes within about 30 seconds of waking.
  • The Services screen shows when each timer runs next.
  • AccuracySec, WakeSystem and the like are accepted and do nothing. Calendar events cannot count back from a month's end (~).

Sockets, templates and path units

{ pkgs, ... }: {
  # A socket the phone opens and hands to its service, as systemd does
  systemd.sockets.echo = {
    wantedBy = [ "sockets.target" ];
    listenStreams = [ "127.0.0.1:7777" ];
    socketConfig.Accept = true;                  # each connection goes to its own run of echo@
  };
  systemd.services."echo@" = {
    serviceConfig.ExecStart = "${pkgs.coreutils}/bin/cat";
    serviceConfig.StandardInput = "socket";      # the connection is its input and output
  };

  # A template, and one of its instances (%i is the part after @)
  systemd.services."hello@" = {
    serviceConfig.Type = "oneshot";
    serviceConfig.ExecStart = "${pkgs.coreutils}/bin/echo hello %i";
  };
  systemd.services."hello@world".wantedBy = [ "multi-user.target" ];

  # A path unit: runs a service when a folder changes
  systemd.paths.inbox = {
    wantedBy = [ "paths.target" ];
    pathConfig.PathChanged = "/var/lib/inbox";
  };
  systemd.services.inbox = {
    serviceConfig.Type = "oneshot";
    script = "ls -l /var/lib/inbox";
  };
}
  • Sockets (TCP, UDP and unix): the phone opens them as root and hands them over as systemd does (LISTEN_FDS, LISTEN_FDNAMES), so nixpkgs' modules that use them run as they are, and a port below 1024 can belong to a service user. A service nothing else wants starts at its first connection (the phone looks for waiting connections every half second). Not supported: abstract (@name) and unix datagram sockets.
  • Templates ("foo@") run as their instances (foo@bar): those another unit names, and those you define as systemd.services."foo@bar". Systemd's specifiers are filled in: %i %I %n %N %p %P %j %J %t %S %C %L %E %T %V %d %u %g %h %U %G %a %v %%.
  • Path units take PathExists, PathExistsGlob, PathChanged, PathModified, DirectoryNotEmpty and MakeDirectory. The phone looks every 2 seconds.
  • Units in packages (systemd.packages): a package's own unit files are read, as on NixOS, with your config's settings over them.
  • Users' own units (systemd.user.services, timers, sockets, paths) run as each normal user, named user-<user>-<unit>; %t is /run/user/<uid>.
  • Notify and watchdog: Type = "notify" services count as started once they send READY=1 (within TimeoutStartSec, 90 seconds). With WatchdogSec, one that stops sending WATCHDOG=1 is stopped and started again (Restart = "on-watchdog").
  • Encrypted credentials: LoadCredentialEncrypted takes what systemd-creds encrypt made on the phone, with the phone's own key (add pkgs.systemd to environment.systemPackages and run it in the terminal). There is no TPM.

Service users, storage and logs

{ pkgs, lib, ... }: {
  systemd.services.notes = {
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      DynamicUser = true;                        # a user of its own (uid 400 to 999)
      StateDirectory = "notes";                  # /var/lib/notes, that user's, kept
      SupplementaryGroups = [ "media_rw" ];      # and the shared storage (/storage, /sdcard)
      LoadCredential = "token:/data/adb/nixup/secrets/notes-token";   # $CREDENTIALS_DIRECTORY/token
      ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8081 --bind 127.0.0.1 --directory /storage/emulated/0/Notes";
    };
    unitConfig.RequiresMountsFor = [ "/storage/emulated/0/Notes" ];  # wait until that storage is there
  };

  # Or let a NixOS module's service run as root
  systemd.services.navidrome.serviceConfig.User = lib.mkForce "root";
}
  • Users: a service with User or DynamicUser (as NixOS's modules give them) runs as that user, with a uid from 400 to 999 that the phone keeps for it. A module's own fixed uid works too. Android lets these users on the network.
  • Shared storage (/storage, /sdcard, SD cards) is open only to apps and root on Android. A service gets it when its user is in media_rw (or sdcard_rw, external_storage): Nixup then mounts the storage into that service's own view. Or the service runs as root.
  • Waiting for storage: services start at boot, before the first unlock and before an SD card may be mounted. With RequiresMountsFor a service waits for that storage, starts again in a fresh view when it appears, and stops if it goes.
  • Folders: StateDirectory (/var/lib), CacheDirectory (/var/cache), LogsDirectory (/var/log), RuntimeDirectory (/run, removed when it stops) and ConfigurationDirectory (/etc), with their …Mode; BindPaths and BindReadOnlyPaths.
  • Secrets: LoadCredential and SetCredential put files in a private folder only the service sees ($CREDENTIALS_DIRECTORY).
  • Logs: what a service prints goes to /data/adb/nixup/units/<name>/log (set aside as log.old past 1 MB). The Services screen shows it.
  • Notifications: when a service stops by itself or fails, and again when it has run for a minute after that. At boot, only if the last of its retries fails. notifications = false; on a service turns them off. The SSH server and adb never send any.
  • Priority: services run below the app on screen (nice 10, on the background cores). serviceConfig.Nice changes it.

Supported unit options

Most of what NixOS's services use works. nix/units.nix lists every key.

KindKeys
NixOS unit optionsdescription, enable, script, preStart, postStart, preStop, postStop, environment, path, wantedBy, requiredBy, after, before, wants, requires, bindsTo, restartTriggers, reloadTriggers, startAt; systemd.targets, systemd.globalEnvironment
CommandsExecStart, ExecStartPre, ExecStartPost, ExecCondition, ExecStop, ExecStopPost, RemainAfterExit, PIDFile
Typesimple, exec, oneshot, forking, notify, notify-reload (idle and dbus run as simple)
Restarting and stoppingRestart (no, always, on-success, on-failure, on-abnormal, on-abort, on-watchdog), RestartSec, the …ExitStatus keys, KillMode, KillSignal, SendSIGKILL, Timeout…, WatchdogSec
EnvironmentEnvironment, EnvironmentFile, UnsetEnvironment, WorkingDirectory
User and limitsUser, Group, SupplementaryGroups, DynamicUser, PermissionsStartOnly, AmbientCapabilities, NoNewPrivileges, UMask, Nice, OOMScoreAdjust, Limit…
FoldersStateDirectory, CacheDirectory, LogsDirectory, RuntimeDirectory, ConfigurationDirectory (and their …Mode), RuntimeDirectoryPreserve, BindPaths, BindReadOnlyPaths
SecretsLoadCredential, SetCredential, LoadCredentialEncrypted, SetCredentialEncrypted
unitConfigRequiresMountsFor, the path Condition… and Assert… tests, ConditionUser, ConditionVirtualization, ConditionArchitecture, ConditionKernelCommandLine, and the order keys
  • Accepted, and nothing done: what only hardens or limits a service on NixOS (ProtectSystem, PrivateTmp, PrivateDevices, SystemCallFilter, CPUQuota, MemoryMax, …), logging keys (StandardOutput, …; the output goes to the service's log), StartLimit…, and the unit options partOf, conflicts, onFailure, onSuccess and aliases. ExecReload and reloadIfChanged are accepted, but a changed service is restarted.
  • Unknown keys in serviceConfig or unitConfig are left out with a warning, as systemd itself does. An unknown key in a timer, socket or path unit stops the build.
  • Refused (the build stops and says why): both script and ExecStart; an unknown Type or Restart; a service that is not a one-time job without exactly one ExecStart; Nice outside -20 to 19; relative paths in BindPaths or RequiresMountsFor; confinement.enable; Accept = true without a template.

Activation scripts

Scripts, as NixOS's system.activationScripts, system.preSwitchChecks and system.userActivationScripts:

{
  # Runs as root with each build and each start of the phone, before the services start
  system.activationScripts.stay-awake = {
    text = "settings put global stay_on_while_plugged_in 3";
    undo = "settings put global stay_on_while_plugged_in 0";   # Nixup's own: when it leaves the config
  };

  # Runs before a build changes anything: when it fails, nothing changes
  system.preSwitchChecks.battery = ''[ "$(cat /sys/class/power_supply/battery/capacity)" -ge 20 ]'';

  # Runs as each normal user, when it changes and at each start of the phone
  system.userActivationScripts.notes = "mkdir -p ~/notes";
}
  • They run in Nixup's view (Nix packages and Android's commands both there), with bash, as root, in the order their deps say. NixOS's own names (users, etc, …) can be in deps. Their PATH has coreutils, findutils, grep, sed and /system/bin.
  • Keep them quick and safe to run again: they run at every build and every start.
  • A script that fails is told, and the others still run, as on NixOS. The build then ends as failed (its changes stay).
  • A dry run runs only scripts with supportsDryActivation = true (with NIXOS_ACTION=dry-activate), and the pre-switch checks.
  • A pre-switch check gets the profile as $1 and switch or dry-activate as $2. NIXOS_NO_CHECK=1 skips the checks.
  • undo is Nixup's own: it runs once when the script leaves the config, and when Nixup is removed, so the phone gets back what the script changed. An undo that fails is tried again at the next build.

NixOS service modules

NixOS's own service modules run on the phone from your nixpkgs, as they are: with their options, their users and their settings files. This is all it takes:

{
  services.ntfy-sh = {
    enable = true;
    settings.base-url = "http://phone:2586";
    settings.listen-http = "127.0.0.1:2586";    # the tailnet only, through Tailscale
  };
}

These modules are on Nixup's list, which means they build with Nixup's options (a test checks each one): AdGuard Home, aria2, at, Audiobookshelf, Bazarr, Blocky, Caddy, calibre-web, code-server, Conduit (Matrix), CoreDNS, CouchDB, cron, Deluge, dnscrypt-proxy, dnsmasq, endlessh, File Browser, Forgejo, FreshRSS, Gitea, gonic, GoToSocial, Gotify, Grafana, harmonia, Headscale, Homepage, i2pd, InfluxDB, Jellyfin, Kavita, Komga, Lidarr, lighttpd, linkding, llama.cpp, Loki, MariaDB/MySQL, Mealie, MiniDLNA, Mosquitto, Navidrome, nginx, Node-RED, ntfy, Ollama, Apache httpd, Privoxy, Prometheus, Prowlarr, qBittorrent, Radarr, Radicale, Readarr, Redis, Soft Serve, Sonarr, Stirling-PDF, Tor, Transmission, ttyd, Unbound, Uptime Kuma, Vaultwarden, vnStat and WebDAV.

Their option names

services. + adguardhome, aria2, atd, audiobookshelf, bazarr, blocky, caddy, calibre-web, code-server, coredns, couchdb, cron, deluge, dnscrypt-proxy, dnsmasq, endlessh, filebrowser, forgejo, freshrss, gitea, gonic, gotify, gotosocial, grafana, harmonia, headscale, homepage-dashboard, httpd, i2pd, influxdb2, jellyfin, kavita, komga, lidarr, lighttpd, linkding, llama-cpp, loki, matrix-conduit, mealie, minidlna, mosquitto, mysql, navidrome, nginx, node-red, ntfy-sh, ollama, privoxy, prometheus, prowlarr, qbittorrent, radarr, radicale, readarr, redis, soft-serve, sonarr, stirling-pdf, tor, transmission, ttyd, unbound, uptime-kuma, vaultwarden, vnstat and webdav.

The list is in nix/nixos.nix.

  • Each runs as its own user. One that needs the phone's shared storage (a music folder) gets it with systemd.services.<name>.serviceConfig.SupplementaryGroups = [ "media_rw" ];, or runs as root with serviceConfig.User = lib.mkForce "root";.
  • Another module of nixpkgs can be added by hand, as on NixOS: imports = [ "${modulesPath}/services/web-apps/foo.nix" ]; (with { modulesPath, ... }: at the top). Anything it needs that the phone lacks stops the build with a message. disabledModules = [ "services/misc/ntfy-sh.nix" ]; leaves one of the list out, for a module of your own.
  • PostgreSQL is not supported: it needs System V shared memory, which Android's kernel does not have. Use a module's SQLite database instead.
  • Settings files a module makes are built on the phone, from what cache.nixos.org has; packages themselves are never built.
NixOS options a phone cannot have

Modules often set or read options for things a phone does not have. Nixup handles them three ways:

  • Accepted and ignored (so modules build): the firewall's port lists, networking.useDHCP and other network setup, security.pam.services, security.wrappers (with a warning: no setuid wrappers), boot.* kernel and file system lists, services.dbus, services.udev, services.logrotate (each service's log is set aside past 1 MB anyway), documentation.*.
  • Warned about: boot.kernelModules and boot.kernel.sysctl.
  • Refused when turned on (the build stops and says why): networking.firewall.enable (off by default on a phone), nftables, NetworkManager, networking.wireless, networking.interfaces, security.acme.certs, extra certificates, polkit, sudo and doas (root is su on a phone), rtkit, AppArmor (Android has SELinux), extra kernel module packages and kernel parameters, systemd.mounts, systemd.network, services.resolved, X11, PulseAudio, PipeWire, Home Assistant, Avahi (Android has its own mDNS), hardware.graphics and PostgreSQL.

Tailscale

Tailscale with NixOS's options, run by root in userspace mode: it takes no VPN slot and changes no routes, so the Tailscale app can stay.

{
  services.tailscale = {
    enable = true;
    authKeyFile = ./tailscale.key;               # logs in by itself; without it, the log shows a login link
    extraUpFlags = [ "--ssh" ];                  # more `tailscale up` flags, like "--advertise-exit-node"
  };
  networking.hostName = "phone";                 # its name on the tailnet
}
  • Your other tailnet devices reach the phone's services, even those that listen only on 127.0.0.1, which Wi-Fi cannot reach. That is how the examples on this page keep services private: http://phone:8080 from your laptop.
  • Each build runs tailscale up --reset with your flags, so a flag you take out goes back to Tailscale's default. The auth key is used only when the phone is logged out.
  • authKeyFile takes a file next to your config (./tailscale.key), the key itself ("tskey-…"), a pkgs.writeText file, or the full path of a file already on the phone. Each becomes a root-only secret.
  • The tailnet name is networking.hostName, or the phone's device name (in small letters).
  • You get a notification when Tailscale connects, drops, or needs a login (checked every 20 seconds).
  • Also: package, port (41641), extraSetFlags, extraDaemonFlags, authKeyParameters, permitCertUid, disableTaildrop, disableUpstreamLogging, and Nixup's own relayServerPort (a peer relay's UDP port). interfaceName must stay "userspace-networking"; useRoutingFeatures and openFirewall do nothing (use flags).

Syncthing

Syncthing, set up from the config as on NixOS, run by root:

{
  services.syncthing = {
    enable = true;
    settings.devices.laptop.id = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU";
    settings.folders.documents = {
      path = "/storage/emulated/0/Documents";
      devices = [ "laptop" ];
      type = "sendreceive";                      # "sendonly", "receiveonly", "receiveencrypted"
      versioning = { type = "trashcan"; params.cleanoutDays = "30"; };
    };
    cert = ./syncthing/cert.pem;                 # optional: a fixed device ID (secret files)
    key = ./syncthing/key.pem;
  };
}
  • Devices, folders and settings are pushed into Syncthing at each build. Devices and folders not in the config are removed (overrideDevices, overrideFolders).
  • Its web interface listens on a socket only root can use (unix:///run/syncthing/gui.sock). For a browser, set guiAddress = "127.0.0.1:8384"; and a guiPasswordFile; the build warns without one.
  • Each folder's path is waited for (SD cards, the storage after the first unlock). Folders under /storage ignore file permissions, which the phone's storage does not have.
  • Its data is in /var/lib/syncthing. Not on the phone: another user, group or data folder, relay.enable, and encryptionPasswordFile.

SSH

An SSH server, as NixOS's services.openssh. Logins get the same shell as the app's terminal, with your packages.

{
  services.openssh = {
    enable = true;
    listenAddresses = [ { addr = "127.0.0.1"; port = 22; } ];   # the tailnet only (leave out: every network)
  };
  users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAA… you@laptop" ];
}
ssh root@phone                  # from a computer on your tailnet
sftp root@phone                 # file transfers work too (file managers, sshfs)
  • Root logs in with keys only (PermitRootLogin = "prohibit-password"). Password logins are off by default.
  • Passwords: with settings.PasswordAuthentication = true, users with a password (users.users.<name>.hashedPassword, …) log in with it, checked through PAM as on NixOS.
  • Keys can come from authorizedKeys.keys, authorizedKeys.keyFiles, ~/.ssh/authorized_keys (root's is /data/adb/nixup/home/.ssh) and authorizedKeysFiles (in folders only root can write to).
  • Host keys are made in /etc/ssh at the first start and kept. The settings are checked (sshd -t) before each start, and restarting the server does not end open sessions.
  • Its log: /data/adb/nixup/units/sshd/log. Not on the phone: startWhenNeeded and authorizedKeysCommand.

Snapcast

Multi-room audio, in sync. services.snapserver is NixOS's; services.snapclient is Nixup's own (NixOS has none), and plays a server's stream through the phone's speaker, headphones or Bluetooth.

{
  services.snapclient = {
    enable = true;
    host = "192.168.1.10";        # the server (a phone cannot look for one), or "ws://server:1780"
    latency = 0;                  # ms to play earlier, on top of Android's own (measured)
  };

  services.snapserver = {
    enable = true;
    settings.stream.source = [ "pipe:///run/snapserver/snapfifo?name=default" ];
    settings.http.enabled = true; # Snapweb, a player and control page, on 1780
  };
}
  • The client's sound goes through Android's audio as music. Its delay is measured at each start and added. 24- and 32-bit sound needs Android 12 or newer.
  • Client options: host, port (1704), hostID (default: the phone's name), instance, latency, sampleFormat ("48000:16:2"), mixer ("software", or "none" for Android's volume alone), extraFlags.
  • nixpkgs' snapcast comes with PipeWire, PulseAudio and Avahi: about 780 MB on the phone.

DAVx⁵

Calendar and contacts accounts for DAVx⁵, the app that syncs a CalDAV/CardDAV server (Nextcloud, Radicale, Baïkal, Fastmail, …) into Android's own Calendar and Contacts. services.davx5 is Nixup's own: NixOS has no DAVx⁵, an Android app.

{ pkgs, ... }: {
  android.packages.fdroid = with pkgs.fdroid; [ at.bitfire.davdroid ];   # DAVx⁵ itself

  services.davx5 = {
    enable = true;
    accounts.home = {
      url = "https://dav.example.org/";          # DAVx⁵ finds the calendars from here
      userName = "neo";
      passwordFile = ./dav-password;             # a secret, root-only on the phone
    };
    accounts.work = {
      url = "https://cloud.example.com/remote.php/dav";
      userName = "neo@example.com";
      passwordFile = ./work-dav-password;
      automatic = true;                          # added with no taps
    };
  };
}
  • A new account opens DAVx⁵'s login screen on the phone, already filled in with the server, the user name and the password. Tap Login, then Finish: DAVx⁵ finds the calendars and address books and starts syncing them. One login screen opens per build; the next build opens the next account's. On a locked phone it is there once you unlock.
  • With automatic = true the account is added with no taps: the build asks the server for its calendars and address books itself, as DAVx⁵'s login does, writes them into DAVx⁵'s database, adds the account as DAVx⁵ and starts its first sync. DAVx⁵ plans the syncs after that (every 4 hours, its default). This needs DAVx⁵ 4.5.17 to 4.5.21, whose database Nixup knows; any other version gets the login screen instead, because DAVx⁵ removes all of its accounts when it finds a database it cannot read. It logs in with Basic authentication: a server that wants Digest needs the login screen.
  • An account that is there already gets the config's user name and password at each build, as DAVx⁵'s account settings would change them, so a new password in passwordFile reaches the phone with the next build.
  • How Nixup finds an account: by its accountName when you give one (type that name at the login screen), or else by its user name on its server. DAVx⁵ names a new account after the email address the server has for you, else after the user name.
  • An account that leaves the config stays in DAVx⁵, and so do accounts you add by hand. To remove one, use DAVx⁵ (the account, ⋮, Delete account).
  • syncCollections is what DAVx⁵ selects for syncing as it finds it, at a login and when the server has new calendars later: "all" (the default) or "none" (pick each one in DAVx⁵). It is DAVx⁵'s own setting, given back as it was when services.davx5 leaves the config.
  • DAVx⁵ gets the contacts, calendar and notifications permissions and unrestricted battery use, and it is never paused for being unused (which would take its permissions away). Each of these can be set otherwise in android.apps."at.bitfire.davdroid".
  • passwordFile takes a file next to your config, a pkgs.writeText file, or the full path of a file already on the phone; each becomes a root-only secret. A password written into the config as text is refused: it would be in the Nix store.
  • install = "fdroid" (or another repository's name) installs DAVx⁵ too; leave it out when DAVx⁵ is in a list of android.packages already. No service runs for DAVx⁵: the build sets it up, so a config needs no Nix packages for it.

adb

Android's debugging bridge. NixOS has nothing like it, so the option is Android's own:

{
  android.adb = {
    enable = true;                               # USB debugging on (false: off; null: not managed)
    port = 5555;                                 # adb over the network too, from every start: adb connect phone:5555
    trustedKeys = [ "QAAAA…= you@laptop" ];      # computers let in without asking (their ~/.android/adbkey.pub)
    trustedKeyFiles = [ ./laptop-adbkey.pub ];   # the same, from files
    forgetUnused = false;                        # keep every computer (Android forgets one unused for 7 days)
  };
}
  • Keys are added to Android's own list (/data/misc/adb/adb_keys). Only the keys Nixup added are taken out again; a computer the phone trusted before stays.
  • A new port takes effect 3 seconds after the build ends, so a build that came in over adb finishes first. The port is set again at each start.
  • enable = false turns USB debugging off, which also cuts off nixup-rebuild over adb.
  • adb never sends notifications: it is about the phone's security.

Networking

These work as on NixOS, inside Nixup's view: for its services, shells and Nix. Android's apps keep Android's own DNS, hosts file and proxy.

{
  networking.hosts = { "192.168.1.10" = [ "nas" "nas.home" ]; };   # names in Nixup's /etc/hosts
  networking.nameservers = [ "9.9.9.9" ];          # DNS servers ([ ]: the network's own, the default)

  # StevenBlack's list of ad and tracker names, blocked in Nixup's /etc/hosts
  networking.stevenblack = {
    enable = true;
    block = [ "gambling" ];                        # more lists: "fakenews", "gambling", "porn", "social"
    whitelist = [ "s.click.aliexpress.com" ];      # names left alone
  };

  networking.proxy.default = "http://127.0.0.1:3128";   # the shells' http_proxy, … and Nix's downloads
  networking.proxy.noProxy = "127.0.0.1,localhost,.home.arpa";
}
  • networking.hostFiles adds more files of names to /etc/hosts (StevenBlack's list is one); networking.extraHosts adds lines.
  • With no nameservers, /etc/resolv.conf follows the network in use, and is rewritten when you change networks.
  • The proxy reaches Nix's own downloads from the build after the one that sets it. noProxy defaults to 127.0.0.1,localhost when a proxy is set.
  • networking.hostName is the phone's name (see System settings); networking.domain and networking.fqdn are there for modules that read them.
  • The firewall's options are accepted and do nothing; networking.firewall.enable = true is refused, as a phone has no such firewall.

Files in /etc and folders

{
  environment.etc."foo/foo.conf".text = ''
    a = 1
  '';
  environment.etc."foo/secret.conf" = { text = "b = 2"; mode = "0600"; };   # a copy, not a link

  systemd.tmpfiles.rules = [
    "d /var/lib/foo 0750 root root -"                                # a folder
    "L+ /home/niobe/Music - - - - /storage/emulated/0/Music"         # a link
  ];
}
  • environment.etc files go into Nixup's /etc only; Android's /system/etc is never touched. A file that leaves the config is removed. Options: text, source, target, mode ("symlink", or a mode for a copy), user, group, uid, gid, enable.
  • Nixup writes some files itself, so they cannot come from environment.etc: passwd, group, shadow, subuid, subgid, hosts, resolv.conf, the certificates, and anything under nixup/. Use the matching options instead.
  • systemd.tmpfiles.rules (and systemd.tmpfiles.settings) run at each build and start, before the services: for paths under /var, /run, /etc and /home. Types d D v q Q e f F w L z Z r R C p; the age column is ignored.

Secrets

Some options point to secret files: keys, passwords, tokens. Nixup never puts them in the Nix store or in the profile. They are: services.tailscale.authKeyFile, services.syncthing.cert, key and guiPasswordFile, services.davx5.accounts.<name>.passwordFile, users.users.<name>.hashedPasswordFile, android.backup.passwordFile and environmentFile, and android.playstore.tokenFile.

{
  # A file next to your config: the build copies it to the phone, readable by root only
  services.tailscale.authKeyFile = ./tailscale.key;

  # A file already on the phone: used where it is (the config itself then holds no secret)
  android.backup.passwordFile = "/data/adb/nixup/secrets/backup-password";
}
  • Files from the config go to /data/adb/nixup/secrets/: the folder is root's alone, each file readable by root only. A file that leaves the config is removed; files you put there yourself stay.
  • Keys can also be given as text, where Nixup knows them by how they start ("tskey-…", "-----BEGIN …", "aas_et/…"), or as a pkgs.writeText file, as a module shared with NixOS might make them. Either way they are written to such a root-only file.
  • A path made by Nix ("${…}") is refused: it would have gone through the Nix store, which every user can read.
  • A secret that is missing stops the build before anything changes. A changed secret restarts the services that use it.
  • A build from a PC sends the secret files along, into a root-only folder that is removed once the build is applied.
Tip

In a flake, every file it uses is part of the flake: a secret file must be in the flake's folder (and, in a git repository, known to git). To keep secrets out of a repository altogether, put them on the phone in /data/adb/nixup/secrets/ and give their full path, as in the second example.

Backups

Apps (their APK and data) and folders are backed up by the phone itself with restic: encrypted, and each file stored only once.

{
  android.backup = {
    repository = "/storage/XXXX-XXXX/Backup";    # SD card, phone storage, or "sftp:me@pc:/backup/phone", …
    passwordFile = ./backup-password;            # next to your config; keep a copy off the phone
    apps = [ "org.thoughtcrime.securesms" "at.bitfire.davdroid" ];
    folders = [ "/storage/emulated/0/DCIM" ];
  };

  # Optional: one app its own way
  android.apps."com.termux".backup = { include = [ "data" ]; when = "manual"; };
}
Important

Keep passwordFile somewhere besides the phone. Without it, the backups cannot be read.

Every backup option
{
  android.backup = {
    repository = null;                            # a full path on the phone, or sftp:, rest:, s3:, b2:, azure:, gs:, swift:, rclone:
    passwordFile = null;                          # the repository's password (secret)
    environmentFile = null;                       # a remote repository's keys, NAME=value lines (secret)
    initialize = true;                            # make the repository at the first backup
    apps = [ ];                                   # apps backed up with the defaults
    folders = [ ];                                # folders, once a schedule
    defaults = {
      apk = true;                                 # its APK too: installable from the backup
      data = true;                                # false: the APK alone
      include = [ "data" "deviceData" "storage" "accounts" ];   # also "media", "obb"
      when = "auto";                              # "auto": after use; "schedule"; "manual"
      autoRestore = false;                        # true on a new phone: see below
    };
    schedule = "daily";                           # "hourly", "daily", "weekly", "6h", …
    quietTime = "10m";                            # "auto": this long in the background first
    keep = { last = 3; hourly = 0; daily = 7; weekly = 4; monthly = 0; yearly = 0; };
    onlyWhileCharging = false;                    # by itself only on the charger
  };
  # Per app: android.apps."<app>".backup = true, false, or its own { include, when, apk, data, … }
}

What is backed up of an app

PartWhat
apkThe app itself, so it can be installed from the backup (only apps you installed, not those that came with the phone)
dataIts private data
deviceDataWhat it reads before the first unlock
storageIts files in Android/data
mediaIts files in Android/media
obbIts files in Android/obb (game data)
accountsThe accounts it adds to Android's settings, with their passwords and sync settings (DAVx⁵'s calendar and contacts accounts, a mail app's). Android keeps them outside the app's folders and deletes them when the app is removed, so without this part a restored app has nothing to sync with

Caches and compiled code are left out; Android makes them again. Each backup also keeps which permissions the app was granted.

When backups run

  • The phone checks every 15 minutes what is due, once it has been unlocked since it started.
  • An "auto" app is backed up after you used it, once it has been in the background for quietTime (10 minutes), at most once an hour; and at least once a schedule, after an update, and when it was never backed up. A "schedule" app: once a schedule. A "manual" app: only by hand. Folders: once a schedule.
  • Only what changed is stored again. An app not in use is paused while it is copied, so its files are copied as one moment.
  • Before each backup the storage is checked: the SD card is there, and there is room. Automatic backups also wait while memory is short or the phone is hot. A backup that waits tries again later and says why on the Backups screen.
  • After each run, old backups beyond keep are dropped; their space is freed at most once a week.

Restoring

  • Always by hand: the Backups screen (pick the backup, then the parts: APK, data, …), or su -c "nixup-backup restore org.example.app".
  • An app that is not on the phone comes back whole: its APK first (from that backup, or another backup of the app), then its data. If no backup has its APK, the restore says “app not found”: install the app first, then restore its data.
  • Restoring stops the app and takes its files out of the backup into a folder of their own first: only when all of them are out do they replace the app's data, so a restore that fails (or a restart in the middle) leaves the app as it was. The files get the app's owner and security labels; links stay links. An older APK is not installed over a newer one, and data from a newer version than the installed app is refused.
  • Its accounts that are not on the phone come back, with their passwords and sync settings (an account that is there stays as it is), and it is granted again the permissions it had. A restart in the middle of a restore picks it up again.
  • A folder comes back into itself (the backup's files replace those there; others stay), or into a new folder.

A new phone

{
  networking.hostName = "phone";                   # the same name as the old phone
  android.backup.defaults.autoRestore = true;
}
  • With autoRestore, a build that installs an app brings back its data (from the newest backup that is not newer than the version installed), and backed-up apps that are on no list are installed from their backup with their data.
  • An app you removed on this phone is never brought back by itself.
  • Backups are kept per phone name (networking.hostName, else the device name). Give the new phone the old one's name, or it will not see the old backups.

Commands

su -c "nixup-backup status"                            # each app and folder: last backup, and whether it is due
su -c "nixup-backup list org.example.app"              # its backups
su -c "nixup-backup now"                               # back up everything now
su -c "nixup-backup now --parts=apk,data com.termux"   # or one app, some of its parts
su -c "nixup-backup restore org.example.app"           # from its newest backup
su -c "nixup-backup restore /storage/emulated/0/DCIM --to=/storage/emulated/0/DCIM-restored"
su -c "nixup-backup restic snapshots"                  # restic itself, with the repository and password

Specialisations

Other versions of the config, built with it, as on NixOS: a “work” one, a “travel” one. The phone switches between them without building again.

{
  specialisation.work.configuration = {
    services.syncthing.enable = false;
    android.settings.display.darkMode = false;
  };

  specialisation.bare = {
    inheritParentConfig = false;                  # only its own lines, not this config's
    configuration = { };
  };
}
  • Switch: ⋮ → Specialisations and pick one (or “The config itself”), or nixup-rebuild switch --specialisation work.
  • The phone keeps the one picked, across restarts too, until another is picked. (NixOS goes back to its default at a restart.)
  • A normal Build applies the config itself, unless you ask for a specialisation. An automatic upgrade keeps the one in use.
  • Each build builds all of them; a check or warning of one is told with its name. Names use letters, digits, _, . and -.

Automatic upgrades

The phone can build its config again by itself, with everything at its newest, as NixOS's system.autoUpgrade:

{
  system.autoUpgrade = {
    enable = true;
    dates = "04:40";                              # systemd's calendar events, like "daily", "Sun 03:00"
    randomizedDelaySec = "45min";                 # a random wait before each
    persistent = true;                            # a time missed while the phone was off comes once it is on
    upgrade = true;                               # false: build the config as flake.lock pins it
    # flake = "github:you/phone#phone";           # another config (null: the picked one; a git clone pulls first)
    # channel = "https://channels.nixos.org/nixos-26.05";   # the nixpkgs of a configuration.nix alone
    runGarbageCollection = false;                 # true: free up space afterwards
  };
  android.autoUpgrade = {
    onlyOnUnmetered = true;                       # only on Wi-Fi, never on mobile data
    onlyWhileCharging = false;                    # true: only while charging
  };
}
  • A time when the phone is not on Wi-Fi (or not charging, when asked), or its battery is under 15% and not charging, is skipped.
  • A config in the app's git clone gets the repository's new commits first. The specialisation in use stays.
  • flake and channel cannot both be set. Only operation = "switch" exists; flags, allowReboot and rebootWindow are ignored (a build needs no restart).

Version and labels

Each generation has a version label and the config's commit, as NixOS's boot menu shows them:

{
  system.stateVersion = "26.05";                  # the release of your first build: leave it there
  system.nixos.tags = [ "travel" ];               # words before the version in each generation's label
  system.copySystemConfiguration = true;          # each generation keeps a copy of configuration.nix (not with a flake)
}

In a flake, the commit it was built from can go into each generation:

{
  inputs.nixup.url = "github:by-architect/Nixup";
  outputs = { self, nixup, ... }: {
    nixupConfigurations.phone = nixup.lib.nixupSystem {
      modules = [
        ./configuration.nix
        { system.configurationRevision = self.rev or self.dirtyRev or null; }
      ];
    };
  };
}
nixup-version                           # 26.05.20261013.a033e66 (Yarara)
nixup-version --revision                # the git commit of the config's nixpkgs
nixup-version --configuration-revision  # system.configurationRevision
nixup-version --json                    # all of them, as NixOS's --json
  • nixos-version is the same command, for tools written for NixOS.
  • /etc/os-release in Nixup's view says NAME=Nixup, ID=nixup, ID_LIKE=nixos, so tools like fastfetch show it.
  • The Generations screen shows each one's label and commit.

Build, dry run, roll back

It works like nixos-rebuild, from the app or from a root shell on the phone:

NixOSNixup
nixos-rebuild switchBuild, or nixup-rebuild switch
nixos-rebuild dry-activateDry run, or nixup-rebuild dry-activate
nixos-rebuild switch --upgradeUpgrade, or nixup-rebuild switch --upgrade
nixos-rebuild switch --rollbackGenerations → pick one → Switch to it, or nixup-rebuild switch --rollback
nixos-rebuild list-generationsGenerations, or nixup-rebuild list-generations
nixos-rebuild switch --specialisation work⋮ → Specialisations, or nixup-rebuild switch --specialisation work
nixos-rebuild --target-hostnixup-rebuild switch --flake .#phone on a PC (below)
nixos-generate-confignixup-config create on a PC (below)
nixos-versionnixup-version (and nixos-version)

From a shell on the phone

su -c 'nixup-rebuild switch'                          # build the config the app picked, and apply it
su -c 'nixup-rebuild dry-activate'                    # what a build would change
su -c 'nixup-rebuild switch --upgrade'                # with everything at its newest
su -c 'nixup-rebuild switch --file ./configuration.nix'   # another config, this once
su -c 'nixup-rebuild switch --flake github:you/phone#phone'
su -c 'nixup-rebuild switch --rollback'               # the generation before the current one
su -c 'nixup-rebuild list-generations'

list-generations prints one line per generation (number, date, label, source), the current one marked. boot and test do not exist on the phone: a build needs no restart.

Upgrade

Upgrade fetches the app lists and releases again, moves a flake's inputs to their newest (its flake.lock is updated), and gives a configuration.nix the newest nixpkgs; then it builds as Build does.

Generations

  • Each build that changes something is saved: its profile, the versions its apps are at, what it was built from (and the commit), the PC it was built on, its flake.lock, its label and the config's commit, and the specialisation in use.
  • Switching to an older generation applies its profile again: its settings, apps and services. It then becomes the newest generation. Apps come at their newest versions, and packages that were freed are fetched again.
  • The Generations screen has a Dry run for each, to see first what switching would change.

Building from a PC

A PC with Nix can build the config and apply it on the phone, as nixos-rebuild --target-host does. The module must be on the phone first. Turn on USB debugging, connect, and allow the PC.

nix run github:by-architect/Nixup -- switch --flake .#phone            # build here, apply on the phone
nix run github:by-architect/Nixup -- dry-activate --flake .#phone      # what would change
nix run github:by-architect/Nixup -- switch --flake .#phone --upgrade  # with everything at its newest
nix run github:by-architect/Nixup -- switch --file configuration.nix   # a configuration.nix alone
nix run github:by-architect/Nixup -- switch --flake .#phone --target-host adb:192.168.1.20:5555
nix run github:by-architect/Nixup -- switch --flake .#phone --target-host phone   # over ssh
nix run github:by-architect/Nixup -- build --flake .#phone             # only build: writes ./result.json
OptionMeans
--flake <flake>[#name]The flake (. when there is a flake.nix here). Without #name: the one named after the phone, else the only one.
--file <file>A configuration.nix (./configuration.nix when there is no flake), with the PC's nixpkgs.
--target-host <where>adb (asks which phone), adb:<serial>, adb:<host>:<port>, or [user@]host for ssh.
--upgradeA local flake's inputs at their newest, app lists and releases fetched again.
--specialisation <name>Apply that specialisation.
--override-input, --show-traceAs with nixos-rebuild.
  • Picking the phone: it lists the phones adb sees, over USB and the network (one phone seen both ways is shown once), and asks which. Its last entry connects a phone over the network, and offers pairing if needed (Wireless debugging → Pair device with pairing code). The address is remembered. Without a terminal, there must be exactly one phone, or a --target-host.
  • Over ssh, the user must be able to use su: root, or a user with androidApp = "dev.nixup.manager".

What happens

  1. The PC checks that the phone has Nixup's module and lets it use root.
  2. It sends the config's repository list; the phone refreshes its app lists; the PC reads the app names back.
  3. The PC evaluates the config into the profile.
  4. It sends the profile, the secret files, the small recipes for the files the config makes (and the config's nixpkgs, when the phone does not have it), what it was built from and on which machine, and flake.lock.
  5. The phone applies it like its own build, with the same checks. The generation says which PC built it.

The PC never builds the phone's packages: the phone downloads them from cache.nixos.org itself. What the PC sends goes into a root-only folder, removed after. After a build from a PC, the app's Build asks whether to apply that build again as it is, or build the config picked on the phone instead.

On a NixOS machine

# with Nixup's flake as an input named nixup
nixpkgs.overlays = [ inputs.nixup.overlays.default ];
environment.systemPackages = [ pkgs.nixup-rebuild ];   # nixup-rebuild and nixup-config

Or let the phone build it

adb push configuration.nix /sdcard/Nixup/configuration.nix
adb shell su -c "nixup-rebuild switch --file /sdcard/Nixup/configuration.nix"   # or dry-activate

A config from your phone

nixup-config create writes a config from what a phone has now, as nixos-generate-config does for a NixOS machine. Building it changes nothing: it is the phone as it is. It lists the phones adb sees and asks which.

nix run github:by-architect/Nixup#nixup-config -- create                    # writes ./configuration.nix
nix run github:by-architect/Nixup#nixup-config -- create --output phone.nix --force   # another file, replaced
nix run github:by-architect/Nixup#nixup-config -- create --target-host adb:192.168.1.20:5555
  • What it reads: the phone's settings (and LineageOS's), model, Android version, dark mode, display size, navigation mode, languages, time zone, keyboard, Data Saver, location, adb's port; the apps, and the repository each came from; the apps that came with the phone and were removed or disabled; battery exceptions and default apps.
  • What it writes: sections for apps by source, apps that came with the phone (debloat, enabled = false), apps' settings, and the phone's settings. Apps from Google Play or releases, and apps it could not place, are written as comments for you to finish.
  • It needs no root; with Nixup installed and root allowed, it learns more (the phone's own app lists). It finds apps' sources in F-Droid and IzzyOnDroid too.
  • It checks the file against Nixup's options before it ends. Then: look it over, and nixup-rebuild switch --file configuration.nix.

The app, screen by screen

Five screens, a terminal, and a menu. One action runs at a time, and what it says shows on the screen as it goes.

Build

  • Config: what a build reads, with Pick file, New, Git and Type it (a flake address, or a full path, each may end in #name).
  • When the config is in the app's git clone: its address, branch, commit and local changes, with Pull, Pull and build and Reclone.
  • The last build (its generation, source, and the PC it came from), then Build, Dry run, Upgrade, Open its folder and Generations.

Updates

  • When it last checked, and which sources update by themselves.
  • A banner when a newer Nixup is out, with Install and What's new.
  • Check for updates, Update all, and each newer app with its versions, size and source, and an Update button.

Backups

  • The repository, and why automatic backups wait, if they do.
  • Back up all, Back up… (any installed app with the parts you pick, or a folder), Refresh.
  • The config's apps, its folders, and other backups, each with Back up and Restore, when it was last backed up, and whether it is due.

Services

  • Each service and how it is: running, listening (starts at its first connection), restarting, waiting for its storage, stopped, done, failed, not run, not started (its condition is not met); since when, and when its timer runs next.
  • Start, Stop, Restart, Run again (one-time jobs) and Log.

Logs

  • Everything that happened, by day: builds, updates, backups, services. Filters: All, Builds, Updates, Backups, Services, Problems.
  • Tap an entry for when, who started it (you, the phone by itself, a computer over adb or ssh, Magisk's Action button, …), how long it took, and Show log.
  • Log files opens apply.log, its older part, and each service's log.

The top bar

  • The terminal icon (see Packages and the terminal).
  • ⋮: Google Play account, Notifications, Specialisations, Free up space, What this phone supports (which settings this phone can have, and why not) and About.

The Updates, Backups, Services and Logs screens each have a search box, and so does every log you open: type part of a name, a package or a message. Case and accents do not matter (“sifre” finds “şifre”). In Magisk 28 and newer, the module's Action button runs a build too.

Commands

On the phone, as root

From adb shell su -c '…', the app's terminal, nixup-shell, or SSH as root. They are in /system/bin from the first restart after installing.

nixup-shell                          # root's shell in Nixup's view, with your packages
nixup-shell htop                     # one command there
nixup-rebuild switch                 # build: dry-activate, --upgrade, --rollback, list-generations
nixup-backup status                  # backups: list, status, now, restore, restic
nixup-version                        # the system's version (nixos-version too)
sh /data/adb/modules/nixup/units.sh adb list                 # services: start, stop, restart, log <name>
sh /data/adb/modules/nixup/update.sh adb check               # app updates: check, now [apps]
sh /data/adb/modules/nixup/build.sh adb gc                   # free up space
tail -n 50 /data/adb/nixup/apply.log                         # the log

From a computer, the same over adb:

adb shell su -c nixup-shell
adb shell su -c "nixup-rebuild list-generations"
adb shell su -c "nixup-backup restore org.example.app"
adb shell su -c "sh /data/adb/modules/nixup/units.sh adb log sshd"

On a PC, with Nix

nix flake init -t github:by-architect/Nixup                       # a flake.nix and configuration.nix to start from
nix run github:by-architect/Nixup -- switch --flake .#phone        # nixup-rebuild: switch, dry-activate, build
nix run github:by-architect/Nixup#nixup-config -- create           # a config from what the phone has
nix run github:by-architect/Nixup#nixup-play-login                 # a Google Play token

Logs and notifications

  • Notifications come in groups you can turn off one by one (⋮ → Notifications): Going on now, Builds and the config, App updates, Backups, Services, Connections (Tailscale), Terminal.
  • While something runs, it shows in “Going on now”; when it ends, its result replaces it. Tapping a notification opens the Logs screen (or Updates).
  • Nothing is sent for what you start in the app while it is on screen, and never anything about the SSH server, adb or secrets.
  • Under Nixup's name in Magisk's module list, the last result shows: ✔ … or ✘ … (log: /data/adb/nixup/apply.log).
FileHolds
/data/adb/nixup/eventsThe journal the Logs screen reads (the newest 1000 entries)
/data/adb/nixup/logs/<run>.logEverything one build, update or backup said (the newest 60)
/data/adb/nixup/apply.logEvery run's output in one file (set aside as apply.log.old past 64 KB)
/data/adb/nixup/units/<name>/logA service's output (set aside as log.old past 1 MB)

Updating Nixup

When a new release is out, Magisk shows an Update for Nixup, and Nixup's Updates screen shows it with an Install button. Install downloads it from Nixup's releases and checks it first: it must be Nixup's module, newer, with its app signed by the same key as the app on the phone. The phone uses it from the next restart. Your config, generations and data stay.

The phone looks for a new release with each update check (every 6 hours, on Wi-Fi).

Uninstall

  1. In Magisk, open Modules → Nixup → Remove. If Remove is grayed out, an update is waiting: restart first.
  2. Restart the phone, and unlock it.

Once the phone is started and unlocked, Nixup undoes itself:

  • Every setting it changed goes back to how it was before Nixup, and the apps it removed (debloat) come back.
  • Apps Nixup installed are uninstalled; their data is kept.
  • Services stop, scripts' undo run, adb keys it added go, and its entries in Obtainium's list are removed.
  • If something cannot go back, it tries again at the next three starts. Then the app and all of /data/adb/nixup are removed.

What stays: your config (wherever you keep it), your backups, apps you installed yourself, and apps' data.

Deleted with Nixup

Everything in /data/adb/nixup goes: the generations, the secrets, the Nix store, a config cloned by the app's Git, and your services' data in /var/lib (a Vaultwarden or Syncthing database, for example). Copy what you need elsewhere first.

Troubleshooting

Every run's log is on the Logs screen; the last lines usually say what went wrong. Common cases:

“the config did not build”
Nix found a mistake. The lines above it give the file, the line and the reason: often a missing ;, an option name with a typo, or a value of the wrong type. Nothing was changed.
“there is no config yet”
Pick one on the Build screen: New, Pick file, Git or Type it.
“the config … is not there”
The file moved, was renamed, or its SD card is out. Pick it again.
“give flake.nix a folder of its own”
A flake's whole folder is copied; keep flake.nix in a small folder of its own, not in Download.
“Not in cache.nixos.org”
A package would need compiling (an overlay changed it, or it is unfree). Use the package as nixpkgs has it, or add build machines.
An app is “signed with another key”
The app on the phone came from somewhere else. Back up its data, uninstall it and build again, or leave it out of the config.
Installs wait at Play Protect
Set android.settings.developer.verifyAdbApps = false; so Android does not stop to scan each app.
“Sign in to Google Play”
No account, or Google refused its token: ⋮ → Google Play account → Sign in. If the page says there is no internet, turn on the Nixup app's network access in its App info.
A setting is “left as it is”
The phone does not have what it needs. ⋮ → What this phone supports says which settings it can have.
“another one is running”
A build, update or backup is still going. Wait for it (the Logs screen shows it), then try again.
Nothing happens after a restart
Unlock the phone once: Android keeps apps' data locked until then, so builds and backups wait.
A service failed
Services → its Log. A service that reads /storage needs media_rw (or root) and often RequiresMountsFor.
Backups wait
The Backups screen says why: the SD card is out, there is not enough room, or the phone is busy or hot. They try again every 15 minutes.
nixup-rebuild: not found
The commands arrive with the first restart after installing. Restart once.
“the config was not built yet”
nixup-shell has no Nixup view before the first build: build once.

Limits

What Nixup does not do, or does differently from NixOS:

  • Packages are never compiled on the phone. They come from cache.nixos.org, or from your build machines.
  • No Nix sandbox: Android's kernels cannot provide it.
  • No real systemd: no cgroups, no limit on restarts, no reloads (a changed service restarts), timers do not wake the phone, path units look every 2 seconds, and socket activation looks every half second.
  • Not on a phone: PostgreSQL, a D-Bus session bus, a desktop session, setuid programs for normal users, a firewall, NetworkManager, sudo. Tailscale runs in userspace mode only.
  • Apps: no beta versions from repositories; going back to a generation does not downgrade apps; Google Play apps with expansion files are not supported yet.
  • Backups: the APK of an app that came with the phone is not backed up (its data is).
  • Settings that need LineageOS, a newer Android or some hardware are skipped on phones without them.
  • One user: Nixup works for the phone's owner, not in a work profile or an app clone.
  • pkgs.formats.yaml writes its files as JSON (which is valid YAML), to save a large download.

License

Nixup is released under the GPL-3.0. It includes Termux's terminal code (Apache 2.0) and Nix-on-Droid's terminal options (MIT), ships Nix (LGPL 2.1) and uses Aurora OSS's gplayapi (GPL-3.0) for Google Play; see NOTICE.