Checks for system flake updates
  • C++ 45.4%
  • Shell 38.3%
  • Nix 14.9%
  • CMake 1%
  • C 0.4%
Find a file
2026-08-14 17:47:09 -04:00
nix Enforce minimum check cadence 2026-08-14 17:34:41 -04:00
scripts Harden transactional updater reliability 2026-08-12 07:39:01 -04:00
tests Enforce minimum check cadence 2026-08-14 17:34:41 -04:00
tray-cpp Format Nix package progress in details view 2026-08-14 13:57:32 -04:00
flake.nix Harden transactional updater reliability 2026-08-12 07:39:01 -04:00
LICENSE Create LICENSE 2026-07-22 18:21:18 -04:00
README.md Enforce minimum check cadence 2026-08-14 17:34:41 -04:00

nixos-updater

nixos-updater is an initial NixOS module for checking and updating the inputs of the system flake at /etc/nixos/flake.nix.

The privileged parts are root-owned oneshot systemd services. A check service periodically writes a candidate lock file in its state directory and compares it with the system lock file without mutating /etc/nixos. Automatic mode starts the apply service from a timer. Manual mode gives a configured local group permission to invoke only the immutable check, low, and high operations of a dedicated Polkit request broker. The broker chooses fixed systemd units and a fixed safe job mode; the group receives no generic systemd unit-management authority. The Plasma tray app stays unprivileged: it reads availability status, sends desktop notifications, and invokes that broker only when manual mode is enabled and its process is actually a member of the configured control group. Other graphical users keep read-only status and notification access without unusable manual controls.

NixOS use

Import the module and enable the paths you want:

{
  inputs.nixos-updater = {
    url = "path:/path/to/nixos-updater";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { nixpkgs, nixos-updater, ... }: {
    nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
      modules = [
        nixos-updater.nixosModules.default
        {
          nixos-updater = {
            enable = true;

            checks.calendar = "hourly";

            automatic = {
              enable = true;
              calendar = "daily";
            };

            manual.enable = true;
            tray = {
              enable = true;
              label = "System updates";
            };
          };

          users.users.alice.extraGroups = [ "nixos-updater" ];
        }
      ];
    };
  };
}

The NixOS module builds its default updater and tray packages from the consuming system's pkgs argument. This repository's nixpkgs input is used only for its own checks. Making that input follow the consuming system's nixpkgs, as above, avoids an independently updated check-only node in the system lock. Consumers that want package names in their own package set can also import the overlay:

nixpkgs.overlays = [ nixos-updater.overlays.default ];

checks.enable defaults to true, and checks.calendar defaults to hourly (one check per hour). Recurring checks must be at least 10 minutes apart. Check timers do not run missed checks after boot by default, so they do not add catch-up work to startup; set checks.persistent = true if you want missed checks to run after boot. The tray can also be enabled with only automatic mode when a read-only notification icon is desired. The C++ tray is the default implementation.

secured defaults to true. For the default /etc/nixos path, activation copies a user-owned flake tree into a child of a private, root-owned adjacent staging directory. It recursively assigns that child to root while the outer directory keeps the new inodes inaccessible to the former owner, then atomically exchanges the child into place. This migrates existing user-owned checkouts while disconnecting retained writable file descriptors from the configured source. The first activation of the current v3 migration protocol forces this exchange even when an older migration already made every entry appear root-owned. The source-parent ancestry is validated before any marker is trusted. After the source-parent filesystem is synchronized, a root-owned, private, path-scoped v3 marker with fixed contents makes later activations idempotent. Entry access modes and ACLs are preserved, so safe existing read and traversal access may remain; the replacement tree is root-controlled rather than necessarily private to root. A configured source symlink is replaced with an independent directory copy. Migration fails closed if the source does not contain flake.nix or a source ancestor is not exclusively root-controlled. Custom flakePath values are validated but not migrated unless nixos-updater.migrateSource = true is explicitly set. Before either a check or apply run invokes Nix, the updater verifies that the configured path, its ancestors, resolved and symlink targets, and entries below it are root-owned and have no extended ACLs. Source entries must have no group/other write bits; writable ancestors are accepted only when protected by the sticky bit. It refuses the run if that boundary is not intact. Absolute and resolvable relative local path inputs, plus local file: URLs recorded in the existing or newly generated lock file, receive the same validation. Local Git sources may use an ordinary root-owned .git directory, but secured mode rejects Git control-path indirection such as gitfiles/linked worktrees, common directories, alternate object stores, configuration includes or external worktrees, per-worktree configuration, and nested submodule control files. This boundary prevents an unprivileged local user from changing configuration that the updater evaluates as root through either visible source bytes or hidden Git authority paths.

The configured flake inputs and their maintainers are trusted. nixos-updater is intended for highly trusted ecosystem inputs and does not try to sandbox a hostile dependency graph: trusted NixOS configuration can arrange privileged code execution during activation by design. Likewise, a local or remote attacker who has already obtained root is outside this boundary. The updater instead avoids creating a privilege-escalation path from an unprivileged local account, and manual users can request only the broker's fixed update operations.

Set nixos-updater.secured = false only when retaining a user-owned development checkout is intentional. This preserves the previous behavior, but anyone who can modify the evaluated flake can arrange for code to run as root during an automatic or authorized manual apply. Disable it before the first secured activation; disabling it later does not restore the checkout's previous owner.

