Booting from ZFS

TL;DR: I have successfully installed NixOS to the eMMC storage of my aarch64 machine, but installing it to the NVMe is proving difficult.
Any help would be appreciated :slightly_smiling_face:

I have an aarch64 machine that has NixOS installed on it, on its eMMC drive to be precise. The machine also has 4 NVMe drives that back a ZFS zpool, which looks like this:

āÆ zfs list
NAME                        USED  AVAIL  REFER  MOUNTPOINT
rpool                      3.13G  10.7T   140K  none
rpool/data                 2.00M  10.7T   256K  none
rpool/home                  140K  10.7T   140K  legacy
rpool/nixos                3.13G  10.7T  3.13G  legacy

What Iā€™d like to is reinstall NixOS on the rpool/home and rpool/nixos datasets, which are meant to host the user home directories and NixOS filesystem root, respectively. I want to do this because ZFS backed by the NVMe SSDs is likely to be a lot more durable than the eMMC drive.

I mount the datasets, and the /boot-disk-to-be, with the following:

MNT="$(mktemp -d)"

āÆ sudo mount rpool/nixos $MNT
āÆ sudo mount rpool/home $MNT/home
āÆ sudo mount /dev/disk/by-uuid/0467-D8D4 $MNT/boot

Finally, I install with

āÆ cd ~/host-config-flakes/cumulus && \
    nix flake update && \
    sudo nixos-install --root "${MNT}" --flake .#cumulus

warning: Git tree '/home/j/host-config-flakes' is dirty
warning: Git tree '/home/j/host-config-flakes' is dirty
building the flake in git+file:///home/j/host-config-flakes?dir=cumulus...
warning: Git tree '/home/j/host-config-flakes' is dirty
installing the boot loader...
setting up /etc...
Copied "/nix/store/9kv0gfbf8gr2dfndqyx26nkv3s486l5j-systemd-257.2/lib/systemd/boot/efi/systemd-bootaa64.efi" to "/boot/EFI/systemd/systemd-bootaa64.efi".
Copied "/nix/store/9kv0gfbf8gr2dfndqyx26nkv3s486l5j-systemd-257.2/lib/systemd/boot/efi/systemd-bootaa64.efi" to "/boot/EFI/BOOT/BOOTAA64.EFI".
āš ļø Mount point '/boot' which backs the random seed file is world accessible, which is a security hole! āš ļø
āš ļø Random seed file '/boot/loader/random-seed' is world accessible, which is a security hole! āš ļø
Random seed file /boot/loader/random-seed successfully refreshed (32 bytes).
Created EFI boot entry "Linux Boot Manager".
setting up /etc...
setting up /etc...
setting root password...
New password:
Retype new password:
passwd: password updated successfully
installation finished!

So the install (including setting up the boot loader) ostensibly goes fine, without any visible errors.

I can also look at $MNT/ and see the file system structure created there. And when I try to boot the newly installed NixOS version, the boot menu will appear and I can select the latest NixOS generation from it, and itā€™ll appear to boot before blanking the screen (which in and of itself is expected behavior, no HDMI output support at this time).
The problem though is that Iā€™m supposed to be able to ssh into the machine, and I cannot.

The only difference between the configurations of 2 installs should be the 2 fileSystems blocks (1 block for eMMC, and 1 for ZFS + NVMe) in hardware.nix. And yet one works, the other doesnā€™t. So perhaps something is wrong with the way I configured NixOS to run on ZFS?

For completeness, here are my system config files:

# flake.nix

{
  description = "NixOS Configuration Flake (cumulus)";

  inputs = {
    # NixOS official package source, using the `nixos-unstable` branch
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
    # wezterm.url = "github:wez/wezterm/main?dir=nix";
  };

  outputs = inputs@{ self, nixpkgs, home-manager, ... }: {
    nixosConfigurations.cumulus = nixpkgs.lib.nixosSystem {
      system = "aarch64-linux";
      modules = [
        # Import the old config file so it will still take effect

        ./configuration.nix

        home-manager.nixosModules.home-manager

        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.j = import ./home.nix;

          # Optionally, use `home-manager.extraSpecialArgs`
          # to pass arguments to ./home.nix
        }
      ];
    };
  };
}
# configuration.nix

