A project can work perfectly on one developer's laptop and fail on another machine because the environments are different. One developer may have a newer compiler, another may have an older library, and a CI server may use a different version of a command-line tool.
These differences create familiar problems: builds that fail unexpectedly, tests that pass locally but fail in CI, and setup instructions that become outdated as a project grows.
Nix Flakes provide a way to describe a project's development environment and dependencies declaratively. Instead of asking every developer to install tools manually, a project can define the tools it needs in configuration files and use Nix to create a reproducible environment.
In this article, you will learn how Nix Flakes work, how to create a development environment, and how to use a lockfile to keep dependencies consistent across machines.
What Are Nix Flakes?
Nix is a package manager and build system that allows developers to describe software dependencies and build environments declaratively.
Nix Flakes extend this model by providing a structured way to define inputs and outputs for a project.
A typical flake declares:
- Inputs, such as a particular version of the Nix package collection.
- Outputs, such as development shells, packages, applications, and checks.
- A lockfile that records the resolved versions or revisions of its inputs.
For example, a Go project might need a specific Go toolchain, Git, and a few additional command-line utilities. Rather than documenting these requirements in a README and relying on developers to install compatible versions, the project can declare them in a flake.nix file.
Developers can then enter the same declared development environment using Nix.
Flakes are commonly used with a flake.lock file, which records the resolved input revisions. This helps different machines use consistent dependency definitions, assuming they use the same lockfile and compatible Nix configuration.
Why Development Environments Become Inconsistent
Consider a team working on a Go application.
One developer uses Go 1.24, another uses Go 1.25, and the CI environment installs whichever version is currently available in its default image.
The differences might affect:
- Compiler behavior and available language features.
- Dependency resolution.
- Formatting and linting results.
- Build scripts and command-line tools.
- Test execution and generated output.
A project can reduce these problems by declaring its required tools and pinning the versions used by the development environment.
Nix Flakes help by making environment configuration part of the repository rather than relying entirely on local machine setup.
Traditional setup | Nix Flakes |
|---|
Developers install tools manually | Tools are declared in configuration |
Setup instructions can become outdated | Environment requirements are stored with the project |
Tool versions may differ between machines | Inputs can be pinned through a lockfile |
CI setup may drift from local setup | CI can use the same flake definition |
Reproducing an old environment can be difficult | A committed lockfile helps preserve dependency revisions |
Nix does not eliminate every source of variation. Operating system differences, environment variables, external services, secrets, and non-hermetic build steps can still affect the result. The objective is to make the declared environment more consistent and easier to reproduce.
Step 1: Install Nix and Prepare the Project
First, install Nix using an installation method appropriate for your operating system and organization.
Confirm that the command is available:
nix --version
Flakes are supported by modern Nix installations, but they may require enabling the relevant experimental features, depending on the installation and configuration.
A common configuration is:
experimental-features = nix-command flakes
This setting belongs in the appropriate Nix configuration file. Some installations already enable these features, and managed environments may require an administrator to change the configuration.
Create a project directory:
mkdir go-service
cd go-service
git init
For this example, assume the project uses Go and requires Git for development tasks.
The same approach can be adapted for Rust, Python, Node.js, or projects that use multiple languages.
Step 2: Create the flake.nix File
Create a file named flake.nix in the project root.
{
description = "Development environment for a Go service";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
go
git
];
shellHook = ''
echo "Go development environment ready"
go version
'';
};
};
}
This flake defines a development shell containing Go and Git.
The configuration has several important parts.
Inputs
The inputs section declares the project's dependency sources.
In this example, the project uses the nixpkgs input from the nixos-unstable branch.
The branch identifies the source from which Nix resolves the input. The committed flake.lock file records the resolved revision, helping prevent the environment from changing simply because the branch moves.
For teams that prioritize stability, consider choosing an appropriate stable Nixpkgs branch rather than automatically tracking an unstable branch. The important point is to pin the resolved input through the lockfile and update it deliberately.
Outputs
The outputs function describes what the flake provides.
Here, it creates a development shell at:
devShells.x86_64-linux.default
The system variable identifies the target platform. The example uses x86_64-linux, so it is not automatically suitable for macOS, ARM Linux, or other architectures.
For a cross-platform team, define outputs for each required system, or use a helper library that generates outputs for a list of supported systems.
Development Packages
The packages list specifies which tools are available inside the shell:
packages = with pkgs; [
go
git
];
When a developer enters the environment, Nix makes these tools available without requiring the developer to install the same versions globally.
The shellHook is optional. It runs when the development shell starts and can display useful information or perform lightweight initialization.
Avoid putting expensive build steps or destructive operations in the shell hook. Entering a development shell should remain predictable.
Step 3: Generate the Lockfile
The first time you use the flake, generate its lockfile:
nix flake lock
This resolves the declared inputs and creates flake.lock.
The lockfile records the resolved input revisions and associated metadata. Commit both files to version control:
git add flake.nix flake.lock
git commit -m "Add reproducible development environment"
Committing the lockfile is important because it allows other developers and CI workers to use the same resolved input revisions.
Without a committed lockfile, different machines may resolve moving references at different times and obtain different dependency revisions.
Should You Commit flake.lock?
For a development environment that needs to remain consistent across a team, committing flake.lock is generally the right choice.
When you intentionally update the environment, update the lockfile and review the resulting changes:
nix flake update
This command updates flake inputs according to their declarations. Review the changes before committing them, especially when the project depends on a large package collection.
A lockfile helps pin the environment's inputs, but it does not guarantee that every external system or every build step is deterministic.
Step 4: Enter the Development Environment
Run the following command from the project directory:
nix develop
Nix evaluates the flake and opens a shell containing the declared tools.
Check the Go version:
go version
Check Git:
git --version
These commands should use the tools supplied by the development environment.
A developer who clones the repository on another compatible machine can follow the same process:
git clone <repository>
cd <repository>
nix develop
Replace <repository> with the actual repository location.
The new developer does not need to manually install the declared Go toolchain and Git versions through separate package managers. Nix supplies the configured tools when the shell is entered.
Nix may download required packages the first time the environment is used. Later runs can reuse available store paths.
Step 5: Add Project Commands to the Environment
A development shell can include more than the compiler and version-control tools.
For example, a project might need a formatter, a linter, and a test runner.
The following configuration illustrates how to extend the package list:
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
go
git
gopls
golangci-lint
];
};
This snippet replaces the earlier devShells.${system}.default definition. It is not a second standalone flake.
The added tools are examples of how a team can make commonly used development utilities available through the same environment.
Before adopting a tool, verify that the selected package version is compatible with your project. Some tools are also distributed through language-specific version managers or project dependencies, so avoid maintaining conflicting versions without a clear reason.
Use the Same Commands Locally and in CI
Once the environment is defined, document a small set of standard commands for development and CI.
For a Go project, these might include:
go fmt ./...
go vet ./...
go test ./...
go build ./...
These commands run the project's formatting, static checks, tests, and build.
The flake makes the required tools available, but it does not automatically execute these commands. You still need to configure your CI workflow to enter the Nix environment and run the required checks.
Keeping the commands consistent between local development and CI reduces the chance that developers validate their changes with a different toolchain from the one used during deployment.
Step 6: Use the Flake in CI
A CI workflow can use Nix to enter the project's declared development shell before running tests.
For example, a shell-based CI job might execute:
nix develop --command bash -c '
go version
go fmt ./...
go vet ./...
go test ./...
go build ./...
'
This command enters the flake's development environment and executes the checks using the declared tools.
Your CI runner must have Nix installed and configured to support the flake. Depending on the environment, it may also need access to the Nix store, required caches, and network resources.
For a real pipeline, use the CI provider's recommended Nix setup and caching strategy. Reusing downloaded build inputs can reduce repeated setup time.
If the project supports multiple operating systems or CPU architectures, run the relevant checks on each supported target. A Linux development shell alone does not validate a macOS build.
Best Practices for Maintaining Nix Flakes
Pin Inputs and Review Updates
Commit flake.lock and update it intentionally.
Review package changes during updates, especially when toolchain versions affect compiler behavior, generated files, or production builds.
Keep the Development Shell Focused
Include the tools developers need for normal work, testing, and debugging.
Avoid adding unrelated packages that increase environment setup time or make the configuration harder to maintain.
Support Every Required Platform
Declare outputs for the platforms used by your team and CI infrastructure.
Check that all selected packages are available on each target. A package available on Linux may not be available on another operating system.
Separate Development and Production Requirements
A development shell can contain formatters, debuggers, and test tools that do not belong in a production runtime image.
Use appropriate build and deployment definitions for production artifacts rather than assuming that the development shell itself is a complete deployment strategy.
Avoid Hidden Dependencies
If a build requires a tool, library, environment variable, or generated file, make that requirement explicit.
A reproducible shell cannot compensate for a build process that silently depends on undeclared software installed on a developer's machine.
Use Caching Carefully
Nix can reuse existing store paths and obtain compatible build artifacts from configured binary caches.
Caching can improve setup performance, but it should not replace correct dependency declarations or verification of build outputs.
Common Mistakes and Troubleshooting
The Flake Works on Linux but Fails on macOS
The configuration may define outputs only for x86_64-linux.
Add outputs for the platforms your team supports and ensure that every selected package is available for those systems.
Developers Get Different Dependency Versions
Check whether flake.lock is committed and whether everyone is using the repository's current version.
If a developer updates inputs locally without committing the resulting lockfile, their environment may differ from the one used by the rest of the team.
Nix Cannot Evaluate the Flake
Check the Nix version, experimental feature configuration, file syntax, and input availability.
If the project uses a restricted network environment, verify that the declared inputs can be fetched or are available through an approved cache.
A Build Still Depends on Software Outside the Flake
Inspect the build scripts and environment variables. A shell declaration does not automatically capture every dependency used by a project.
Add missing tools to the configuration and make required setup steps explicit.
Environment Setup Takes Too Long
Check whether the CI system repeatedly downloads the same dependencies or builds expensive packages from source.
Use an appropriate binary cache and keep the dependency set focused. Also distinguish the cost of entering the shell from the time required to compile the application itself.
Advantages and Disadvantages
Advantages
- Development tools are declared in version-controlled configuration.
- A lockfile helps keep input revisions consistent across machines.
- New developers can use a shared environment without manually installing every tool.
- CI can reuse the same environment definition as local development.
- Toolchain updates can be reviewed as changes to the repository.
Disadvantages
- Developers need to learn Nix configuration and its evaluation model.
- Initial setup and package downloads can take time.
- Platform-specific configuration may be necessary.
- A lockfile does not make every external dependency or build step reproducible.
- Teams must maintain the flake and review dependency updates.
Summary
Nix Flakes provide a declarative way to define development environments and pin their inputs through a lockfile. For teams that work across different machines, this helps reduce differences in compiler versions, command-line tools, and other development dependencies.
A practical starting point is to create a flake.nix file, declare the required tools, generate and commit flake.lock, and use nix develop for local development. The same environment can then be used in CI, provided the runner is configured correctly.
Nix Flakes do not eliminate every environmental difference, but they make important parts of the setup explicit and reviewable. With a maintained lockfile and consistent build commands, teams can make development environments easier to reproduce and less dependent on individual machine configurations.
Join the conversation! Your thoughts help the community grow.