How I like to install NixOS (declaratively)

Michael Stapelberg

我如何以宣告式安裝 NixOS

在我的其中一次網路儲存電腦組裝中,我在尋找 Flatcar Container Linux 的替代方案,並再次嘗試了 NixOS(距離上次使用已將近十年)。安裝 NixOS 的方式有很多種,在本文中,我將說明我如何在實體硬體或虛擬機器上以自己偏好的方式安裝 NixOS:透過網路並以完全宣告式的方式。

前言:何謂宣告式?

declarative(宣告式)一詞指的是你描述「要達成什麼」,而非「如何達成」。以 NixOS 來說,這表示你要宣告希望系統包含哪些軟體(加入設定選項 environment.systemPackages,或啟用某個模組),而不是例如執行 apt install

declarative 方法的一個優點是,系統會遵循你的設定,因此只要還原設定變更,就能乾淨地還原系統上的變更。

我喜歡將 declarative 設定檔納入版本控制來管理,通常是使用 Git。

當初架設目前這套網路儲存系統時,我選擇了 CoreOS(後來的 Flatcar Container Linux),因為它是一個會自動更新的基礎系統,並具備 declarative 的 cloud-init 設定。

安裝 NixOS 的方式

圖形化安裝程式:僅適用於桌面環境

NixOS 手冊的「Installation」章節描述了圖形化安裝程式(「供桌面使用者使用」,以 Calamares 系統安裝程式為基礎,於 2022 年新增)以及手動安裝程式。

使用圖形化安裝程式時,很容易就能將 NixOS 安裝到磁碟上:只要不斷確認預設值,最後就會得到一個可運作的系統。但也有一些缺點:

  • 安裝完成後,你需要手動啟用 SSH——而且必須在本機操作,無法透過網路進行。
  • 圖形化安裝程式會為你產生一份初始的 NixOS 設定,但沒有辦法注入你自己的初始 NixOS 設定。

顯然,圖形化安裝程式並非為遠端安裝或自動化安裝而設計。

手動安裝

另一方面,手動安裝程式對我來說太過手動:可以展開NixOS 手冊「Installation summary」章節中的「Example 2」與「Example 3」來了解其過程。平心而論,這些步驟完全可行,但我不想在趕時間時用這種方式安裝系統。一來,手動流程在壓力下容易出錯。再者,互動式地複製貼上指令,根本就與撰寫 declarative 設定檔背道而馳。

網路安裝:nixos-anywhere

理想上,我希望大部分的安裝工作都能在自己的電腦上舒適地完成,這表示安裝程式必須能透過網路使用。此外,我希望機器在安裝完成後能立即以可運作的初始 NixOS 設定啟動(不需要任何手動步驟!)。

幸好,有一個(由社群提供的)解決方案:nixos-anywhere。你只需負責啟動 NixOS 安裝程式,然後執行一道指令,nixos-anywhere 就會透過 SSH 連入該安裝程式、分割你的磁碟並將 NixOS 安裝到磁碟上。值得注意的是,nixos-anywhere 是以 declarative 方式設定的,因此你可以隨時重複這個步驟。

(我知道 nixos-anywhere 甚至可以透過 SSH 連入任意系統並以 kexec 重新啟動至 NixOS 安裝程式,這當然是個很酷的炫技手法,但我更偏好明確啟動安裝程式的方式,因為在我看來這樣風險較低,也更通用、更可重複。)

設定:安裝 Nix

我想在其中一台機器上使用 NixOS,但(目前)不是在我的主要桌機上。

因此,我只在 Arch Linux 上安裝了 nix 工具(用於建置,即使不執行 NixOS):

% sudo pacman -S nix
% sudo groupadd -r nixbld
% for n in $(seq 1 24); do sudo useradd -c "Nix build user $n" \
    -d /var/empty -g nixbld -G nixbld -M -N -r -s "$(which nologin)" \
    nixbld$n; done
% sudo systemctl enable --now nix-daemon.socket

現在,執行 nix-shell -p hello 應該會讓你進入一個已安裝 GNU hello 套件的新 shell:

% export NIX_PATH=nixpkgs=channel:nixos-25.05
% nix-shell -p hello
hello

[nix-shell:/tmp]$ hello
Hello, world!

順帶一提,Arch Linux wiki 上的 Nix 頁面說明了如何使用 nix 安裝套件,但那不是我感興趣的部分:我只想遠端管理 NixOS 系統。

自行建置安裝程式

前面我說過「你只需負責啟動 NixOS 安裝程式」,這其實很簡單:將 ISO 映像檔寫入 USB 隨身碟並用它來啟動你的機器(或選擇 ISO 並啟動你的虛擬機器)。

