Nix/NixOS is a declarative language for defining your entire operating system. I use it on my dedicated servers to be able to apply GitOps for the servers. I define my services in a Git repo, everything from what version of packages to use, to what services should be installed, and how they should be installed. Those servers run Kubernetes which is where most of my services live.
Nix is a beast. The language is quite complicated and I wrote about my challenges. While I’ve gotten used to the language, I still don’t consider it intuitive. With that out of the way, my next challenge is that if I run nix flake update, which updates the packages that come from NixPkgs (which is most packages that you install.) Then I don’t really know what’s changing.
The Problem
Every time I run nix flake update, I end up with a diff that looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| diff --git a/flake.lock b/flake.lock
index 125bbcf..c122eb0 100644
--- a/flake.lock
+++ b/flake.lock
@@ -134,11 +137,24 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1780036600,
- "narHash": "sha256-SSgsnJO46Tjaheiw7TnpQHtjO6rwbQZPbFYY2vLqk8A=",
+ "lastModified": 1767892417,
+ "narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
+ "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
+ "type": "tarball",
+ "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
+ },
+ "original": {
+ "type": "tarball",
+ "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1781071287,
+ "narHash": "sha256-QacjyL0WxppiT8eqIwzIE836MdhByxq+CVSsDNm3Fkw=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "65b17bdb9959018296a1afc624695168414f4db9",
+ "rev": "f3c00d2737ae70055f5207e2421c46eb5fec7876",
"type": "github"
},
"original": {
|
This doesn’t convey anything meaningful. Is it a small change? A big change? Am I introducing breaking changes? Are any of my services going to restart?
It’d be great if there was a way to compare the difference in my Nix roots to see what actually changed.
There are a few tools that I found that already handle this.
Both tools work by partially building (without actually compiling) the old version and new versions.
1
2
3
4
5
6
| HOST="srv9"
NEW_PATH=$(nix path-info --derivation "path:.#nixosConfigurations.${HOST}.config.system.build.toplevel")
# Switch to old version
OLD_PATH=$(nix path-info --derivation "path:.#nixosConfigurations.${HOST}.config.system.build.toplevel")
|
1
| nix run "github:NixOS/nixpkgs#dix" --inputs-from path:. -- "$OLD_PATH" "$NEW_PATH"
|
The first tool I tested called nix-diff, gave me a list of changed, added, and removed nixpkgs. Though, it becomes hard to read because Nixpkgs includes numerous changes of both packages that are important (e.g. etcd is changing), mixed in with numerous single patch files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| Old: /nix/store/p4d9k8b9bdlkxb2rnk7xgxl9phfh0p7n-nixos-system-srv9-26.11.20260529.65b17bd.drv
New: /nix/store/ks9wfpigs9s2qwxxwmp9hfymyy1hcp0i-nixos-system-srv9-26.11.20260614.6f11897.drv
<<< /nix/store/p4d9k8b9bdlkxb2rnk7xgxl9phfh0p7n-nixos-system-srv9-26.11.20260529.65b17bd.drv
>>> /nix/store/ks9wfpigs9s2qwxxwmp9hfymyy1hcp0i-nixos-system-srv9-26.11.20260614.6f11897.drv
CHANGED
...
[U.] etcd 3.6.11 -> 3.6.12
[U.] etcdctl 3.6.11-go-modules, 3.6.11 -> 3.6.12-go-modules, 3.6.12
[U.] etcdserver 3.6.11-go-modules, 3.6.11 -> 3.6.12-go-modules, 3.6.12
...
ADDED
...
[A.] equivalent 1.0.2
[A.] indexmap 2.14.0
...
REMOVED
[R.] 100-fix-gcc14-build.patch <none>
[R.] 2010_add_build_timestamp_setting.patch <none>
[R.] 20_no_Werror.diff <none>
[R.] 2251737b3b175925684ec0d37029ff4cb521d302.patch <none>
[R.] 30_ag_macros.m4_syntax_error.diff <none>
...
[R.] adns 1.6.1.tar.gz, 1.6.1
[R.] alsa-lib 1.2.15.3.tar.bz2, 1.2.15.3
[R.] alsa-plugin-conf-multilib.patch <none>
...
PATHS: 4672 -> 4421 (+596, -847)
SIZE: 230 MiB -> 230 MiB
DIFF: 232 KiB
|
1
| nix run "github:NixOS/nixpkgs#nix-diff" --inputs-from path:. -- "$OLD_PATH" "$NEW_PATH"
|
The next tool, confusing named nix-diff, works the same way as before–given two Nix derivations paths, it computes the changes between them. Except it visualizes it different. Here’s a flake update:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| /nix/store/p4d9k8b9bdlkxb2rnk7xgxl9phfh0p7n-nixos-system-srv9-26.11.20260529.65b17bd.drv:{out}
+ /nix/store/ryyfi5k2ayl9dzyndhl4accyjajmzvil-nixos-system-srv9-26.11.20260619.27a095f.drv:{out}
• The set of input derivation names do not match:
- initrd-linux-6.18.33
- linux-6.18.33
- linux-6.18.33-modules
+ initrd-linux-6.18.36
+ linux-6.18.36
+ linux-6.18.36-modules
• The input derivation named `activate` differs
- /nix/store/7jzqzanvshv7m21mj79ajrsk7bajbmvp-activate.drv:{out}
+ /nix/store/zq212jmvldndzacb2ajvf14xpbpqgf4k-activate.drv:{out}
• The input derivation named `bash-interactive-5.3p9` differs
- /nix/store/sqwn65xb0ymbbhfbvcsksy7py02pd1gd-bash-interactive-5.3p9.drv:{out}
+ /nix/store/qq9ryli5b3babf4nxrv32i62bcr6vihp-bash-interactive-5.3p9.drv:{out}
• The input derivation named `bash-5.3.tar.gz` differs
- /nix/store/rzkd3k18k5yjhqqq6qn8633za14dz9nw-bash-5.3.tar.gz.drv:{out}
+ /nix/store/3fihvbzw6j53l3xqi5168kkhnxd4a49i-bash-5.3.tar.gz.drv:{out}
• The input derivation named `mirrors-list` differs
- /nix/store/84w0zzmqzl2bn5s4rx7axsh9znws1rdf-mirrors-list.drv:{out}
+ /nix/store/qkrfwhcrn7af1w11gxh3m45xjsgxlrxz-mirrors-list.drv:{out}
continues for thousands of lines
|
It can even show the files differences. Here’s an example where I changed a network configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| - /nix/store/p4d9k8b9bdlkxb2rnk7xgxl9phfh0p7n-nixos-system-srv9-26.11.20260529.65b17bd.drv:{out}
+ /nix/store/v563pm583hzk7ghfz1ngv69m0p23p4dp-nixos-system-srv9-26.11.20260529.65b17bd.drv:{out}
• The input derivation named `activate` differs
- /nix/store/7jzqzanvshv7m21mj79ajrsk7bajbmvp-activate.drv:{out}
+ /nix/store/cm2blp83xb0gkbkkz88w62sgc23f7hi4-activate.drv:{out}
• The input derivation named `etc` differs
- /nix/store/zra979j9iamynnq6mhjn9rc14mp2mnrn-etc.drv:{out}
+ /nix/store/7j8y5xpdaji14vf7xzrwrp25b474pmkq-etc.drv:{out}
• The input derivation named `system-units` differs
- /nix/store/0m2yxv4p5g09918c09381c93y292rdcz-system-units.drv:{out}
+ /nix/store/kbpc9238p0g928s30m3kgkdhw4b8xrx8-system-units.drv:{out}
• The input derivation named `unit-systemd-networkd.service` differs
- /nix/store/bg1p3bxk2yld11llqnspwkn9868i9gps-unit-systemd-networkd.service.drv:{out}
+ /nix/store/s59rxwijqw9j5ysqvigzqbw07ny7ia1s-unit-systemd-networkd.service.drv:{out}
• The input derivation named `X-Reload-Triggers-systemd-networkd` differs
- /nix/store/ifx8cnd1v84kjp688mcgasnzzkxpaslj-X-Reload-Triggers-systemd-networkd.drv:{out}
+ /nix/store/mgyvv9ysg6zfplllrybpcv08b4zn75ny-X-Reload-Triggers-systemd-networkd.drv:{out}
• The input derivation named `unit-ens18.network` differs
- /nix/store/kwmg7j0hx45m3mrv0wkl0ang8injvp0n-unit-ens18.network.drv:{out}
+ /nix/store/vh06fqvmnyva7q5hp3sc1jz19wa33m7j-unit-ens18.network.drv:{out}
• The environments do not match:
text=''
[Match]
Name=ens18
[Link]
RequiredForOnline=routable
[Network]
←Address=15.204.37.15/29←→Address=15.204.37.16/29→
Address=2604:2dc0:200:fa4:beef:beef:beef:beef/80
[Route]
←Destination=15.204.37.15/29←→Destination=15.204.37.16/29→
Scope=link
←Source=15.204.37.15←→Source=15.204.37.16→
[Route]
Destination=51.81.243.254/32
Scope=link
←Source=15.204.37.15←→Source=15.204.37.16→
[Route]
Destination=2604:2dc0:200:0fff:00ff:00ff:00ff:00ff/64
Source=2604:2dc0:200:fa4:beef:beef:beef:beef
[Route]
Gateway=51.81.243.254
GatewayOnLink=true
[Route]
Gateway=2604:2dc0:200:0fff:00ff:00ff:00ff:00ff
GatewayOnLink=true
''
• Skipping environment comparison
• Skipping environment comparison
• Skipping environment comparison
• Input derivations differ but have already been compared
unit-ens18.network
• Skipping environment comparison
• Skipping environment comparison
• The input derivation named `boot.json` differs
- /nix/store/sx2bwx5zgx55ks8mm21mmiambk2igb70-boot.json.drv:{out}
+ /nix/store/8l3qfak4dp05yi30d0iw7w479wq8iad9-boot.json.drv:{out}
• The input derivation named `initrd-linux-6.18.33` differs
- /nix/store/fqn732pisgd8xcfmgmp1dwr5v7h4ym5c-initrd-linux-6.18.33.drv:{out}
+ /nix/store/3dpp66v2k9dr0vibhxc9p92m7flnqksp-initrd-linux-6.18.33.drv:{out}
• Input derivations differ but have already been compared
unit-ens18.network
• Skipping environment comparison
• Skipping environment comparison
|
Executing in a script
When I’m performing nix flake updates, I run the following script to compare the difference between my previous commit and current unstaged changes using a git stash.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| #!/usr/bin/env bash
set -euo pipefail
HOST="${1:-srv9}"
echo "Getting current system derivation..."
NEW_PATH=$(nix path-info --derivation "path:.#nixosConfigurations.${HOST}.config.system.build.toplevel")
echo "Stashing changes and getting previous system derivation..."
git stash
OLD_PATH=$(nix path-info --derivation "path:.#nixosConfigurations.${HOST}.config.system.build.toplevel")
git stash pop
echo "Comparing system between:"
echo " Old: $OLD_PATH"
echo " New: $NEW_PATH"
nix run "github:NixOS/nixpkgs#nix-diff" --inputs-from path:. -- --color=never "$OLD_PATH" "$NEW_PATH"
|
Both Nix diffing tools give a huge amount of output for nix flake updates that it can be a overwhelming. While neither really tell me what I need to know (i.e. does Kubernetes get rebuilt or restarted?), they’re still useful to consult. I generally use the dix more frequently for nix flake updates and nix-diff when I’m developing a single derivation.
Integrating with Forgejo Actions Workflows
As part of my Nix GitOps repository, I was already running nix flake check to ensure that my configuration was valid. Next, I want to setup automatically weekly nix flake update operations that create a pull request that I manually review and merge.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| on:
push:
pull_request:
jobs:
build:
name: Build Nix targets
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2
- name: Check Nix flake inputs
uses: https://github.com/DeterminateSystems/flake-checker-action@v12
- name: Build default package
run: nix flake check --no-update-lock-file
- uses: https://github.com/natsukium/nix-diff-action@v1.1.0
if: github.event_name == 'pull_request'
with:
attributes: |
- displayName: srv9
attribute: nixosConfigurations.srv9.config.system.build.toplevel
|
With the above workflow, I get comment on each pull request showing the changed packages:

Conclusion
While Nix and NixOS provides strong declarative infrastructure management through GitOps, the developer experience still represents a friction point for me. While I can see a Git diff on flake.lock, there’s no meaningful information in it.
Tools like dix and nix-diff bridge this gap by deriving meaningful comparisons from actual system derivations. They transform cryptic hash changes into actionable information: listing updated packages (with version deltas), highlighting configuration file differences, and revealing dependency shifts. Though their output can be verbose, they provide some information on what’s actually changing so I can then go off and see if there’s any actual breaking changes. Though I rarely find the time to do that and opt for deploy and pray.
Comments
To give feedback, send an email to adam [at] this website url.
Donate
If you've found these posts helpful and would like to support this work directly, your contribution would be appreciated and enable me to dedicate more time to creating future posts. Thank you for joining me!