{ config, lib, pkgs, ... }:
{
  imports = [ ./hardware.nix ];

  boot = {
    kernelParams = [ "earlycon=efifb"  "boot=zfs" ];
    loader = {
      efi.canTouchEfiVariables = true;
      systemd-boot = {
        enable = true;
        configurationLimit = 16;
      };
    };
  };

  boot.supportedFilesystems.zfs = true;
  boot.zfs.forceImportRoot = true;
  boot.initrd.systemd.enable = true;
  boot.initrd.supportedFilesystems.zfs = true;
  #  services.zfs = {
  #    autoScrub.enable = true;
  #    autoSnapshot.enable = true;
  #  };
  networking.hostId = "54e492c4"; # See https://nixos.org/manual/nixos/unstable/options#opt-networking.hostId

  hardware = {
    enableRedistributableFirmware = true;
    deviceTree = {
      enable = true;
      name = "rockchip/rk3588-friendlyelec-cm3588-nas.dtb";
    };
  };

  # Set your time zone.
  time.timeZone = "Europe/Amsterdam";

  # Select internationalisation properties.
  i18n.defaultLocale = "en_US.UTF-8";

  # Enable the X11 windowing system.
  # services.xserver.enable = true;

  # Configure keymap in X11
  # services.xserver.xkb.layout = "us";
  # services.xserver.xkb.options = "eurosign:e,caps:escape";

  # Enable CUPS to print documents.
  # services.printing.enable = true;

  # Enable sound.
  # hardware.pulseaudio.enable = true;
  # OR
  # services.pipewire = {
  #   enable = true;
  #   pulse.enable = true;
  # };

  # Enable touchpad support (enabled default in most desktopManager).
  # services.libinput.enable = true;

  # Define a user account. Don't forget to set a password with ā€˜passwdā€™.
  users.users.j = {
    isNormalUser = true;
    initialPassword = "<SOME PASSWORD>"; 
    description = "j";
    extraGroups = [ "networkmanager" "wheel" "zmount" ];
    shell = pkgs.zsh;
    openssh.authorizedKeys.keys = [
      # SNIP
    ];
    packages = with pkgs; [];
  };

  users.users.git = {
    isNormalUser = true;
    description = "A user account to manage & allow access to git repositories";
    extraGroups = [ "git" "networkmanager" ];
    shell = "${pkgs.git}/bin/git-shell";
    openssh.authorizedKeys.keys = [
      # SNIP
    ];
    packages = with pkgs; [];
  };

  users.groups.zmount = {};  # used for mounting ZFS datasets

  users.defaultUserShell = pkgs.zsh;

  nix.settings.experimental-features = ["nix-command" "flakes"];

  # Allow unfree packages
  nixpkgs.config.allowUnfree = true;

  programs.zsh.enable = true;
  programs.zsh.ohMyZsh = {
    enable = true;
    plugins = [
      "git"
      "git-prompt"
      "man"
      "rsync"
      "rust"
      "ssh-agent"
    ];
    theme = "agnoster";
  };
  programs.zsh.shellInit = ''
    # Load SSH key
    # eval "$(ssh-agent -s)"             > /dev/null
    # ssh-add ~/.ssh/id_ed25519.cumulus 2> /dev/null

    # echo "šŸŖloaded system ZSH configurationšŸŖ"
  '';
  programs.zsh.shellAliases = { # system-wide
    l = "eza -ahlg"; # list
    lT = "eza -hlgT"; # tree
  };

  # List packages installed in system profile. To search, run:
  # $ nix search wget
  environment.systemPackages = with pkgs; [
    bat
    bottom
    cifs-utils
    samba
    curl
    emacs
    eza # nicer replacement for `ls`
    fd # An alternative for `find`
    git
    lm_sensors
    neofetch
    nix-tree
    ripgrep
    rsync
    tokei
    vim
  ];

  # Some programs need SUID wrappers, can be configured further or are
  # started in user sessions.
  # programs.mtr.enable = true;
  # programs.gnupg.agent = {
  #   enable = true;
  #   enableSSHSupport = true;
  # };

  services = {
    openssh = {
      enable = true;
      settings = {
        KbdInteractiveAuthentication = true;
        PasswordAuthentication = true;
        PermitRootLogin = "yes";
      };
    };
  };

  networking = {
    dhcpcd.enable = false;
    firewall.enable = false;
    hostName = "cumulus";
    useDHCP = false;
    useNetworkd = true;
    hosts = {
      # SNIP
    };
  };

  systemd = {
    network = {
      enable = true;
      networks = {
        enP4p65s0 = {
          address = [ "192.168.1.145/24" ];
          matchConfig = { Name = "enP4p65s0"; Type = "ether"; };
          gateway = [ "192.168.1.1" ];
        };
      };
    };

    services.internetAccess = {
      wantedBy = [
        # "multi-user.target"
      ];
      after = [
        "network.target"
        "multi-user.target"
      ];
      description = "Add internet access.";
      path = [pkgs.bash pkgs.iproute2];
      script = ''
        ip route add default via 192.168.1.1 dev enP4p65s0
      '';
      serviceConfig = {
        Type = "oneshot";
        User = "root";
        Restart = "no";
      };
    };

  };

  # Open ports in the firewall.
  # networking.firewall.allowedTCPPorts = [ ... ];
  # networking.firewall.allowedUDPPorts = [ ... ];
  # Or disable the firewall altogether.
  # networking.firewall.enable = false;

  # Copy the NixOS configuration file and link it from the resulting system
  # (/run/current-system/configuration.nix). This is useful in case you
  # accidentally delete configuration.nix.
  # system.copySystemConfiguration = true;

  # This option defines the first version of NixOS you have installed on this
  # particular machine, and is used to maintain compatibility with application
  # data (e.g. databases) created on older NixOS versions.
  #
  # Most users should NEVER change this value after the initial install, for
  # any reason, even if you've upgraded your system to a new NixOS release.
  #
  # This value does NOT affect the Nixpkgs version your packages and OS
  # are pulled from, so changing it will NOT upgrade your system - see
  # https://nixos.org/manual/nixos/stable/#sec-upgrading for how to
  # actually do that.
  #
  # This value being lower than the current NixOS release does NOT
  # mean your system is out of date, out of support, or vulnerable.
  #
  # Do NOT change this value unless you have manually inspected all
  # the changes it would make to your configuration, and migrated
  # your data accordingly.
  #
  # For more information, see `man configuration.nix` or
  # https://nixos.org/manual/nixos/stable/options#opt-system.stateVersion .
  system.stateVersion = "24.11"; # Did you read the comment?

}
# hardware.nix