但在能透過 SSH 遠端登入之前,我們需要手動設定密碼。我還需要以 TERM=xterm 環境變數來 SSH,因為 rxvt-unicode(我偏好的終端機)的 termcap 檔並未包含在預設的 NixOS 安裝程式環境中。同樣地,我設定的 locale 也無法運作,而我偏好的 shell(Zsh)也無法使用。

如果安裝程式能預先設定好一個方便的環境,不是會好得多嗎?

對於其他 Linux 發行版,像是 Debian、Fedora 或 Arch Linux,我不會嘗試重新建置官方的安裝程式 ISO 映像檔。我相信它們的流程與工具運作得很好,但我也確定那會是我需要額外學習、除錯與維護的東西。

但建置 NixOS 安裝程式與設定一般的 NixOS 系統非常相似:同樣的設定,同樣的建置工具。相關流程記載於官方 NixOS wiki

我複製了通常會放進 configuration.nix 的客製化設定,匯入了來自 nixpkgsinstallation-cd-minimal.nix 模組,並將結果放入 iso.nix 檔案中:

{ config, pkgs, ... }:

{
  imports = [
    <nixpkgs/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix>
    <nixpkgs/nixos/modules/installer/cd-dvd/channel.nix>
  ];

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];

    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "wheel" ];
    initialPassword = "SGZ3odMZIesxTuh2Y2pUaJA";  # random for this post
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
    rxvt-unicode  # for terminfo
    lshw
  ];

  programs.zsh.enable = true;
  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

要建置 ISO 映像檔,我將 NIX_PATH 環境變數設為指向 nix-build(1)iso.nix 檔案,並選擇 NixOS 25.05 的上游 channel:

% export NIX_PATH=nixos-config=$PWD/iso.nix:nixpkgs=channel:nixos-25.05
% nix-build '<nixpkgs/nixos>' -A config.system.build.isoImage

在我的2025 年高階 Linux 電腦上花了約 1.5 分鐘後,安裝程式 ISO 就會出現在 result/iso/nixos-minimal-25.05.802216.55d1f923c480-x86_64-linux.iso(以我的情況來說大小為 1.46 GB)。

啟用 Nix Flakes

可惜的是,nix 專案至今仍未預設啟用「實驗性」的新命令列介面(CLI),儘管它已經推出超過 5 年,因此我們需要建立一個設定檔並啟用現代的 nix-command 介面:

% mkdir -p ~/.config/nix
% echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf

要如何分辨新舊指令?舊指令是用連字號連接的(nix-build),新指令則是以空白分隔的(nix build)。

你會注意到我也啟用了 Nix flakes,我使用它來讓我的 nix 建置是 hermetic 且固定在特定版本的 nixpkgs 以及我想包含在建置中的任何其他 nix 模組上。我喜歡將 flakes 比作其他程式設計環境中的版本鎖定檔:其概念是,5 個月後再建置系統,結果會與今天相同。

要驗證 flakes 是否可運作,請執行 nix shell(不是 nix-shell):

% nix shell nixpkgs#hello
/tmp 2 % hello
Hello, world!

(重新)安裝步驟

供參考,以下是我在 Proxmox 中為 NixOS 建立新虛擬機器的設定。最重要的設定是 bios=ovmf(= UEFI 開機,不是預設值),這樣我才能在實體機器和虛擬機器上使用相同的開機載入程式設定:

Proxmox 虛擬機器建立對話框截圖

在啟動我們(未簽署的)安裝程式之前,我們需要進入 UEFI 設定並停用 Secure Boot。舉例來說,Proxmox 預設就會啟用 Secure Boot。

接著,在目標系統上啟動客製化的安裝程式 ISO,並確認 ssh [email protected] 可以在不要求輸入密碼的情況下運作。

宣告一個 flake.nix,內容如下:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";

    disko.url = "github:nix-community/disko";
    # Use the same version as nixpkgs
    disko.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    {
      nixpkgs,
      disko,
      ...
    }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
    in
    {
      nixosConfigurations.zammadn = nixpkgs.lib.nixosSystem {
        inherit system;
        inherit pkgs;
        modules = [
          disko.nixosModules.disko
          ./configuration.nix
        ];
      };
      formatter.${system} = pkgs.nixfmt-tree;
    };
}

disk-config.nix 中宣告你的磁碟設定:

disk-config.nix
{ lib, ... }:

{
  disko.devices = {
    disk = {
      main = {
        device = lib.mkDefault "/dev/sda";
        type = "disk";
        content = {
          type = "gpt";
          partitions = {
            ESP = {
              type = "EF00";
              size = "500M";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = [ "umask=0077" ];
              };
            };
            root = {
              size = "100%";
              content = {
                type = "filesystem";
                format = "ext4";
                mountpoint = "/";
              };
            };
          };
        };
      };
    };
  };
}

configuration.nix 中宣告你想要的 NixOS 設定:

{ modulesPath, lib, pkgs, ... }:

{
  imports =
    [
      (modulesPath + "/installer/scan/not-detected.nix")
      ./hardware-configuration.nix
      ./disk-config.nix
    ];

  # Adding michael as trusted user means
  # we can upgrade the system via SSH (see Makefile).
  nix.settings.trusted-users = [ "michael" "root" ];
  # Clean the Nix store every week.
  nix.gc = {
    automatic = true;
    dates = "weekly";
    options = "--delete-older-than 7d";
  };

  boot.loader.systemd-boot = {
    enable = true;
    configurationLimit = 10;
  };
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = "zammadn";
  time.timeZone = "Europe/Zurich";

  # Use systemd for networking
  services.resolved.enable = true;
  networking.useDHCP = false;
  systemd.network.enable = true;

  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";  # enp9s0 (10G) or enp8s0 (1G)
    networkConfig = {
      IPv6AcceptRA = true;
      DHCP = "yes";
    };
  };

  i18n.supportedLocales = [
    "en_DK.UTF-8/UTF-8"
    "de_DE.UTF-8/UTF-8"
    "de_CH.UTF-8/UTF-8"
    "en_US.UTF-8/UTF-8"
  ];
  i18n.defaultLocale = "en_US.UTF-8";

  users.mutableUsers = false;
  security.sudo.wheelNeedsPassword = false;
  users.users.michael = {
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5secret"
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5key"
    ];

    isNormalUser = true;
    description = "Michael Stapelberg";
    extraGroups = [ "networkmanager" "wheel" ];
    initialPassword = "install";  # TODO: change!
    shell = pkgs.zsh;
    packages = with pkgs; [];
  };

  environment.systemPackages = with pkgs; [
    git  # for checking out github.com/stapelberg/configfiles
    rsync
    zsh
    vim
    emacs
    wget
    curl
  ];

  programs.zsh.enable = true;

  services.openssh.enable = true;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "25.05"; # Did you read the comment?
}

…然後將其鎖定:

% nix flake lock
  1. 使用 nixos-anywhere,從安裝程式取得 hardware-configuration.nix 並將 NixOS 安裝到磁碟:
% nix run github:nix-community/nixos-anywhere -- \
  --flake .#zammadn \
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
  --target-host [email protected]

約一分鐘後,我的虛擬機器就安裝完成並重新啟動了!

完整 nixos-anywhere 安裝紀錄(如果你好奇的話)
% nix run github:nix-community/nixos-anywhere -- \      
  --flake .#wiki \                                                                 
  --generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
  --target-host [email protected]                                               
[... transcript truncated for brevity but contains full nixos-anywhere log ...]
### Installing NixOS ###
installing the boot loader...
setting up /etc...
Created "/boot/EFI".
installation finished!
### Rebooting ###
### Done! ###

安裝後步驟

現在系統中 declarative 的部分已經就緒,我們需要處理有狀態(stateful)的部分。

以我的情況來說,唯一需要設定的有狀態部分是 Tailscale mesh VPN。

要設定 Tailscale,我透過 SSH 登入並執行 sudo tailscale up。接著,我依照連結將新節點加入我的網路。之後,在 Tailscale Machines 控制台中,我停用金鑰到期並加入 ACL 標籤。

進行變更

現在,在我修改了設定檔中的某些內容後,我會透過遠端使用 nixos-rebuild 將變更部署到我的 NixOS 系統:

% nix run nixpkgs#nixos-rebuild -- \
  --target-host michael@zammadn \
  --use-remote-sudo \
  switch \
  --flake .#zammadn

請注意,並非所有變更都會在 nixos-rebuild switch 過程中完全套用:雖然 systemd 服務通常會重新啟動,但新需要的核心模組並不會自動載入(例如在 Frigate 中啟用 edgetpu coral 硬體加速器後)。

因此,為確保所有變更都已生效,請在部署變更後重新啟動(reboot)你的系統。

NixOS 的優點之一是,你可以在開機選單中選擇要執行哪一代的系統。如果最新的變更造成問題,你可以快速重新啟動至上一代來還原變更。當然,你也可以還原設定變更並部署新的一代——視當下情況選擇較方便的方式即可。

結論

透過本文,我希望能傳達當初剛開始使用 Nix 與 NixOS 時,希望有人能告訴我的事情:

  1. 啟用 flakes 與新的 CLI。
  2. 使用 nixos-anywhere 進行遠端安裝。
    • 如果想要的話,可以自行建置安裝程式,這很簡單!
  3. 使用 nixos-rebuild 內建的 --target-host 旗標進行遠端部署。

接下來該往哪裡去?

原文由 Michael Stapelberg 發布

本文章由 muse-spark-1.2-contributor 進行翻譯