Home Manager:声明用户环境、切换 Generation 与安全回滚
换一台电脑后,真正难复制的不是软件清单
一位开发者在新电脑上装好了 Git、终端和编辑器,却仍花了半天追赶旧机器:Git 别名散落在 ~/.gitconfig,Shell 环境变量藏在 profile,用户服务由手工命令启动,同名配置文件还有几份年代不明的备份。把整个 home 目录同步过去风险太大,只复制 dotfiles 又漏掉软件包和服务;更麻烦的是,一次错误覆盖可能连原文件来自哪里都说不清。
Home Manager 把用户级 packages、dotfiles、环境变量、用户服务和其他 home 文件写成 Nix module,构建为不可变 Store 对象,再通过 activation 投影到用户目录与 profile。它能让变更先构建、后激活,并为成功切换留下 generation;但它不是系统配置、设备管理、备份或秘密管理器。宿主 OS、磁盘加密、企业策略和运行时凭证仍有各自的 owner。
先决定谁有权切换这个 home
Home Manager 有三种主要接入方式。差异不只是安装命令,而是“谁批准一次用户环境变更、哪个 generation 承担回滚”的所有权。
standalone 由登录用户执行 home-manager build 和 home-manager switch,维护独立于系统的 Home Manager generations。它适合普通 Linux、macOS、WSL2,也适合希望用户配置与 NixOS 或 nix-darwin 系统配置分开演进的人。原生 Windows 不是 Home Manager 的目标平台;Windows 开发机应在受支持的 WSL Linux 环境里按 Linux 边界使用。
NixOS module 把用户配置并入 nixosSystem。管理员通过 nixos-rebuild switch --flake ... 构建系统,Home Manager 再由系统激活服务处理用户配置。nix-darwin module 同理,入口是 darwin-rebuild switch --flake ...。这两种模式适合系统与用户环境必须一起评审、一起发布的机器;同一份 home 一旦由系统 module 接管,就不要再让用户对它执行 standalone home-manager switch,否则两个 profile 与两套回滚入口会争夺所有权。
选择时可以用一个简单事实判断:用户是否应该在没有管理员发布系统配置的情况下独立切换 dotfiles?答案为“是”时偏向 standalone;答案为“否”,且系统本来就由 NixOS 或 nix-darwin 声明管理时,module 模式能减少版本与发布入口的分叉。官方的 安装模式总览 可用于核对目标平台和当前安装入口。
从 standalone Flake 跑通第一条链路
下面的配置应放在一次性测试账号或可恢复的 home 中,不要直接拿日常账号的真实 dotfiles 做第一次 switch。机器需要可用的 Nix,并启用 nix-command 与 flakes;先运行 nix --version 和 nix flake --help。多用户 Nix 安装还要确认登录用户位于 allowed-users 允许集合内,否则“能读取配置”仍可能在真正构建时被 daemon 拒绝。Home Manager 的稳定分支应与 Nixpkgs 稳定分支对齐,开发分支则与 unstable Nixpkgs 对齐。团队仓库应固定明确分支并提交 flake.lock,而不是长期依赖浮动引用。
在 ~/.config/home-manager/flake.nix 写入一个最小 standalone 输出。把 system、用户名、home 路径和分支替换为测试账号的真实值;Linux 通常是 /home/alice,macOS 通常是 /Users/alice。
{
description = "managed user environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, home-manager, ... }: {
homeConfigurations."alice" = home-manager.lib.homeManagerConfiguration {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
modules = [ ./home.nix ];
};
};
}follows = "nixpkgs" 让 Home Manager 和当前配置使用同一个 Nixpkgs input,避免 lock graph 中无意出现两套 Nixpkgs;它不替代兼容测试。再创建 home.nix:
{ pkgs, ... }:
{
home.username = "alice";
home.homeDirectory = "/home/alice";
home.packages = [ pkgs.jq ];
home.file.".config/hm-lab/marker".text = "generation-one\n";
programs.git = {
enable = true;
userName = "Example Developer";
userEmail = "developer@example.invalid";
};
programs.home-manager.enable = true;
home.stateVersion = "26.05";
}示例使用 Home Manager 开发线与 nixos-unstable,适合一次性实验;生产仓库应把两者换成互相匹配的稳定 release 分支。26.05 在这里是 Home Manager 的正式兼容版本标识,不是文章日期。home.stateVersion 也不是已安装的 Home Manager 版本,更不是“越新越好”的功能开关。它选择一组兼容默认值,应记录为这个 home 首次启用 Home Manager 时采用的值。以后升级 Home Manager 或 Nixpkgs 时通常保持不变;只有 release notes 明确要求迁移、配置已完成相应改造时,才单独评审是否改变它。首次建档应从所选分支的 升级说明 核对合法值,而不是复制陌生仓库的数字。
先锁输入,再只构建:
cd ~/.config/home-manager
nix flake lock
git diff -- flake.lock
home-manager build --flake .#alice
test -x result/activate
echo "exit=$?"预期 build 成功后出现指向 activation package 的 result,result/activate 可执行,而 ~/.config/hm-lab/marker 尚未因 build 改变。build 会求值 module、做类型和 assertion 检查、实现所需 Store 闭包,却不把结果投影到 home。这正是变更进入日常账号前的安全检查点。
确认 diff、Store 闭包和目标文件都合理后再激活:
home-manager switch --flake .#alice
home-manager generations
cat ~/.config/hm-lab/marker
jq --version预期 marker 为 generation-one,jq 可从用户环境解析到,并且 generations 列表新增一项。这里的预期来自配置语义,执行者仍应保存真实退出码、generation 路径和目标文件摘要,不能只凭终端最后一行判断成功。
如果机器还没有 home-manager 命令,可以按 standalone 安装说明 使用匹配分支的 nix run home-manager/<branch> -- init --switch 建立初始配置。团队落地后仍应通过锁定的 Flake 输出运行,不让每位开发者自行选择分支。
一个类型错误为什么不该碰到 home
把 programs.git.enable = true; 暂时改成字符串:
programs.git.enable = "yes";然后只运行 build:
home-manager build --flake .#alice
echo "exit=$?"
cat ~/.config/hm-lab/marker预期 build 非零退出,错误证据会指向 programs.git.enable 的类型不匹配;现有 marker 仍保持上一 generation 的内容。这个反例区分了三个阶段:求值和 module 校验失败时没有 activation package;构建成功才得到 activation;只有 switch 才会执行 activation。遇到 unknown option、assertion failure 或包构建失败,也先按这条阶段轴定位,不要用反复 switch 代替诊断。
修正布尔值后,把 marker 改为 generation-two,再次先 build 后 switch。此时可以验证真正的回滚:
home-manager build --flake .#alice
home-manager switch --flake .#alice
cat ~/.config/hm-lab/marker
home-manager switch --rollback
cat ~/.config/hm-lab/marker
home-manager generations第二次 switch 后预期读到 generation-two;rollback 后,home-manager generations 中的 (current) 应移动到前一项,marker 恢复为 generation-one。如果 marker 变了而 current 指针没有移动,或指针移动但用户服务仍使用新配置,都不能判定回退完成,应保存 activation 输出并检查对应服务日志。当前 Home Manager 的 回滚说明 将 switch --rollback 定义为回到上一 configuration;它不是任意历史版本选择器,也不等于把 Git 仓库和 flake.lock 一起退回。
rollback 重新激活旧 generation,并不撤销所有外部副作用。用户服务、dconf、程序自行写入的数据和激活脚本调用的外部系统,都可能需要模块专属的反向操作。配置声明若涉及这类状态,必须为它单独设计正反实验,不能把“generation 已切回”当成所有数据都已恢复。
手工文件冲突会在首次 activation 暴露
dotfiles 迁移最危险的时刻,是声明第一次接管已有文件。先在测试账号制造一个带摘要的手工文件:
mkdir -p ~/.config/hm-lab
printf 'manual-owner\n' > ~/.config/hm-lab/conflict
nix hash file --type sha256 ~/.config/hm-lab/conflict在 home.nix 增加同一路径:
home.file.".config/hm-lab/conflict".text = "managed-owner\n";再次执行 home-manager build --flake .#alice。build 可能成功,因为冲突发生在 activation 面对真实 home 文件时;随后执行 switch 时,默认冲突保护下预期应看到目标文件已存在一类明确失败,而不是静默覆盖。命令行备份选项或 module 配置可能改变具体行为,因此执行前必须按目标版本核对,并保留退出码、冲突路径、切换前后摘要和 generation 列表。
home-manager switch --flake .#alice
code=$?
printf 'exit=%s\n' "$code"
nix hash file --type sha256 ~/.config/hm-lab/conflict
home-manager generations如果切换失败,重新计算摘要并确认手工文件没有变化;若目标内容或 generation 已变化,应先停止迁移并按该版本的 activation 行为排查。确认原文件完整后,把它移入明确的迁移目录,再重新 switch:
mkdir -p ~/.local/state/home-manager-migration
mv ~/.config/hm-lab/conflict \
~/.local/state/home-manager-migration/conflict.manual
home-manager switch --flake .#alice
cat ~/.config/hm-lab/conflict不要把 force = true 当作批量迁移捷径。它会改变冲突处理意图,却不会判断旧文件是否有业务价值。更稳妥的项目接入流程是先列出拟接管路径,计算现有文件摘要,逐项决定“删除、迁移、由 module 生成、继续手工维护”,再把每批变更独立 build 和 switch。官方 使用说明 给出了 build、switch 与 generation 的当前命令入口,可用于核对目标版本行为。
NixOS:让系统发布同时拥有用户配置
已经使用 Flake 管理 NixOS 时,把 Home Manager input 放入系统 Flake,并导入 NixOS module:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, home-manager, ... }@inputs: {
nixosConfigurations.workstation = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit inputs; };
modules = [
./configuration.nix
home-manager.nixosModules.home-manager
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.alice = import ./home/alice.nix;
}
];
};
};
}useGlobalPkgs = true 让 Home Manager 使用系统配置的 pkgs,减少重复求值和 overlay 分叉;代价是用户配置不再能独立选择一套 Nixpkgs。useUserPackages = true 把用户包放入 /etc/profiles/per-user/$USER,团队应显式写出选择,不依赖默认值。
管理员先构建系统闭包,再切换:
sudo nixos-rebuild build --flake .#workstation
sudo nixos-rebuild switch --flake .#workstation这里 Home Manager 的用户环境属于 NixOS generation。切换后应同时记录 nixos-rebuild list-generations、目标系统 profile 和用户文件摘要;回退则通过 NixOS boot generation 或对应的 nixos-rebuild 回滚路径让系统与 home 保持一致,而不是让 alice 单独运行 home-manager switch --rollback。如果团队要求用户 dotfiles 独立发布,就退回 standalone 所有权,不要在 module 模式上叠加第二套发布命令。
nix-darwin:同样的 module,不同的系统边界
nix-darwin 的接入结构与 NixOS 相似,但系统输出和触发命令不同。把 Home Manager 作为 Darwin module 导入:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin.url = "github:LnL7/nix-darwin";
nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, nix-darwin, home-manager, ... }: {
darwinConfigurations.workstation = nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
home-manager.darwinModules.home-manager
{
users.users.alice.home = "/Users/alice";
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users.alice = import ./home/alice.nix;
}
];
};
};
}对应入口是:
darwin-rebuild build --flake .#workstation
darwin-rebuild switch --flake .#workstation此时 nix-darwin generation 拥有系统与 Home Manager 激活。切换前后应记录 darwin-rebuild --list-generations、当前系统链接与受管文件摘要,回退也由系统入口完成。macOS 的偏好设置、Keychain、GUI 应用数据和 launchd 外部状态并不会因为 module 接入就自动获得同等回滚保证;每个相关 module 都要核对其写入方式和可逆性。若只想跨多台 Mac 管理用户 dotfiles,而系统配置由 MDM 或人工维护,standalone 通常拥有更清楚的责任线。
把 home 配置接进项目,而不是复制项目环境
Home Manager 适合管理“跟用户走”的工具入口,例如 Git 配置、Shell、编辑器通用配置、SSH 客户端声明和用户服务。项目特有的编译器、依赖库、环境变量与任务命令更适合放在项目 Flake 的 devShells 中。否则两个项目需要不同 Node.js 或不同代码生成器时,用户级包又会变成新的全局环境。
一种可维护的仓库布局是让系统与用户配置引用同一个 home/alice.nix,而每个业务仓库保留自己的 flake.nix:
environment-config/
├── flake.nix
├── flake.lock
├── hosts/
│ └── workstation.nix
└── home/
├── alice.nix
└── modules/
├── git.nix
└── shell.nixCI 对环境仓库只做无副作用构建和检查,不执行 switch:
nix flake metadata --no-update-lock-file >/dev/null
nix build --no-update-lock-file \
'.#homeConfigurations.alice.activationPackage'NixOS 或 nix-darwin 仓库则构建对应 system output。部署仍由拥有目标机器权限的发布入口执行。PR 应展示 flake.lock diff、module diff、目标机器、拟接管路径与 generation 回退点;包含 shellHook、activation script 或用户服务时,还要像审查代码一样审查它们的命令和外部副作用。
Secret 不能因为“声明式”就进入 Store
Nix Store 通常对本机所有用户可读,Store 对象还可能被上传到 binary cache。只要 token、私钥、生产配置或 .env 进入 Nix 字符串、Flake source、derivation environment,或由 home.file.<name>.text 生成,就可能永久出现在 Store 中。文件最终链接到 0600 路径,也无法抹掉 Store 副本。
Home Manager 应只声明秘密的消费接口,例如程序从 $XDG_RUNTIME_DIR/app/token、受权限控制的绝对路径或专用秘密代理读取;明文值由登录会话、Keychain、systemd credentials、短期环境注入或专门的秘密管理工具在运行时提供。不要在 home.sessionVariables、programs.* 明文字段、仓库 tracked 文件或 CI 日志里放真实值。
权限也不止文件 mode。能够修改环境仓库的人可以改变用户服务、Shell 初始化和 activation script;能够执行 NixOS 或 nix-darwin switch 的人还能推动系统级变更。团队应分别保护“提交配置”“更新 lock”“写 binary cache”“切换个人 home”“发布系统 generation”这些权限,外部 PR 不应在持有生产凭证的 Runner 上直接求值并执行未审查代码。
Generation 是回滚资产,也是容量成本
每次成功 switch 通常创建新的 profile generation。旧 generation 的链接是 GC root,它引用的 Store 闭包及可达依赖不会被垃圾回收。这解释了为什么回滚很快,也解释了为什么长期升级后 /nix/store 持续增长:退出终端、删除源码目录或卸载一个 package 声明都不会自动释放仍被旧 generation 引用的对象。
清理前先观察,不要在共享机器上直接删除全部旧 generations:
home-manager generations
nix-store --gc --print-live
nix-store --gc --print-dead先按团队保留策略删除确认不再需要的 Home Manager generations,再重新查看 dead 集合,最后才执行受控 GC。删除动作要使用 Home Manager 自己的 generation 入口,并在执行前保存列表:
home-manager generations > hm-generations.before.txt
# 按明确 ID 删除:先从上面的列表取得 ID
read -r -p 'Generation ID to remove: ' generation_id
home-manager remove-generations "$generation_id"
# 或按团队保留窗口删除:home-manager expire-generations '-30 days'
home-manager generations
nix-store --gc --print-deadexpire-generations 的相对时间只是操作表达式,实际窗口必须由团队的离线重建成本和故障恢复目标决定。删除 generation 只移除回滚根,不会立刻回收仍被其他 roots 引用的闭包。nix-collect-garbage -d 会把清理扩大到旧 profile generations,可能同时消灭故障复现和快速回滚所需闭包,不应作为无差别日常命令。
容量预算至少要看三条趋势:每次更新新增的闭包大小、各机器保留的 generation 数量、cache miss 时重新下载或构建的时间。固定“最多保留几个”不是通用答案;工作站、离线机器和共享构建机的回滚窗口不同。更有用的治理规则是给每类机器指定 owner、最低可回滚 generation、最长保留窗口、磁盘水位处置方式和冷重建演练。
升级要拆成锁更新、构建验证和激活
home-manager switch 不会自动把 Flake inputs 更新到新版本。升级应先在分支中显式移动 Home Manager 或 Nixpkgs input,审查 flake.lock,再构建 activation package;只有构建、目标文件 diff、用户服务检查和项目 smoke test 都满足预期,才在测试机器 switch。
git switch -c update-home-environment
nix flake update home-manager nixpkgs
git diff -- flake.lock
nix flake metadata --no-update-lock-file
home-manager build --flake .#alice
# NixOS: sudo nixos-rebuild build --flake .#workstation
# nix-darwin: darwin-rebuild build --flake .#workstation这里的输入名必须与 flake.nix 一致;旧 Nix CLI 若不支持一次列出多个 input,就分别执行 nix flake lock --update-input <name>,不能退回无选择地刷新整个锁图。Home Manager release branch 与 Nixpkgs 分支必须保持兼容组合,跨 release 线先读 release notes。home.stateVersion 保留初始兼容值,不能与 lock update 一起机械抬升。激活失败时先保留日志和 generation,再回到旧 lock 与配置提交;standalone 激活成功但业务工具异常时可重新激活旧 generation,module 模式则回退对应系统 generation。
升级失败的第一证据取决于阶段。module 类型、未知 option 和 assertion 错误出现在求值或 build;包失败可用 -L 查看构建日志;手工文件冲突出现在 activation;用户服务在 switch 后异常,则检查 systemd user 或 launchd 状态与日志。先标记失败阶段,再决定回滚配置、闭包还是外部状态,能避免把所有问题都归因于“Home Manager 坏了”。
退出 Home Manager 也要保留可恢复路径
实验清理先切回已知良好的 generation,确认终端、Git 和用户服务仍可用;再删除实验配置中的测试文件与包,build 并 switch 一次,让声明式状态自然移除。最后才处理 standalone 配置目录、旧 generations 和 Store GC。手工迁移备份必须先还原到普通文件,并验证它们不再是指向 Store 的符号链接。
如果团队决定完全退出 Home Manager,先导出当前受管路径、用户包和服务清单,把仍需要的文件复制为普通文件并重新设置权限,再停用 activation 入口。仅删除 ~/.config/home-manager 不会恢复被接管前的文件,也不会停止已经注册到外部系统的所有副作用。
Home Manager 最有价值的场景,是团队愿意把用户环境当作代码审查:输入有锁、变更先 build、接管文件有迁移证据、switch 有 owner、generation 有保留策略、秘密留在 Store 外。只需要同步少量文本配置时,受控 dotfiles 仓库成本更低;项目工具版本应交给 devShell;需要完整 OS 隔离时应使用 VM 或容器;需要企业设备合规、远程擦除和资产盘点时则应由设备管理系统承担。工具选型的关键不是声明了多少选项,而是谁能证明一次变更如何生效、失败停在哪一层,以及回退后还剩哪些外部状态。
