| name | nix-for-dev |
| description | Use 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. |
Nix for development
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
npins add github nixos nixpkgs --branch nixpkgs-unstable
npins update
npins update nixpkgs
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.mkShell directly. Do not introduce flake-parts to "structure" it.
- Use
pkgs.writeShellApplication (not writeShellScriptBin) — strict mode + runtimeInputs validation. Always set meta.description.
- Run
nixpkgs-fmt from the devShell rather than wiring up formatter perSystem.
- Expose extra shells via
overrideAttrs so 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-flake exposes lib.evalModules / lib.makeProcessCompose for module evaluation outside flake-parts.
services-flake exposes processComposeModules.default (a path) — pass it as a module to evalModules.
# 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 slow nix develop / nix flake archive
nix-justfile — justfile recipe conventions for Nix projects
nix-typescript — pnpm + Nix conventions
nix-haskell — flake-parts + haskell-template variant (when ergonomics > cold start)
- juspay/kolu — full reference implementation