{ config, lib, pkgs, modulesPath, ... }:
{
  imports = [
    (modulesPath + "/installer/scan/not-detected.nix")
  ];

  boot.initrd.availableKernelModules = [ "nvme" "usbhid" ];
  boot.initrd.kernelModules = [ "zfs" ];
  boot.kernelModules = [ ];
  boot.extraModulePackages = [ ];


  # Installing NixOS using these works: 
  # fileSystems."/" = { # eMMC
  #   device = "/dev/disk/by-id/mmc-A3A564_0xe6596dd2-part3";
  #   fsType = "ext4";
  #   # options = [ "lazytime" ];
  # };
  # fileSystems."/boot" = { # eMMC
  #   device = "/dev/disk/by-id/mmc-A3A564_0xe6596dd2-part2";
  #   fsType = "vfat";
  #   # options = [ "umask=0077" "fmask=0077" "dmask=0077" ];
  # };


  # But installing NixOS using these *FAILS*: 
  fileSystems."/" = {
    device = "rpool/nixos";
    fsType = "zfs";
  };
  fileSystems."/home" = {
    device = "rpool/home";
    fsType = "zfs";
  };
  fileSystems."/boot" = {
    device = "/dev/disk/by-uuid/0467-D8D4"; # points to /dev/nvme0n1p1
    fsType = "vfat";
    options = [ "fmask=0077" "dmask=0077" ];
  };

  swapDevices = [ ];

  # Enables DHCP on each ethernet and wireless interface. In case of scripted
  # networking (the default) this is the recommended approach. When using
  # systemd-networkd it's still possible to use this option, but it's
  # recommended to use it in conjunction with explicit per-interface
  # declarations with `networking.interfaces.<interface>.useDHCP`.
  ## networking.useDHCP = lib.mkDefault true;
  # networking.interfaces.eth0.useDHCP = lib.mkDefault true;
  # networking.interfaces.enP4p65s0.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
  powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
}

