Day 31 — Overlays light

Updated

July 30, 2026

Day 31 — Overlays light

Stage III · ~4h
Goal: Apply a small overlay so the same package change is visible to the NixOS host and to devShells—without forking nixpkgs or cargo-culting overlay stacks.

Important

Overlays are sharp tools. Prefer options, package overrides in a single module, or upstream pins when they suffice. Today teaches the mechanism so you are not helpless—not so every problem becomes an overlay.

Why this day exists

Sooner or later you need:

  • A newer/older minor of one tool than nixpkgs 26.05 ships
  • A one-line patch to a package you use
  • A renamed or wrapped package for your lab

Overlays let you modify the pkgs fixed-point. Done lightly, they are elegant. Done heavily, they make upgrades impossible.


Theory 1 — What an overlay is

An overlay is a function:

final: prev: {
  # new or overridden attrs
}
Argument Meaning
prev Package set before this overlay
final Package set after all overlays (fixed-point)—use for deps on other overridden pkgs
final: prev: {
  hello = prev.hello.overrideAttrs (old: {
    # example: add a patch or change configure flags
  });
}

Theory 2 — Wiring overlays on NixOS

# modules/common/nixpkgs.nix
{ ... }:
{
  nixpkgs.overlays = [
    (import ../../overlays/hello.nix)
    # or inline:
    # (final: prev: { ... })
  ];
}

With home-manager.useGlobalPkgs = true, HM sees the same overlayed pkgs. That is why Day 25 set that flag.

Flake-level pkgs for shells/checks

pkgsFor = system: import nixpkgs {
  inherit system;
  overlays = [
    (import ./overlays/hello.nix)
  ];
};

Consistency rule: the overlay list used for nixosConfigurations should match the list used for devShells / checks when you care about the same tools—or document intentional differences.


Theory 3 — overrideAttrs vs override vs new package

Tool When
pkg.override { ... } Change callPackage parameters
pkg.overrideAttrs Change derivation attributes (patches, version, src)
New attr final.mytool = ... Add a package name without clobbering

Example patch (illustrative):

# overlays/hello.nix
final: prev: {
  hello = prev.hello.overrideAttrs (old: {
    patches = (old.patches or []) ++ [
      # ./patches/hello-example.patch
    ];
  });
}

If you do not have a real patch today, use a harmless visible change such as pname suffix only for learning—or prefer adding a wrapper package instead of mutating hello:

final: prev: {
  hello-lab = prev.writeShellScriptBin "hello-lab" ''
    echo "lab overlay wrapper"
    exec ${prev.hello}/bin/hello "$@"
  '';
}

Wrappers are often safer than overriding core packages.


Theory 4 — Composition order

nixpkgs.overlays = [ overlayA overlayB ];

Later overlays see earlier results via prev chain. Conflicts: last writer wins for the same attr. Keep one file per concern and a short list.


Theory 5 — When not to overlay

Need Prefer
Enable a service option NixOS module option
Package only for one project Project devShell / packages
Temporary try nix shell / branch pin
Huge fork of desktop stack Separate nixpkgs input (advanced)

Overlays that rewrite half of python3Packages are Stage V+ territory—and often a mistake.


Theory 6 — Pinning a package from another nixpkgs (light)

Sometimes you want one package from a newer pin without moving the whole OS:

# advanced sketch — use sparingly
# inputs.nixpkgs-unstable.url = "...";
# final: prev: {
#   fancy = inputs.nixpkgs-unstable.legacyPackages.${prev.system}.fancy;
# }

Costs: two package graphs, closure size, ABI surprises. For today, a wrapper or small overrideAttrs is enough.


Worked example — Shared overlay module

overlays/
  default.nix      # composes list
  hello-lab.nix
modules/common/nixpkgs.nix
# overlays/default.nix
[
  (import ./hello-lab.nix)
]
# overlays/hello-lab.nix
final: prev: {
  hello-lab = prev.writeShellScriptBin "hello-lab" ''
    echo "hello from Stage III overlay"
    exec ${prev.hello}/bin/hello "$@"
  '';
}
# modules/common/nixpkgs.nix
{ ... }:
{
  nixpkgs.overlays = import ../../overlays/default.nix;
  environment.systemPackages = [
    # pkgs.hello-lab  # add via a small packages module once pkgs sees overlay
  ];
}

In a module that receives pkgs:

{ pkgs, ... }:
{
  environment.systemPackages = [ pkgs.hello-lab ];
}

And in flake pkgsFor, import the same overlay list.


Lab 1 — Create overlay file

mkdir -p overlays
# add hello-lab.nix and default.nix as above

Lab 2 — Attach to NixOS

Import overlay list via nixpkgs.overlays. Add pkgs.hello-lab to system or HM packages. Rebuild:

sudo nixos-rebuild switch --flake .#lab
hello-lab

Lab 3 — Attach to devShell / checks pkgs

Ensure pkgsFor (or flake-parts pkgs) uses the same overlays.

nix develop -c hello-lab

If only system has it, fix the flake pkgs construction.


Lab 4 — Prove HM sees it (if useGlobalPkgs)

# homes/alice snippet
home.packages = [ pkgs.hello-lab ];

Rebuild; as user command -v hello-lab.


Lab 5 — Intentional override discipline

Journal:

  1. What you overlaid/wrapped and why
  2. How you will remove it when upstream catches up
  3. Whether a project-only shell would have been enough

Lab 6 — Negative space

Find one change you could do with an overlay but will do with a module option instead. Write it down—builds judgment.


Common gotchas

Symptom / mistake What to do
pkgs.hello-lab missing Overlay not wired into that pkgs import
HM doesn’t see overlay useGlobalPkgs false or separate pkgs
Infinite recursion in overlay Using final incorrectly; simplify
Overlay only on system Wire flake pkgsFor too
Overlaying all of nixpkgs for fun Revert; minimize surface
Patch path wrong Path relative to overlay file / flake root

Checkpoint

  • Overlay or wrapper package defined in repo
  • Visible on NixOS host packages
  • Visible in devShell or documented intentional difference
  • HM consistency verified if applicable
  • Removal plan written
  • Judgment note: when not to overlay

Commit

git add .
git commit -m "day31: light overlays shared by host and shell"

Write three personal gotchas before continuing.


Tomorrow

Day 32 — Stage III gate: prove host + Home Manager + devShell + checks live in one clean flake—exit criteria for daily-driver shape.