Skill v3.0.0
currentAutomated scan100/100+1 new
name: nix-ecosystem description: Use when writing Nix expressions, flake.nix, home-manager config, programs./services. modules, nixpkgs packaging, or nix flake check — including vacuous flake checks missing a target platform, overlays vs system packages, and activation-script hazards. version: 3.0.0
Nix traps that read as success. Everything here is a failure that leaves no error behind — a check that verified nothing, a pin that landed on the wrong version, an option that silently does not exist. Base language mechanics are assumed; this file carries only what gets it wrong.
Flakes
nix flake check passes vacuously off-platform
Per-system outputs exist only for the systems a flake enumerates, and nix flake check evaluates the outputs present for the evaluating system. On a platform the flake never listed there may be nothing at all to check, and the command exits 0 having verified nothing. An empty check set and a passing one are identical at the exit code.
The tell: the check succeeds in seconds on a developer machine, then fails in CI on the platform the flake actually targets.
Treat success on a platform absent from the flake's system list as non-evidence. Run natively on a supported system, or force full evaluation:
nix flake check --all-systems --no-write-lock-file --print-build-logs --keep-going
--all-systems evaluates every system's outputs; building foreign-platform derivations still needs a builder or substituter. Evaluation-only coverage already catches the common case — an output that does not evaluate on the target platform. Where a flake exposes Linux outputs only, the authoritative local check on Darwin is the language toolchain directly, with the flake gate deferred to Linux CI. Say which of the two ran.
Consume an overlay, not packages.${system}
When depending on a package from another flake, prefer its overlays.default. The packages output is keyed by system and exists only for the systems upstream enumerated, so inputs.tool.packages.${system}.default fails outright on any other platform. An overlay is system-agnostic by construction — a final: prev: function applied to the consumer's package set.
# Fragile: breaks as soon as the consumer's system is not in upstream's listpackages = [ inputs.tool.packages.${system}.default ];# Portable: the overlay builds against the consumer's own pkgspkgs = import nixpkgs {inherit system;overlays = [ inputs.tool.overlays.default ];};# then simply: pkgs.tool
The same asymmetry applies to any system-keyed output (lib.${system}, apps.${system}). A missing system key surfaces as attribute '...' missing during evaluation, which reads like a typo rather than a platform gap — check the upstream flake's system list before assuming the attribute path is wrong.
Pin by enumerating tags, not by "latest release"
On forges that distinguish a git tag from a published Release object, a "latest release" endpoint reports only tagged commits that also have a Release attached. A maintainer who tags without publishing one is invisible to it, and the pin silently lands on an older version while looking authoritative.
Resolve versions from tag listings — git ls-remote --tags <url>, or the forge's tags endpoint — never from a latest-release endpoint, and re-verify existing pins the same way when auditing for drift. Only the tag listing matches what a github:owner/repo/vX.Y.Z URL can actually resolve.
One dependency graph
Every input can declare its own inputs, each resolving independently by default. Without follows, flake.lock gains multiple nixpkgs nodes (nixpkgs, nixpkgs_2, …) pinned to different revisions, producing divergent core libraries (glibc, openssl, libgit2), binary-cache misses that force rebuilds from source, and occasional ABI mismatches between components meant to interoperate.
home-manager = {url = "github:nix-community/home-manager";inputs.nixpkgs.follows = "nixpkgs";};# Deeper override: force a grandchild input to the root as wellsome-tool.inputs.helper.inputs.nixpkgs.follows = "nixpkgs";
Exceptions are sometimes correct and should not be reflexively "fixed": a component incompatible with unstable can point at a stable channel, and a tool whose prebuilt closure lives in its own binary cache may need to keep its own nixpkgs during bootstrap so the cache actually hits. Keep each exception a single isolated node and record the reason inline with a link to the upstream issue. The undocumented divergence is the failure mode, not the divergence. If you see a package built twice with different hashes, or an unexpected rebuild-from-source after a flake update, inspect the lock for duplicated nixpkgs nodes.
Modules
The option namespace is decided by the importer
programs.* and services.* exist in both the NixOS and Home Manager module systems, under the same names but with genuinely different schemas. Which one an option path resolves to is decided by the module system that evaluated the file — not by the file, its directory, or its author's intent. A tree of "home" modules imported into a nixosSystem evaluates its programs.foo against NixOS's module, and any Home-Manager-only option inside it is an error.
Diagnostic: an option you know the program supports is reported as not existing. Before doubting the option name, follow the import chain upward to either nixosSystem / nixosModules (NixOS) or homeManagerConfiguration / home-manager.users.<name> (Home Manager). The option is usually fine; the namespace is the wrong one.
# Home Manager: prefix/shell exist, plugins take { plugin; extraConfig; } submodulesprograms.tmux = {prefix = "C-t";plugins = [{ plugin = pkgs.tmuxPlugins.resurrect; extraConfig = "set -g @resurrect-strategy-nvim 'session'"; }];};# NixOS: no prefix/shell — use shortcut; plugins is a plain package list,# so per-plugin @plugin-* variables must go into extraConfig insteadprograms.tmux = {shortcut = "t";plugins = with pkgs.tmuxPlugins; [ resurrect ];extraConfig = "set -g @resurrect-strategy-nvim 'session'";};
A module tree can land on the NixOS side unintentionally — for example because it also sets systemd.services and was therefore imported as a NixOS module rather than through home-manager.users.<name>. Sharing one tree across both requires either restricting it to options existing in both, or splitting the structurally divergent programs into per-system files. Assume divergence and check.
home.file makes declared settings read-only
A file placed with source or text is a symlink into the Nix store, and store files are read-only. For a GUI application whose settings file is managed this way, every key declared in Nix is enforced on every switch — but the application's own settings UI can no longer persist changes to those keys, because the write-back fails against a read-only symlink. More Nix-declared keys means less in-app control over exactly those toggles. Surface this tradeoff before declaring more keys, not after the user finds a switch that will not stick.
Some applications rewrite their whole settings file on quit; against a read-only symlink that write fails outright and can drop unrelated in-app state with it. Prefer a programs.* module where one exists, and declare only the keys that genuinely must be pinned.
A Home Manager module whose settings option has a freeformType accepts arbitrary undocumented keys without validating them, so a typo produces a silently ineffective key rather than an evaluation error. Verify key names against the application's own documentation.
Import-from-derivation breaks cross-platform modules
Any builtins.readFile / fromJSON / import applied to a path inside a derivation output — for example builtins.readFile "${nurPkgs.someTheme}/theme.conf" — forces that derivation to be built during evaluation. In a module shared across systems this is a trap: evaluating a Darwin configuration that imports the module triggers an aarch64-linux build merely to read a file, so evaluation now needs a Linux builder or fails outright, and a config that only wanted to render a text file drags in a foreign-platform closure.
A module intended for cross-platform consumption must be IFD-free. If one genuinely needs IFD, keep it vendored per-platform rather than promoting it into shared infrastructure. Make the failure explicit with --option allow-import-from-derivation false; IFD then errors at the offending expression instead of quietly starting a build. The other tell is an evaluation that pauses to build a derivation whose name carries a system you are not on.
Reading a file already in the source tree is ordinary evaluation, not IFD — pure and free.
Two imports that both set _module.args
If a bundle module internally imports a companion that sets _module.args.foo, importing that same companion again alongside the bundle in the same scope throws error: attribute '...foo' is defined multiple times. The module system does not dedupe two imports that both define a _module.args entry. Rely on the ambient one the bundle already provides. This surfaces at build time, not always at eval time.
Directory autodiscovery
When a module deploys many files sharing one directory structure, enumerate with builtins.readDir instead of listing each by hand, so adding a file needs no module edit.
letskillFileAttrs =letentries = builtins.readDir ./skills;names = builtins.filter (n: entries.${n} == "directory") (builtins.attrNames entries);inbuiltins.listToAttrs (map (name: {name = "app/skills/${name}/SKILL.md";value = { source = ./skills/${name}/SKILL.md; force = true; };}) names);in{ xdg.configFile = skillFileAttrs; }
readDir returns name-to-type ("directory" | "regular" | "symlink"); filter by type rather than assuming all entries are files. Flakes only see git-tracked files, so a newly created path is invisible to evaluation until git added — "works with nix build locally but the file is missing after switch" is almost always an un-added path.
Modules pulled in via imports = [ ./x ] must take ... in their signature so the module system can pass extra arguments.
Distribute cross-cutting constants through specialArgs (nixosSystem) or extraSpecialArgs (standalone homeManagerConfiguration) rather than relative imports; for a value only some modules need, _module.args.foo sets it locally without threading it through every signature.
Git hooks compose in two layers because git config scoping is local > global: a global layer (Home Manager programs.git.hooks setting core.hooksPath) applies everywhere, and a per-project layer overrides it inside that project. In hook scripts use NUL-delimited iteration (-z / -0 / --null) and prefer printf to echo.
Packaging
Derive the version from the manifest
When packaging a repository that already declares its version in a language manifest, derive the Nix version from that manifest. A hardcoded copy drifts silently — the build keeps succeeding while package metadata and the store path carry a stale version, and nothing ever fails to point it out. Observed in practice: a flake.nix still saying 0.1.0 long after the manifest had reached 0.1.7.
let cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);in rustPlatform.buildRustPackage {pname = cargoToml.package.name;version = cargoToml.package.version;src = ./.;cargoHash = "sha256-...";}
Per-ecosystem equivalents: fromJSON of package.json, fromTOML of pyproject.toml (project.version), a parsed *.cabal field.
Not for a foreign release fetched by tag. There the version is an input rather than an output: keep it a literal attribute (with finalAttrs or rec so rev = "refs/tags/v${version}" can interpolate it), because the manifest only becomes readable after the fetch has already been pinned by that same version.
Rust
overrideAttrs cannot change vendored dependencies by setting a new cargoHash — it has no effect after the fact. To override for a version bump you must override the resulting cargoDeps and set its outputHash.
On nixpkgs 25.05+ the fetchCargoVendor mechanism is the default and non-optional; a bare useFetchCargoVendor = true; is redundant and nixpkgs asks you to remove it. cargoHash is still required (or cargoLock for a path-based lockfile). Seed unknown hashes with lib.fakeHash, build, then copy the real hash from the mismatch error. cmake is commonly needed transitively (e.g. aws-lc-sys via rustls).
Darwin SDK
Nixpkgs replaced the per-framework Darwin inputs with a bundled versioned SDK. Drop unversioned darwin.apple_sdk.frameworks.* entries entirely — the default SDK is in the Darwin stdenv now — and add apple-sdk_NN to buildInputs only when a specific version is required. The SDK propagates libiconv/libresolv automatically. The legacy darwin.apple_sdk_11_0.* stubs have been removed and now error out.
JavaScript builder selection
| Situation | Builder | |
|---|---|---|
| npm lockfile, no build step | buildNpmPackage with npmDepsHash, dontNpmBuild = true | |
| npm lockfile, needs build | buildNpmPackage with npmDepsHash; finalAttrs when src needs the version | |
| pnpm lockfile | stdenvNoCC.mkDerivation + fetchPnpmDeps (hash + fetcherVersion) + pnpmConfigHook — the pnpm major used to fetch deps must match the one used to build | |
| bun lockfile | Two-phase: a fixed-output derivation runs bun install --frozen-lockfile, then the main build consumes it | |
| turbo / monorepo orchestrator | See the daemon trap below |
Use nodejs-slim_NN for the runtime wrapper and full nodejs_NN for the build; wrap entrypoints with makeBinaryWrapper; install app trees under $out/lib/<pname>/.
Build daemons break sandbox teardown
Monorepo tools that spawn a background daemon holding file locks on cache directories (turbo and similar) break sandbox teardown with cannot unlink "...drv-0": Directory not empty, because the daemon is still alive and its cache tree is locked when the sandbox tries to clean up.
Disable by both environment variable and CLI flag, force cache bypass, and remove the cache directories at the end of buildPhase: export TURBO_DAEMON=0; export TURBO_FORCE=true; pnpm turbo build --no-daemon; then rm -rf .turbo node_modules/.cache/turbo || true. Any build helper that persists a daemon or on-disk cache across invocations must be forced into single-shot, cache-clean mode inside the sandbox.
Hashes
Compute all hashes as SRI (sha256-...). Language builders carry a second hash for the dependency set (cargoHash, vendorHash, npmDepsHash, pnpmDeps.hash) that must be regenerated whenever the lockfile changes. Consult Context7 with library ID /nixos/nixpkgs for current language-builder patterns before writing a language-specific derivation; prefer the language builder to raw mkDerivation, and keep meta complete (description, homepage, license, maintainers, platforms, mainProgram for CLIs).
Secrets never enter the store
The store is world-readable by design (0555 directories, 0444 files) and content-addressed. Anything materialized into it — via writeText, toJSON piped into a store file, an unquoted path literal, or string-interpolating a path — becomes readable by every local user and is copied into any binary cache the closure is pushed to. No plaintext or ciphertext secret should ever enter the store.
A path literal, or a string produced by coercing a path ("${./secrets.yaml}", "${inputs.self}/secrets.yaml"), is copied into the store as an evaluation input — even a store-resident sops file is then in the closure. Reference the secret by a runtime string path that is never a Nix path type: an absolute string like "/var/lib/app-secrets/secrets.yaml" resolving only on the target. With sops-nix this also needs sops.validateSopsFiles = false;, because the validator asserts builtins.isPath sopsFile and rejects a plain string.
Keep decryption keys out of source and store by deriving them at boot: a oneshot systemd service (Type = oneshot, RemainAfterExit = true, ordered before the units that need it and after host-key generation) derives an age key from the host SSH key with ssh-to-age, writes it 0600, and decrypted secrets land under a runtime tmpfs. Make the derivation idempotent.
Prove nothing leaked:
nix-store -q --requisites <drvPath-or-outPath> | grep -i secrets
An empty result is the pass condition. Run it whenever a config touches secret handling, and always before pushing a closure to a public cache.
Declarative modules that serialize their whole config into the store (writeText of a toJSON blob) turn any env, vars, command strings, or hook bodies into world-readable store data. Treat such modules as non-secret-only unless reworked.
Activation scripts are a privileged, recurring attack surface
Activation scripts — nix-darwin and NixOS alike — run as root, and many write into the logged-in user's home: SSH material, agent sockets, per-user state. The user owns every component of that path, so anything running as that user can replace any directory along it with a symlink in the window between activation checking the path and writing to it. Path-based operations re-resolve the whole path on every call, so each call reopens the race; the check you performed a moment ago says nothing about the object you are about to write. This surface exists on every switch, by construction — which is what distinguishes it from ordinary untrusted-input handling.
- Never use path-based
mkdir -p,chmod,chown,touch, orinstallon user-controlled paths from a
privileged activation script or a root helper it installs.
O_NOFOLLOWon the final component alone is insufficient — an attacker only needs an ancestor. Traverse
descriptor-relatively: open each component with openat(dirfd, name, O_DIRECTORY | O_NOFOLLOW) and operate on the resulting descriptor (mkdirat, openat, fchown, fchmod), so the object you validated is the object you write.
- Validating a descriptor and then invoking a subprocess with the original path throws the guarantee away: the
subprocess re-resolves from scratch and reintroduces the race in full. Do the work in-process against the descriptor, or pass the descriptor itself (/dev/fd/N) where the tool accepts it.
On macOS several standard paths are system symlink aliases into /private — /var, /tmp, /etc. A blanket O_NOFOLLOW traversal therefore rejects legitimate configuration such as a state directory under /var/lib/…, and the naive fix (drop O_NOFOLLOW) undoes the hardening. Canonicalize only an explicit allowlist of these fixed system aliases and keep strict traversal for everything else.
The same reasoning covers any privileged helper a Nix module installs. For general untrusted-input rules see trust-boundaries; what is Nix-specific here is that activation is privileged by construction and re-runs on every switch.
Darwin building Linux
Apple Silicon macOS cannot natively build *-linux derivations. Register a Linux builder VM as a build machine; for anything large, offload to native CI and only substitute the result.
| Need | Mechanism | |
|---|---|---|
| Simplest, zero extra inputs | nix-darwin's nix.linux-builder (QEMU). Auto-configures buildMachines, distributedBuilds, builders-use-substitutes. Slowest, least to maintain. | |
| Faster boot, native virtio | A MicroVM framework or a vfkit runner over Apple's hypervisor. vfkit is the practical macOS hypervisor (built-in virtiofs, no 9p, no TAP); microvm.nix on Darwin needs a compatible pin because virtiofsd is unavailable on some revisions, plus storeOnDisk = false and vmHostPackages = nixpkgs.legacyPackages.aarch64-darwin. | |
| Near-native | A Virtualization.framework builder VM. No QEMU overhead, and Rosetta gives x86_64-linux at roughly 70–90% native versus QEMU's order-of-magnitude slowdown. Most bespoke. |
Only one mechanism can be active at once — they are mutually exclusive on nix.buildMachines / nix.linux-builder.
On the native-framework path: networking is NAT/user-mode only, with no TAP and no port forwarding, so discover the guest IP via the ARP table (deterministic guest MAC) or /var/db/dhcpd_leases (fixed guest hostname). /nix/store is shared read-only over virtiofs with an overlay for the writable layer. There is no nested virtualization on Apple Silicon, so the builder VM will not run inside another VM — it fails in typical M-series CI runners.
Generate the builder SSH keypair at activation time and keep the private key on the host; never commit it, since the config repo may be public. Share only the public key into the guest over a read-only mount. Bootstrap may use a password, but harden to key-only once the builder works.
Substituting foreign-platform derivations from Darwin needs explicit flags, because NixOS system.build.toplevel sets preferLocalBuild (allowSubstitutes = false) and the daemon negatively caches narinfo 404s:
nix build .#packages.aarch64-linux.<pkg> \--max-jobs 0 \--option extra-platforms aarch64-linux \--option always-allow-substitutes true
Add --option narinfo-cache-negative-ttl 0 when a stale negative cache hides a now-available path. Permanent form: nix.settings = { extra-platforms = [ "aarch64-linux" ]; always-allow-substitutes = true; };. A closure often spans multiple substituters, so all must be configured for the fetch to complete.
For large closures, build on native CI and let the Mac substitute the finished closure. Building a big *-linux closure on a virtiofs-overlay store can churn the store hard enough to lose store-path visibility mid-build — an architectural limit of the shared/overlay store, not a transient bug.
launchd
Run a long-lived agent in the correct domain. A daemon needing a graphical session must live in the gui domain; leaving it in the background/user domain can inject LimitLoadToSessionType = Background and produce flaky bootstrap where a process is alive but launchctl print gui/$UID/... shows nothing loaded. When migrating from a user-domain job, explicitly boot out the old user/$UID/... job or launchd may respawn it.
Raise file-descriptor limits in layers or you hit "Too many open files": a boot-time daemon for kern.maxfiles / kern.maxfilesperproc / launchctl limit maxfiles, per-agent NumberOfFiles (soft and hard), and — the commonly missed one — the GUI session's own launchctl limit maxfiles.
PID files and GC roots
A PID file tells you a process exists, not that it is your process. PIDs are reused, and the state file can be replaced between the read and the signal. Persist a composite identity — canonical executable path, canonical state directory, process start time — and require all three to match before acting. Re-read and revalidate immediately before every signal, including the SIGKILL fallback of a stop handshake: a slow shutdown escalation is exactly the window in which the original process exits and an unrelated one inherits the PID.
A long-lived VM or daemon whose closure must survive garbage collection needs an indirect GC root, and refreshing it must never leave a window with no root. Create the replacement symlink under a temporary name and rename it over the old one; never remove the valid root first, or a concurrent nix-collect-garbage can delete the closure of a running system. Accept only canonical /nix/store/... values as root targets, bound any external nix-store invocation with a timeout, and on expiry send SIGTERM, escalate to SIGKILL, and always reap the child.
Deployment
NixOS activation (switch-to-configuration, as driven by deploy-rs) takes a non-blocking exclusive flock at /run/nixos/switch-to-configuration.lock. A second concurrent activation fails immediately (exit 11 / EAGAIN) rather than queueing. A local Ctrl+C on deploy-rs can leave the remote switch-to-configuration running and holding the lock; kill the leftover remote process. A crash while holding it leaves a stale lock file to remove manually — check with fuser / ps on the target before assuming it is stale.
deploy-rs magicRollback verifies post-activation reachability, but when activation starts many or slow services, startup can exceed the verification window and trigger a false rollback. Disable magicRollback, rely on autoRollback, and raise activationTimeout:
{ autoRollback = true; magicRollback = false; activationTimeout = 600; }
Keep ephemeral containers' TimeoutStopSec low (e.g. 15s) so a slow shutdown does not hold the activation lock; with the default 90s across many containers, teardown alone can block the next deploy for a long time. Declared NixOS containers are auto-started by systemd on activation, and a full reset of systemd-machined state before applying can be needed to avoid EEXIST races on redeploy.
Sandbox discipline
The build sandbox has no network and a minimal toolset (no git), and Nix builds Rust in the release profile.
- Tests needing network or git must be
#[ignore]d or feature-gated so the sandboxedcargo teststill passes;
run them outside the sandbox in a dev shell.
- The release profile strips
debug_assert!, so a#[should_panic]test asserting that adebug_assert!fires
must be gated with #[cfg(debug_assertions)], or it fails under the Nix build while passing under a debug cargo test.
The multicall argv[0] trap
In a Nix environment a large share of PATH entries are symlinks into multicall binaries: coreutils installs echo, ls, cat and dozens more as links to a single coreutils executable that dispatches on argv[0]. Code that "hardens" a resolved executable path by canonicalizing it (realpath, readlink -f, Path::canonicalize) rewrites argv[0] to the dispatcher's own name, and the program then runs as the wrong tool entirely. The signature is a spawn producing empty stdout, a usage message on stderr, and a nonzero exit — a failure that looks like a broken argument list, not a resolution bug.
Existence and executability checks may follow symlinks; the path handed to exec must keep the candidate's own basename. Canonicalize for comparison, never for invocation.
This is near-certain to surface under nix build / nix flake check and may never surface on a distribution shipping separate binaries — a textbook "passes on my machine, fails in the sandbox" case. It is plain POSIX behavior; Nix just makes the multicall layout the norm.
Rules
- Verify
flake.lockis updated after any input change. - Use
mkIfandmkMergefor conditional module options, never a plainifat top level. Preferlib.mkDefault
to lib.mkForce.
- Set
home.stateVersionto the initial version and do not change it after setup unless migrating. - Always provide a hash to
fetchurl/fetchTarball; usenix-prefetch-urlorlib.fakeHashto obtain it. - Avoid
with lib;at module top level — it pollutes scope and hides where functions come from. Use explicit
lib.mkIf, lib.types.str. Narrow scopes like with pkgs; [ ... ] are fine.
- Prefer declarative configuration to
nix-env -i,nix-channel, and other mutable/nix/varstate. - Document non-obvious overlay and override rationale in a comment — this is one of the cases where a comment
carries a WHY the code cannot.
Related
- trust-boundaries — general untrusted-input and privilege-boundary rules
- devenv-ecosystem — devenv 2.0 configuration
- context7-usage — fetching current nixpkgs and Home Manager documentation
- investigation-patterns — debugging evaluation and derivation failures
- serena-usage — navigating Nix expressions by symbol
- testing-patterns — what acceptance means for a declarative change
- Language conventions for nixpkgs packaging: golang-ecosystem,
rust-ecosystem, haskell-ecosystem, php-ecosystem, swift-ecosystem, c-ecosystem, cplusplus-ecosystem, common-lisp-ecosystem