Nix for development
SkillDocs & knowledgeUse this when setting up Nix for a development project (devShell + package build) and you care about `nix develop` being fast. Covers the zero-inputs flake.nix + npins + default.nix/shell.nix layout, sub-flakes for non-user-facing Nix, and language-specific recommendations.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Nix for development skill
What this skill tells your AI
The instructions your AI receives, as published by srid/emanote in .agents/skills/nix-for-dev/SKILL.md and read by ahel’s review.
A low-overhead Nix setup for dev projects: ~1s cold nix develop, ~0.1s warm, with a clean separation between the user-facing flake and internal Nix. Reference implementation: juspay/kolu.
Core principle: zero flake inputs
The top-level flake.nix declares no inputs at all. Each flake input adds ~1.5s of fetcher-cache verification on cold eval; a single nixpkgs input costs ~7s. With zero inputs, cold nix develop is ~1.0s, warm ~0.1s.
Instead, pin sources with npins and import them via fetchTarball / callPackage from files under nix/.
# flake.nix — slim, zero inputs
{
outputs = { self, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
eachSystem = f: builtins.listToAttrs (map
(system: {
name = system;
value = f (import ./nix/nixpkgs.nix { inherit system; });
})
systems);
in
{
packages = eachSystem (pkgs: {
default = import ./default.nix { inherit pkgs; };
});
devShells = eachSystem (pkgs: {
default = import ./shell.nix { inherit pkgs; };
});
};
}
Do not add nixpkgs, flake-parts, git-hooks, etc. as flake inputs. If a downstream consumer needs to override the pinned nixpkgs, they can override the npins source (NPINS_OVERRIDE_nixpkgs=/path).
File layout
flake.nix # slim wrapper, zero inputs
default.nix # main package(s), pkgs ? import ./nix/nixpkgs.nix { }
shell.nix # devShell, pkgs ? import ./nix/nixpkgs.nix { }
nix/
nixpkgs.nix # imports npins source + applies overlay
overlay.nix # injects leaf packages into pkgs
env.nix # env vars shared by build + devShell + wrapper
packages/<name>/ # callPackage-style leaf packages
npins/
default.nix # generated by npins (do not edit)
sources.json # pinned source revisions
default.nix and shell.nix both accept pkgs ? import ./nix/nixpkgs.nix { } so they also work via plain nix-build / nix-shell, not just nix develop.
npins workflow
npins init # creates npins/ and pins nixpkgs
npins add github nixos nixpkgs --branch nixpkgs-unstable
npins update # update all pins
npins update nixpkgs # update one
nix/nixpkgs.nix:
# Pinned nixpkgs import — managed by npins.
# To update: npins update nixpkgs
let
sources = import ../npins;
nixpkgs = import sources.nixpkgs;
in
args: nixpkgs (args // {
overlays = (args.overlays or [ ]) ++ [ (import ./overlay.nix) ];
})
Leaf packages via overlay
Pure callPackage-style packages live in nix/packages/<name>/default.nix and are auto-injected via nix/overlay.nix:
# nix/overlay.nix
final: _prev: {
my-fonts = final.callPackage ./packages/fonts { };
}
Packages that need per-invocation arguments (commit hash, build-time env) stay in the top-level default.nix — overlays are for things that legitimately belong on pkgs.
Shared env vars
Define a single nix/env.nix returning an attrset; both the build derivation and the devShell spread it into their env. This prevents drift between nix build and nix develop.
# nix/env.nix
{ pkgs }: {
MY_FONTS_DIR = pkgs.my-fonts;
MY_GH_BIN = "${pkgs.gh}/bin/gh";
}
devShell conventions
- Use
pkgs.mkShelldirectly. Do not introduceflake-partsto "structure" it. - Use
pkgs.writeShellApplication(notwriteShellScriptBin) — strict mode +runtimeInputsvalidation. Always setmeta.description. - Run
nixpkgs-fmtfrom the devShell rather than wiring upformatterperSystem. - Expose extra shells via
overrideAttrsso the base stays fast:
devShells = eachSystem (pkgs:
let default = import ./shell.nix { inherit pkgs; };
in {
inherit default;
e2e = default.overrideAttrs (prev: {
env = (prev.env or { }) // {
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;
};
});
});
nix develop .#e2e for the heavier shell; the default stays cold-start-fast.
Sub-flakes for non-user-facing Nix
Module integration tests (home-manager, NixOS, Darwin) genuinely need flake-parts-style inputs (home-manager, nix-darwin). Keep those inputs out of the top-level flake by nesting a sub-flake under nix/<name>/flake.nix. CI builds it with --override-input pointing back at the parent:
# nix/home/example/flake.nix
{
inputs = {
self_pkg.url = "github:owner/repo"; # parent; CI passes --override-input
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { nixpkgs, home-manager, self_pkg, ... }: {
# nixosConfigurations / checks that exercise self_pkg.homeManagerModules.default
};
}
Users running nix develop / nix run on the top-level flake never evaluate this graph. Only CI does.
Language templates
Haskell
Use haskell-flake via its standalone entry point lib.evalHaskellProject (not the flake-parts module). See haskell.nixos.asia/standalone for the full API.
In keeping with the zero-inputs principle, pin haskell-flake via npins (npins add github srid haskell-flake) and call it from default.nix:
# default.nix
{ pkgs ? import ./nix/nixpkgs.nix { } }:
let
sources = import ./npins;
haskell-flake = import sources.haskell-flake;
project = (haskell-flake.lib { inherit pkgs; }).evalHaskellProject {
projectRoot = ./.;
modules = [{
settings.mypackage.haddock = false;
devShell.tools = hp: { inherit (hp) fourmolu; };
}];
};
in
project.packages.mypackage.package
Wire project.devShell into shell.nix the same way. If you need flake-parts and nixos-unified autowiring (multi-package projects, fully wired checks), see the nix-haskell skill — it uses haskell-template and trades startup time for ergonomics.
TypeScript / pnpm
See nix-typescript for fetchPnpmDeps and hash management.
Dev services
For multi-process dev environments (server + watcher + db), use process-compose-flake and services-flake via their standalone entry points (no flake-parts needed). Both flakes have zero inputs themselves, so pinning them via npins keeps the top-level flake.nix zero-input too:
process-compose-flakeexposeslib.evalModules/lib.makeProcessComposefor module evaluation outside flake-parts.services-flakeexposesprocessComposeModules.default(a path) — pass it as a module toevalModules.
# shell.nix
{ pkgs ? import ./nix/nixpkgs.nix { } }:
let
sources = import ./npins;
pcLib = import "${sources.process-compose-flake}/nix/lib.nix" { inherit pkgs; };
servicesMod = pcLib.evalModules {
modules = [
"${sources.services-flake}/nix/process-compose"
{ services.redis."r1".enable = true; }
];
};
in
pkgs.mkShell {
inputsFrom = [ servicesMod.config.services.outputs.devShell ];
}
Reference: services-flake/example/without-flake-parts and doc/without-flake-parts.md.
Companion docs
nix-perf— diagnosing slownix develop/nix flake archivenix-justfile— justfile recipe conventions for Nix projectsnix-typescript— pnpm + Nix conventionsnix-haskell— flake-parts + haskell-template variant (when ergonomics > cold start)- juspay/kolu — full reference implementation
Signals
- GitHub stars
- 959
- Forks
- 79
- Last commit
- Jul 2026
ahel recommends instead
Advanced
- Catalog kind
- skill
- Gateway key
nix-for-dev-srid- Source
- github.com/srid/emanote