Pin the Tools Your Repository Expects
A pull request reaches CI and fails under Python 3.10. The developer tested it with a global Python 3.12 installation. Someone investigating the failure then discovers that psql is missing locally. Before anyone examines the code, the team is debugging its workstations.
Commit the Toolchain
Declare tools in flake.nix, commit flake.lock, let direnv activate the environment, and run CI commands through nix develop.
I stopped maintaining separate local Brewfiles and CI package selections because every tool change created another synchronization task. That burden will look familiar to anyone who has fixed a build by updating instructions in several places. Put the selection beside the code and make both environments consume it.
The finished repository here supplies Python, Ruff, and PostgreSQL client tools. Ruff includes the native ruff server command, so editor configuration can use the repository's executable instead of an aging global ruff-lsp wrapper. The contract is concrete: the checkout declares what its commands need.
Prepare Nix, direnv, and an Empty Git Repository
Start with working installations of Nix, Git, direnv, and nix-direnv. Check the commands before creating project files:
nix --version
direnv version
git --version
Enable the following in the applicable Nix configuration, usually ~/.config/nix/nix.conf for user configuration or /etc/nix/nix.conf for system configuration. Preserve any existing feature settings.
experimental-features = nix-command flakes
Add the appropriate hook to your shell startup file. For Bash, put this in ~/.bashrc:
eval "$(direnv hook bash)"
For Zsh, use this in ~/.zshrc:
eval "$(direnv hook zsh)"
Then restart the shell:
exec "$SHELL"
nix-direnv supplies the use flake integration used below. Its installed direnvrc must be sourced from ~/.config/direnv/direnvrc, unless your installation already configures that integration. Find the installed file through your package manager and add a source directive pointing to it. The location depends on the installation: a Nix profile, a system profile, and a Homebrew prefix place it in different directories.
Finally, create the repository:
mkdir project-shell
cd project-shell
git init
Write One Flake for Python, Ruff, and PostgreSQL
A beginner needs a package list and a default shell. The next step is making that shell available on the architectures the team actually uses. This file covers Apple Silicon and Intel macOS, plus ARM and x86 Linux:
{
description = "Repository development tools";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
systems = [
"aarch64-darwin"
"x86_64-darwin"
"aarch64-linux"
"x86_64-linux"
];
in {
devShells = builtins.listToAttrs (builtins.map
(system:
let
pkgs = import nixpkgs { inherit system; };
in {
name = system;
value.default = pkgs.mkShell {
packages = [
pkgs.python3
pkgs.ruff
pkgs.postgresql
];
};
})
systems);
};
}
Save it as flake.nix, then stage it before asking Nix to evaluate the repository:
git add flake.nix
nix flake lock
git add flake.lock
Git-backed flakes exclude untracked files. Skipping that first git add produces a missing-file failure even though your editor can see the file. An unnecessarily memorable onboarding lesson.
builtins.map constructs each system's shell definition; builtins.listToAttrs turns those definitions into named attributes. For this small matrix, an extra helper-flake dependency buys little. Keep the machinery visible.
Load the Shell on Directory Entry Without Blind Approval
Does entering a repository now execute its environment instructions? Once you approve them, yes. Treat that approval as a code-review decision.
Create .envrc with this complete content:
use flake
Add this line to .gitignore:
.direnv/
Read both .envrc and flake.nix, then activate and verify:
direnv allow
command -v python
python --version
ruff server --help
psql --version
The Python path should resolve into /nix/store/. A decorated shell prompt is weaker evidence than the executable your shell actually selects.
Entering the directory loads the environment; leaving restores the previous environment. Each teammate reviews and approves the activation on their own machine. Committing .envrc shares the instructions, while direnv allow records local approval.
Keep these layers separate: flake.lock selects an immutable nixpkgs revision; .envrc declares activation; .direnv/ holds cached environment state and garbage-collector root symlinks managed by nix-direnv. Commit the first two. Keep the last local.
Run the Committed Toolchain in CI
CI needs Nix and the checkout. It has no reason to install direnv just to run a command.
- Check out the repository, including its committed lock file.
- Install Nix with
nix-commandandflakesenabled. - Run the repository's checks through
nix develop --no-update-lock-file --command.
This empty repository has no application test suite yet, so use a runnable toolchain smoke check:
nix develop --no-update-lock-file --command bash -c 'python --version && ruff --version && psql --version'
--no-update-lock-file makes evaluation fail when the declared inputs require a lock update. CI should test the committed selection rather than quietly generate another one.
As application checks arrive, put the actual check command behind the same invocation. A version check establishes executable availability; application tests exercise the project. The official nix develop command reference documents the command interface.
Review Lock Updates and Preserve Local Shell Caches
Routine directory entry should consume the committed lock. Advancing package definitions belongs in a reviewable change:
nix flake update nixpkgs
nix develop --no-update-lock-file --command bash -c 'python --version && ruff --version && psql --version'
git diff -- flake.lock
Review the revision change, run the project's checks, and commit the lock update. The update advances the selected nixpkgs revision without requiring a change to the input declaration in flake.nix.
For a lock-file conflict, settle the intended input declaration first. Restore the lock from the agreed target branch, run nix flake lock to reconcile it, and rerun checks. If the agreed change advances nixpkgs, run the explicit update command too. Hand-merging revision hashes and narHash strings creates an artifact nobody should have to trust.
Leave .direnv/ in place during normal housekeeping. nix-direnv uses its local roots to keep the shell closure reachable through garbage collection. Deleting that directory discards the cache and its roots, making later downloads more likely. A cache belongs outside Git without becoming disposable on every directory change.
Give PostgreSQL Tools to the Shell and Its Daemon to a Container
pkgs.postgresql makes the boundary tangible. Through mkShell, it supplies psql, pg_dump, and the associated client libraries and headers from the Nix store. Adding that package does not start a database.
A running PostgreSQL server needs an initialized PGDATA directory, configuration, a supervised daemon, and a port or socket. Hand that lifecycle, including port 5432 binding, to the container setup. Leaving a directory should restore shell variables without leaving a surprise database process behind.
Use the dev shell for interpreters, formatters, language servers, clients, and check commands. Use a container when the task depends on specific Linux userspace, service networking, or database startup behavior. That boundary is more useful than another round of industry opinions about which tool should replace everything.
Keep connection details outside flake.nix. Supply values such as DATABASE_URL=postgresql://[email protected]:5432/app_dev through an untracked local mechanism.
Carry a Fresh macOS Checkout Into a Linux CI Check
Commit the environment contract, leaving
.direnv/local:git add flake.nix flake.lock .envrc .gitignore git commit -m "Declare repository development shell"A teammate with the prerequisites installed clones the repository onto an Apple Silicon Mac. They inspect
.envrcandflake.nix, enter the checkout, and rundirenv allow. The flake selectsaarch64-darwin.They run
command -v python, confirm the store path, and execute the same smoke check used in CI:nix develop --no-update-lock-file --command bash -c 'python --version && ruff --version && psql --version'The Linux runner checks out that commit, installs Nix with flakes enabled, and runs the identical command. It selects
x86_64-linuxfrom the same locked nixpkgs revision. The handoff now consists of a commit and a command, rather than somebody's workstation setup notes.
Your Thoughts
Share your thoughts.
Join the Discussion