It seems like a ZFS issue, like somehow the zpool (named rpool) isnā€™t being mounted.
And no matter what I do (including using options like boot.zfs.extraPools = [ "rpool" ];) I canā€™t get the device to boot from ZFS.
I strongly suspect that if I can make it do so, everything will work as it should from there.

Does anyone have any ideas to make the machine boot from a ZFS?

EDIT: When I boot from eMMC, I can access the ZFS array and manipulate it, but only after I manually execute sudo zpool import -a. So maybe the equivalent of that command needs to happen at boot time.
The question is, how does one go about doing that?

Can you expand on this? Do you get to see any output during initrd? If you can keep the kernel using efifb for as long as possible so that you can debug initrd, that would help a lot.

I doubt itā€™s a ZFS issue. I bet thereā€™s some kernel module your initrd is missing thatā€™s necessary to access the nvme drives.

If youā€™ll forgive the horribly literal screen shot, is is all thatā€™s printed before the screen goes blank:

Zooming in on the text at the bottom, I see nothing there that even hints to me that initrd is being reached. Just that the earliest boot stage of Linux (where earlycon is loaded) is being reached.

Sure Iā€™d love more output, and Iā€™ll be happy to post that here too. How do I go about doing that on NixOS?

That is of course possible, but in both system confĆ­gs I load the ā€œnvmeā€ driver in boot.initrd.availableKernelModules (see hardware.nix in OP).
And that suffices for manually importing the zpool when I use the install on the eMMC drive.
Shouldnā€™t Linux autoload it when itā€™s needed in initrd?

I just tried updating my config to:

boot.initrd.availableKernelModules = [  ];
  boot.initrd.kernelModules = [ "zfs" "nvme" "usbhid" ]; 

That is I tried to make initrd load the modules regardless of what it thinks it needs. Alas, that yields the same result.

Yea I was wondering if thereā€™s some hardware driver youā€™re missing. Like maybe thereā€™s a kernel module for the pcie implementation that needs to be added as well. Hard to know for sure.

I wouldnā€™t expect a hardware driver to be the issue, per se.
The reason for that is that when I boot the NixOS-root-on-eMMC config (the other config being NixOS-root-on-ZFS) I can manually mount my zpool, mount my datasets and then use them as normal.
Since the 2 confĆ­gs are identical other than ZFS usage and which drives and partitions to use for / and /boot, if it was a driver issue, Iā€™d expect that driver issue to be present on the NixOS-on-ZFS config.

The biggest delta between the 2 confĆ­gs is ZFS usage and booting from ZFS. So for want of better data, that to me suggests where to look.

