Creating a Project Dev Shell With Nix Flakes
Running nix develop in a freshly cloned repository fails with error: flake 'git+file:///home/dev/shop' does not provide attribute 'devShells.aarch64-darwin.default', or with error: getting status of '/nix/store/...-source/flake.nix': No such file or directory right after creating the file. Both are common first-hour failures when writing a flake, and both have precise causes. This page writes a working, multi-platform dev shell from scratch and pins exact tool versions, as part of reproducible dev shells with Nix, Devbox and direnv.
The end result is a flake.nix and flake.lock in the repository that give every developer on macOS or Linux, Intel or ARM, the same tools, with one command.
Diagnostic
Show what the flake exposes and whether Nix can see it at all:
#!/usr/bin/env bash
set -euo pipefail
nix --version
git status --short flake.nix flake.lock || true
nix flake show 2>&1 | head -12
nix eval --impure --raw --expr builtins.currentSystem; echo
Expected bad output for the two common failures:
nix (Nix) 2.24.6
?? flake.nix
error: getting status of '/nix/store/7lq...-source/flake.nix': No such file or directory
aarch64-darwin
flake.nix is untracked (??). And after git add, a flake written with only devShells.x86_64-linux.default shows no output for aarch64-darwin, which is what this machine is.
Root cause
Flakes are evaluated from a copy of the repository in the Nix store, and for git repositories Nix copies only files that git knows about. An untracked flake.nix is invisible, so Nix reports it missing even though it is right there on disk. It does not need to be committed, only added to the index. The second failure comes from the structure of flake outputs: every output is keyed by a system string such as x86_64-linux, aarch64-linux, x86_64-darwin or aarch64-darwin. A shell declared for one system does not exist for the others, and Nix does not fall back. Helpers like flake-utils.lib.eachDefaultSystem generate the same shell for all four systems from one definition.
A third, quieter issue is version pinning. pkgs.nodejs_20 gives whatever Node 20 patch release is in the pinned nixpkgs revision. That is reproducible — everyone with the same lockfile gets the same patch — but it may not be the exact patch production runs. When an exact version matters, it has to come from a nixpkgs revision that contains it, or from an override.
Resolution
- Write the flake for all common systems:
{
description = "shop dev shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
linuxOnly = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.strace pkgs.inotify-tools ];
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [ nodejs_20 python312 jq terraform kubectl protobuf ] ++ linuxOnly;
env.PIP_REQUIRE_VIRTUALENV = "true";
};
});
}
lib.optionals pkgs.stdenv.isLinux keeps Linux-only tools out of the macOS shell, where they do not build.
- Make it visible to Nix and generate the lockfile:
#!/usr/bin/env bash
set -euo pipefail
git add flake.nix
nix flake lock
git add flake.lock
nix flake show
- Pin an exact version when the patch level matters. Add a second nixpkgs input at a revision that contains the version, and take that one package from it. Tools such as the nixhub.io search show which nixpkgs revision provides a given version:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
nixpkgs-terraform.url = "github:NixOS/nixpkgs/5ed627539ac84809c78b2dd6d26a5cebeb5ae269";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, nixpkgs-terraform, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
tfpkgs = import nixpkgs-terraform { inherit system; config.allowUnfree = true; };
in {
devShells.default = pkgs.mkShell {
packages = [ pkgs.nodejs_20 tfpkgs.terraform ];
};
});
}
allowUnfree is needed because Terraform's licence is not free in the nixpkgs sense; Nix refuses unfree packages unless explicitly allowed, which is a deliberate safety check rather than an error.
- Enter the shell and record the result:
#!/usr/bin/env bash
set -euo pipefail
nix develop --command bash -c 'node --version; terraform version | head -1; command -v protoc'
Expected output
$ nix flake show
git+file:///home/dev/shop
└───devShells
├───aarch64-darwin
│ └───default: development environment 'nix-shell'
├───aarch64-linux
│ └───default: development environment 'nix-shell'
├───x86_64-darwin
│ └───default: development environment 'nix-shell'
└───x86_64-linux
└───default: development environment 'nix-shell'
$ nix develop --command bash -c 'node --version; terraform version | head -1'
v20.15.1
Terraform v1.8.5
Four systems are exposed, and the shell provides the pinned versions on this machine.
Run the same nix develop --command line on a colleague's machine with a different operating system. The version strings match, and on the same system the store paths printed by command -v match character for character, including the hash. That hash is derived from every input used to build the package — source, compiler, flags, dependencies — so matching hashes are a stronger guarantee than matching version numbers: two builds of "Node 20.15.1" compiled against different OpenSSL versions would have different hashes, and Nix would treat them as different packages.
The flake is also the right place for small environment conventions that otherwise live in personal shell configuration. env.PIP_REQUIRE_VIRTUALENV in the example stops anyone from installing Python packages into the Nix-provided interpreter by mistake, which would fail anyway because the store is read-only, but with a far less helpful error. Similar one-line settings — a GOFLAGS value, a NODE_OPTIONS memory limit — belong here too, so the shell behaves the same for everyone.
Prevention
Check the flake in CI on both Linux and macOS runners with
nix flake checkandnix develop --command true. A package that fails to build on one platform is caught before a developer on that platform hits it.Schedule lockfile updates in a monthly pull request so nixpkgs security fixes arrive regularly and upgrades are reviewed, rather than happening whenever someone runs
nix flake updateby accident.Warn on a dirty tree only when it matters. Nix prints
warning: Git tree is dirtywhenever uncommitted changes exist; it is harmless during development. Do not suppress it globally — it is a useful reminder when debugging a shell that differs from CI.
Platform caveats
macOS: after a macOS major upgrade,
nixmay disappear fromPATHbecause the upgrade rewrote/etc/zshrc. Re-run the installer's repair command or re-add the Nix profile sourcing line; the store itself is intact.
Apple Silicon (ARM64): a few packages are not cached for
aarch64-darwinand build from source on first use. If one is slow, checknix path-info --store https://cache.nixos.org <path>; if it is missing, cache it in a team cache after the first build.
WSL2: use the Linux instructions inside the distribution. Enable systemd in
/etc/wsl.conffor the multi-user daemon, or use the single-user install.
Rollback
Revert the lockfile to return to the previous toolchain; old store paths are usually still present, so nothing needs to be downloaded:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- flake.lock
nix develop --command bash -c 'node --version'
Frequently Asked Questions
Why does Nix say flake.nix does not exist when it is in the directory?
In a git repository, Nix only sees files tracked by git. Run git add flake.nix (committing is not required) and try again.
How do I get an exact patch version of a tool?
Find a nixpkgs revision that contains it, add that revision as a separate input, and take the package from it. For languages with their own version files, such as .nvmrc, a tool like mise may be simpler for that one runtime.
Do I need flake-utils?
No, but without it you must write each system's output by hand or with nixpkgs.lib.genAttrs. flake-utils.lib.eachDefaultSystem keeps the file short and ensures all four common systems are covered.
Why does Nix refuse to install Terraform?
Terraform uses the Business Source License, which nixpkgs classifies as unfree. Set config.allowUnfree = true when importing the nixpkgs instance that provides it, or use OpenTofu, which is free and packaged as opentofu.