Set nixos-updater.experimental = true to use nh os build instead of nix build for the candidate build stage. Input checks and candidate-lock generation still use nix flake update. Both package variants activate the exact store path produced by the candidate build.

With manual.enable = true, users in nixos-updater.manual.controlGroup may request a check or apply run with:

$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request check
$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request low  # low-resource apply
$ /run/wrappers/bin/pkexec /run/current-system/sw/bin/nixos-updater-request high # unrestricted apply

pkexec resolves the current-system symlink before Polkit binds each operation to the immutable broker executable and its exact first argument. The broker rejects extra or unknown arguments, invokes only systemctl start --no-block --job-mode=replace for its fixed units, and admits at most one manual apply at a time. A second manual apply request fails promptly while the first is queued or running instead of creating another privileged process waiting on the updater lock. Repeated check requests target one fixed unit and are coalesced by systemd.

The check service uses an alternate output lock file:

# nix flake update --flake path:/etc/nixos --output-lock-file /var/lib/nixos-updater/candidate-lock

Both apply services generate a candidate lock, validate it in secured mode, build and activate against it while the canonical lock remains unchanged, and commit it only after successful activation. The principal commands are equivalent to:

# nix flake update --flake path:/etc/nixos --output-lock-file /var/lib/nixos-updater/apply-lock
# nix build --no-update-lock-file --option allow-dirty true \
    --reference-lock-file /var/lib/nixos-updater/apply-lock \
    --out-link /var/lib/nixos-updater/candidate-system \
    'path:/etc/nixos#nixosConfigurations."<hostname>".config.system.build.toplevel'
# nix build --no-link --profile /nix/var/nix/profiles/system /nix/store/<candidate-system>
# /nix/store/<candidate-system>/bin/switch-to-configuration switch
# cp /var/lib/nixos-updater/apply-lock /etc/nixos/.flake.lock.nixos-updater.<temporary>
# mv /etc/nixos/.flake.lock.nixos-updater.<temporary> /etc/nixos/flake.lock

With nixos-updater.experimental = true, the candidate build command is instead:

# nh os build --bypass-root-check --no-nom --diff never --no-specialisation \
    --hostname '<hostname>' \
    --out-link /var/lib/nixos-updater/candidate-system path:/etc/nixos -- \
    --no-update-lock-file --option allow-dirty true \
    --reference-lock-file /var/lib/nixos-updater/apply-lock

Experimental mode deliberately builds and activates the base NixOS configuration. It ignores an active /etc/specialisation selection rather than letting nh resolve a different closure than the candidate out-link.

The automatic timer starts nixos-updater-low@automatic.service, which runs with reduced CPU and IO priority and a memory limit of 50% of physical RAM. The check service uses the same resource limits. The fixed manual nixos-updater-high@manual.service runs without those resource restrictions. The shared process lock is kept in a root-only subdirectory of the otherwise readable state directory. Checks skip when another run is active. One accepted manual apply may wait for an automatic run to release the lock; further manual apply requests are rejected until that fixed manual unit is no longer queued or active. Apply units carry NixOS switch metadata that prevents an in-flight activation from stopping its own service if the candidate removes the updater. The two pre-template service names are retained as unselected compatibility units so upgrading from that layout is protected by the same rule.

The apply service skips the build when flake.lock is unchanged. A candidate generation, build, profile update, or activation failure leaves the canonical lock untouched. The next apply simply generates the latest candidate and tries again; Nix reuses already fetched inputs and built store paths when applicable. An interruption after successful activation but before the final rename may repeat activation on the next run. The final copy is created beside flake.lock, so the rename commits either the old complete lock or the new complete lock. When the tray is running, it sends a desktop notification after an attempted check or apply fails, and after an apply succeeds. Failed attempts are shown as retryable; the next apply regenerates the latest candidate rather than retaining the failed candidate as pending work. When the successfully built system has different kernel, kernel module, or initrd artifacts than the booted system, the completion notification also says that a reboot is required. A successful boot action reports the same whenever the installed closure differs from the running system, including userspace-only changes. The tray keeps showing that state until the next boot. rebuildAction can be set to boot or test instead of switch. The default switch action persists and activates the candidate. boot persists it for the next boot without switching the running userspace, while test activates it temporarily without updating the system profile. A successful selected action commits the candidate lock, so use switch when the update must remain active across reboots.

The tray uses themed update-status icons when available: update-none while current, update-low when updates are available, an upside-down update-none while applying, update-medium when a reboot is required, and update-high after a failed check or apply. Themes without those icons fall back to the normal updater icon. The tray requires a private, user-owned XDG_RUNTIME_DIR for its single-instance lock and fails closed rather than using a predictable shared temporary path.

The explicit path: flake reference avoids Git ownership checks. Raw local flake paths inside Git repositories are interpreted by Nix as git+file references instead. In the default secured mode, the updater's own ownership validation still applies before Nix evaluates that reference.

Current scope

  • The checker publishes whether flake input updates are available.
  • The apply service mutates the lock file in the configured flake directory.
  • The tray app can notify on availability and rebuild completion, request manual runs, and show the newest completed updater service result exposed by systemd.
  • Update previews, history, and rollback controls are not built yet.

Testing

Run the package, module, broker-policy, security, migration, and transactional-apply checks with:

nix flake check --no-write-lock-file