Thatā€™s because hardware drivers are available implicitly in stage 2. In stage 1, they have to be explicitly added to boot.initrd.availableKernelModules. If there is a missing driver in stage 1, you wouldnā€™t notice it in stage 2.

1 Like

That gave me an idea:

  • Boot into the NixOS-root-on-eMMC config
  • Do an lsmod to list all loaded kernel modules
  • Add all listed modules to boot.initrd.availableKernelModules. Thatā€™s a remarkably long list of 79 kernel modules, and a couple of entries that stand out (other than zfs and nvme) are nvme_core and nvme_auth.
  • Re/install the NixOS-root-on-ZFS config
  • Try to boot the newly installed NixOS

The result of all that is that it still wonā€™t boot properly, i.e. no meaningful change.

Iā€™m open to any other ideas you might have (:slight_smile:

Thatā€™s actually really informative, yea. Itā€™s hard to make any further progress without getting some kind of log output. If you can get the kernel to use efifb for longer, or if you can get serial access, that would help a lot.

Just a wild guess: could this whole thing be a matter of ā€žlegacyā€œ vs ā€žnon-legacyā€œ mounts?

i am on x86-64, and i use this to install nixos and boot from zfs

Well Iā€™m not sure how to accomplish this. If you can tell me how, Iā€™d be happy to do it.
As for serial access, I donā€™t currently own such cables, so at least for now that isnā€™t an option Iā€™m afraid.

I have tried setups both in legacy and in non-legacy modes. Doesnā€™t seem to make a difference.

Thank you, itā€™s useful to be able to compare.

Unfortunately I donā€™t see anything that might make a difference in terms of getting the device to boot from ZFS.

Did you try

boot = {
  initrd = {
    postDeviceCommands = ''
      sleep 1
      zpool import -f poolName     
    '';
  }; # initrd

  zfs = {  
    devNodes = "/dev/disk/by-partlabel";
  }; # zfs
}; # boot

I hadnā€™t before, so I just tried it.
Unfortunately Iā€™m met with an error complaining that systemd stage 1 doesnā€™t support the option boot.initrd.postDeviceCommands:

āÆ cd ~/host-config-flakes/cumulus/  &&  nix flake update  &&  sudo nixos-install --root "$MNT" --flake .#cumulus                                                     13:38:27.133824412
warning: Git tree '/home/j/host-config-flakes' is dirty
warning: updating lock file '/home/j/host-config-flakes/cumulus/flake.lock':
ā€¢ Updated input 'home-manager':
    'github:nix-community/home-manager/18fa9f323d8adbb0b7b8b98a8488db308210ed93?narHash=sha256-4ATtQqBlgsGqkHTemta0ydY6f7JBRXz4Hf574NHQpkg%3D' (2025-02-01)
  ā†’ 'github:nix-community/home-manager/f20b7a8ab527a2482f13754dc00b2deaddc34599?narHash=sha256-yXT82kERWL4R81hfun9BuT478Q6ut0dJzdQjAxjRS38%3D' (2025-02-05)
ā€¢ Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/3a228057f5b619feb3186e986dbe76278d707b6e?narHash=sha256-xvTo0Aw0%2Bveek7hvEVLzErmJyQkEcRk6PSR4zsRQFEc%3D' (2025-02-01)
  ā†’ 'github:NixOS/nixpkgs/799ba5bffed04ced7067a91798353d360788b30d?narHash=sha256-ooLh%2BXW8jfa%2B91F1nhf9OF7qhuA/y1ChLx6lXDNeY5U%3D' (2025-02-04)
warning: Git tree '/home/j/host-config-flakes' is dirty
warning: Git tree '/home/j/host-config-flakes' is dirty
building the flake in git+file:///home/j/host-config-flakes?dir=cumulus...
warning: Git tree '/home/j/host-config-flakes' is dirty
error:
       ā€¦ while calling the 'head' builtin
         at /tmp/tmp.6CQP6xIueq/nix/store/k8nkf470zpidpa5nh76lh2x6rxfzpwa4-source/lib/attrsets.nix:1574:11:
         1573|         || pred here (elemAt values 1) (head values) then
         1574|           head values
             |           ^
         1575|         else

       ā€¦ while evaluating the attribute 'value'
         at /tmp/tmp.6CQP6xIueq/nix/store/k8nkf470zpidpa5nh76lh2x6rxfzpwa4-source/lib/modules.nix:846:9:
          845|     in warnDeprecation opt //
          846|       { value = addErrorContext "while evaluating the option `${showOption loc}':" value;
             |         ^
          847|         inherit (res.defsFinal') highestPrio;

       ā€¦ while evaluating the option `system.build.toplevel':

       ā€¦ while evaluating definitions from `/tmp/tmp.6CQP6xIueq/nix/store/k8nkf470zpidpa5nh76lh2x6rxfzpwa4-source/nixos/modules/system/activation/top-level.nix':

       (stack trace truncated; use '--show-trace' to show the full, detailed trace)

       error:
       Failed assertions:
       - systemd stage 1 does not support 'boot.initrd.postDeviceCommands'. Please
         convert it to analogous systemd units in 'boot.initrd.systemd'.

           Definitions:
           - /tmp/tmp.6CQP6xIueq/nix/store/aifvf00dyn34wad04rq2x8qq78dz4r0w-source/cumulus/configuration.nix

And I donā€™t know nearly enough about systemd to do what it is asking i.e. to convert it to analogous systemd units in boot.initrd.systemd.

None of this should be necessary anyway.

As I said before, what we need is information. We cannot diagnose a system when all we know is that there is no output because HDMI is not expected to work.

Iā€™m not sure either, but my first guess would be adding video=efifb to kernel params.

This is a fairly good summary of the current state of things, as the Radxa 5B and the CM3588 both use the RK3588 chip. So if ZFS had support for Linux 6.13 it might be easier to diagnose.

It also suggests using uboot, and trying that may not be a bad Idea - if it works with ZFS it would already have an advantage over the status quo.

First things first, given that HDMI-out isnā€™t working, do you have access to a UART adapter? This will allow you to see the boot logs that you typically see over HDMI with the added benefit of scrolling up.

Some of the potential fixes+issues:

  1. We want to make sure that the ZFS datasets from the eMMC arenā€™t being mounted and causing some kind of conflict at boot. I suspect this because UUIDs for the devices arenā€™t mentioned and the dataset on both eMMC and NVMe are named the same.

  2. If your ZFS datasets do not have the ā€œlegacyā€ mount points, you will need to add options = [ "zfsutil" ]; under fileSystems.<name>.

  3. You might also want the phy_rockchip_naneng_combphy kernel module to be available in the initrd:

  boot.initrd.availableKernelModules = [
    "phy_rockchip_naneng_combphy"

    # I have these on **all** my machines
    "nvme"
    "usb_storage"
    "usbhid"
  ];

Thanks for the reply. Unfortunately I donā€™t have access to a UART cable at this time. But Iā€™ve ordered one, due to arrive in some days (same-day delivery is mostly a fable where I live).

  1. The eMMC disk has no ZFS and as such has no datasets, as thatā€™s a ZFS-specific concept. W.r.t. conflicts, I tend to mount eMMC mounts either by label or by id, and ZFS mounts are by dataset. In each of those cases I think it should be unique enough, though I could be wrong. I donā€™t mount by UUID mainly because those were somehow never generated when I created the partitions, and Iā€™d rather not do all that manual labor again as well as figure out why those UUIDs were never generated; All of which would be necessary in order to mount by UUID.
  2. I have tried mounting ZFS mounts both in legacy and non-legacy (ZFS-native?) mode. Neither work for me at the time of writing, so that might be a red herring altogether.
  3. This is worth a shot. Iā€™ll post the results when I have them.