You have flakes, devShells, a flake.lock that locks every dependency. Now, how do you turn this into concrete team usage? This guide turns the building blocks of guides 8 to 10 into six operational use cases: a standardised workstation, CI/CD identical to local, automated checks, a reproducible build of an internal tool, an update strategy and express onboarding. Each example relies on the same reference flake.nix, replayed on 1 October 2026 with Nix 2.35.2 and nixos-26.05, the current stable branch.
What you will learn
- Build a team devShell shipping Terraform, Ansible, kubectl, Helm and the quality tools
- Consume this devShell in GitHub Actions and GitLab CI, without extra configuration
- Define reproducible checks (lint, format) run locally and in CI with
nix flake check - Package an administration script as a reproducible Nix derivation with
writeShellApplication - Manage
flake.lockupdates without surprises for the team - Bring the onboarding of a new member down to
git clone+nix develop
In what context?
Nix becomes particularly useful as soon as a team is involved, because each workstation and each CI runner drifts on its own. The following situations are its typical symptoms:
- Two engineers have different versions of Terraform or Ansible, and the plans diverge
- The CI installs packages with
sudo apt installin the pipeline, making builds fragile and slow - A new contributor spends half a day installing the tooling before writing the first line
- A silent update of
helmin CI breaks the deployment, impossible to reproduce locally - Shell scripts are valid "on my machine" but not on the runner
A flake.nix versioned with the code structurally solves each of these problems.
Prerequisites
This guide follows on from guides 8 (environments), 9 (factoring) and 10 (flakes). You must:
- have Nix installed with flakes enabled (see the installation guide),
- understand the structure of a
flake.nix(inputs, outputs, devShells), - know how to use
nix developandnix flake check.
Structure of the reference project
Create this project in your lab directory. All the use cases rely on this same base:
mkdir -p ~/Projects/lab-nix && cd ~/Projects/lab-nixmkdir -p nix scripts srcgit initRépertoirelab-nix/
- flake.nix (entry point, inputs + delegation to the modules)
- flake.lock (locked revisions, always committed to Git)
Répertoirenix/
- devshells.nix (team devShell)
- checks.nix (automatable checks)
- packages.nix (reproducible builds)
Répertoirescripts/
- lint.sh (shell lint)
- validate.sh (Terraform validation)
Répertoiresrc/ (source code)
- …
- .envrc (automatic activation with direnv, optional)
The root flake.nix stays short thanks to the factoring into nix/. This is the convention introduced in guide 9. Two details matter: Terraform has been published under the BSL licence since version 1.6, and nixpkgs refuses to evaluate it until you explicitly allow it; and the file is written in the nixfmt format, otherwise the format check of use case 3 fails on it.
{ description = "Boîte à outils DevOps : reproductible pour toute l'équipe";
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; };
outputs = { self, nixpkgs, flake-utils, }: flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs { inherit system; config.allowUnfreePredicate = pkg: builtins.elem (nixpkgs.lib.getName pkg) [ "terraform" ]; }; src = ./.; in { devShells.default = import ./nix/devshells.nix { inherit pkgs; }; checks = import ./nix/checks.nix { inherit pkgs src; }; packages = import ./nix/packages.nix { inherit pkgs; }; } );}Without the allowUnfreePredicate block, pkgs = nixpkgs.legacyPackages.${system} would do for everything else, but entering the shell fails:
error: Refusing to evaluate package 'terraform-1.15.3' in /nix/store/…/pkgs/applications/networking/cluster/terraform/default.nix:85 because it has an unfree license (‘bsl11’)Use case 1, Standardised DevOps toolbox
The goal
A single devShell that ships all the tooling of a DevOps team. Each member enters this environment with nix develop and gets the same versions, whatever their distribution or installation history. Mind the attribute names: in nixpkgs, helm is an audio synthesiser, the Kubernetes package manager is called kubernetes-helm.
{ pkgs }:
pkgs.mkShell { name = "devops-toolkit";
packages = with pkgs; [ # Version control git git-crypt
# Structured data processing jq yq-go
# Shell code quality shellcheck shfmt
# Infrastructure as Code terraform opentofu
# Configuration management ansible ansible-lint
# Kubernetes kubectl kubernetes-helm kustomize k9s
# Security trivy cosign
# Network debugging curl httpie ];
shellHook = '' echo "DevOps environment active ($(uname -m))" echo " Terraform : $(terraform version 2>/dev/null | head -1)" echo " kubectl : $(kubectl version --client 2>/dev/null | head -1)" echo " Helm : $(helm version --short 2>/dev/null)" echo " Ansible : $(ansible --version 2>/dev/null | head -1)" '';}Test
Add both files to the Git index, then enter the shell: the shellHook prints the versions provided by nixos-26.05.
cd ~/Projects/lab-nixgit add flake.nix nix/devshells.nixnix developDevOps environment active (x86_64) Terraform : Terraform v1.15.3 kubectl : Client Version: v1.36.3 Helm : v3.20.2+gv3.20.2 Ansible : ansible [core 2.21.1]Use case 2, CI/CD identical to the local workstation
The principle
Your flake.nix is versioned with the code. The CI consumes it directly with the same command as locally:
# Locallynix develop --command bash -c "shellcheck scripts/*.sh && terraform -chdir=infra validate"
# In CI: exactly the same commandnix develop --command bash -c "shellcheck scripts/*.sh && terraform -chdir=infra validate"The three following tabs apply this principle to GitHub Actions, GitLab CI and a self-hosted runner. The GitHub actions are pinned on the SHA of their commit, with the version as a comment, and the workflow only receives the permission to read the repository.
name: CI
on: [push, pull_request]
permissions: {}
jobs: validate: runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} extra_nix_config: | experimental-features = nix-command flakes
# Binary cache: drastically cuts download time - uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 with: name: my-org-cache authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Check the flake structure run: nix flake check --no-build
- name: Lint and IaC validation run: | nix develop --command bash -c " shellcheck scripts/*.sh terraform -chdir=infra validate ansible-lint playbooks/ || true "image: nixos/nix:2.35.2@sha256:7a007c766426c1877758ddc5cb87a965ac131fc78c582ce0083d922d51ae945c
variables: NIX_CONFIG: "experimental-features = nix-command flakes"
stages: - validate - test
flake-check: stage: validate script: - git config --global --add safe.directory "$CI_PROJECT_DIR" - nix flake check --no-build
lint: stage: validate script: - nix develop --command shellcheck scripts/*.sh
validate-iac: stage: test script: - nix develop --command terraform -chdir=infra validate - nix develop --command ansible-lint playbooks/ || trueNo cache: block on /nix/store here: the GitLab cache only accepts paths inside the project directory, and a /nix/store declared in cache:paths is simply not saved. To avoid downloading the packages again for every job, use a binary cache (Cachix, or an S3 cache declared in NIX_CONFIG) or a persistent runner.
On a persistent runner, install Nix only once. The /nix store is shared by every job, no more downloading again.
# Nix 2.35.2 installer, checked against its hash before runningcurl -fsSL -o install-nix.sh https://releases.nixos.org/nix/nix-2.35.2/installecho "$(curl -fsSL https://releases.nixos.org/nix/nix-2.35.2/install.sha256) install-nix.sh" \ | sha256sum --check
# Multi-user installation on the CI serversh install-nix.sh --daemon
# Enable flakes for the CI usermkdir -p /home/ci-runner/.config/nixecho "experimental-features = nix-command flakes" \ >> /home/ci-runner/.config/nix/nix.confThe first job fills the store, which takes a few minutes. The following ones reuse the packages already present, and nix develop only has to evaluate the flake.
Use case 3, Reproducible checks with nix flake check
The goal
Define checks (lint, format) as Nix derivations: the check becomes a package like any other, built in the Nix sandbox.
- runnable locally with
nix flake check, - integrated automatically in CI,
- cached, a check that already passed does not run again.
Define the checks
Each check is a runCommandLocal that runs the tool then creates $out: if the tool fails, the derivation fails, and nix flake check with it. The nixfmt package is the official formatter of Nix; the old nixfmt-rfc-style name is now only an alias.
{ pkgs, src }:
{ # Shell script checks shellcheck = pkgs.runCommandLocal "check-shellcheck" { buildInputs = [ pkgs.shellcheck ]; } '' find ${src}/scripts -name "*.sh" -exec shellcheck {} + touch $out '';
# Formatting of the Nix files (RFC style) nixfmt = pkgs.runCommandLocal "check-nixfmt" { buildInputs = [ pkgs.nixfmt ]; } '' nixfmt --check ${src}/flake.nix find ${src}/nix -name "*.nix" -exec nixfmt --check {} + touch $out '';}Run the checks
Run all the checks at once, or a single one when debugging: nix build on the check attribute prints its logs if it fails.
cd ~/Projects/lab-nixgit add nix/checks.nix scripts/
# All the checksnix flake check
# A single check, with a detailed trace on errornix build .#checks.x86_64-linux.shellcheck --show-tracerunning 2 flake checks...building '/nix/store/cfk8z97ymsxb5wiiw5qawd5pd53j400q-check-nixfmt.drv'...building '/nix/store/z09h1az790v0m84jsyd5ivcn2w9ksyig-check-shellcheck.drv'...all checks passed!warning: The check omitted these incompatible systems: aarch64-darwin, aarch64-linux, x86_64-darwinUse '--all-systems' to check all.If shellcheck finds a problem, the check fails and Nix prints the end of the log:
Last 10 log lines: > > In /nix/store/…-source/scripts/lint.sh line 3: > echo $x > ^-- SC2086 (info): Double quote to prevent globbing and word splitting. > > Did you mean: > echo "$x"The format check behaves the same way: a badly formatted Nix file comes out as flake.nix: not formatted. Run nixfmt flake.nix nix/*.nix to fix it before committing.
CI integration
In GitLab CI and GitHub Actions, replace your ad hoc lint scripts with:
nix flake checkIt is a single command, independent of the runner, that runs exactly the same checks as locally.
Use case 4, Reproducible build of an internal tool
The goal
Package an administration script as a Nix derivation. The result is built from the same inputs on every machine, hence at the same store path, and can be distributed through a binary cache or installed directly with nix profile add.
Example: Kubernetes audit script
The script assumes nothing about the workstation that runs it: kubectl and jq come from the store, at the version fixed by the flake.lock, and writeShellApplication refuses to build a script that shellcheck finds faulty.
{ pkgs }:
{ # Kubernetes audit tool with its exact dependencies shipped k8s-audit = pkgs.writeShellApplication { name = "k8s-audit"; runtimeInputs = with pkgs; [ kubectl jq ]; text = '' echo "=== Cluster nodes ===" kubectl get nodes -o json \ | jq -r '.items[] | "\(.metadata.name)\t\(.status.conditions[] | select(.type=="Ready") | .status)"'
echo "" echo "=== Pods not Running ===" kubectl get pods --all-namespaces \ --field-selector 'status.phase!=Running,status.phase!=Succeeded' \ 2>/dev/null || echo "(none)"
echo "" echo "=== Recent Warning events ===" kubectl get events --all-namespaces \ --field-selector type=Warning \ --sort-by='.lastTimestamp' \ 2>/dev/null | tail -15 ''; };}Build, test, distribute
The four commands cover the full cycle: build the tool, run it from ./result, try it without installing it, then add it to the profile.
cd ~/Projects/lab-nixgit add nix/packages.nix
# Buildnix build .#k8s-audit
# Test (the binary is in ./result/bin/)./result/bin/k8s-audit
# Use temporarily without installingnix shell .#k8s-audit --command k8s-audit
# Install into the user profilenix profile add .#k8s-auditThe k8s-audit binary uses its own versions of kubectl and jq from the Nix store: it works on any machine with Nix installed, with no system dependency.
Use case 5, Update strategy for flake.lock
The problem
flake.lock freezes the nixpkgs revision. It must be updated regularly to get the security fixes and new tool versions, without breaking the team along the way.
Update workflow
The six steps isolate the update in a branch, have it validated by the CI, and keep a way back.
-
Create a dedicated branch
Terminal window git checkout -b chore/nix-update-$(date +%Y-%m) -
Update all the dependencies
Terminal window nix flake updateThis recreates
flake.lockwith the most recent revisions of each declared input. -
Inspect what changed
Terminal window git diff flake.lock"rev": "<previous revision, 40 characters>","rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",The
nixpkgsrevision is the only real information. Look atgithub.com/NixOS/nixpkgs/compare/<old-rev>...<new-rev>to see the changes included. -
Validate locally
Terminal window nix flake checknix develop --command bash -c "terraform version && kubectl version --client" -
Open a PR/MR
The CI validates the checks automatically. The PR is merged if everything is green.
-
Roll back if there is a problem
Terminal window # Simply go back to the previous nixpkgs revisiongit revert HEAD# Or pin a specific revisionnix flake lock --override-input nixpkgs \github:nixos/nixpkgs/<40-character-revision>
Recommended cadence
The right frequency depends on the team's pace and exposure: the more dependencies there are, the more changes a late update brings in at once.
| Frequency | Use case |
|---|---|
| Weekly | Teams with heavy CI/CD activity, many dependencies |
| Monthly | Stable projects, tooling that changes little |
| On demand | Critical CVE in a dependency spotted by monitoring |
Use case 6, Express onboarding
A new DevOps engineer joins the team. In less than 15 minutes, they have the complete environment, at the same versions as their colleagues.
-
Install Nix (only once, 3-5 minutes)
Terminal window curl -fsSL -o install-nix.sh https://releases.nixos.org/nix/nix-2.35.2/installecho "$(curl -fsSL https://releases.nixos.org/nix/nix-2.35.2/install.sha256) install-nix.sh" \| sha256sum --checksh install-nix.sh --no-daemonsource ~/.nix-profile/etc/profile.d/nix.sh -
Enable flakes
Terminal window mkdir -p ~/.config/nixecho "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf -
Clone and enter the environment
Terminal window git clone https://gitlab.mon-org.example.com/infra/devops-tools.gitcd devops-toolsnix develop -
Check
Terminal window terraform versionkubectl version --clienthelm versionansible --versionEvery version shown is identical to those of the rest of the team.
-
(Optional) Automatic activation with direnv
Terminal window nix profile add nixpkgs#direnvecho 'eval "$(direnv hook bash)"' >> ~/.bashrcsource ~/.bashrcdirenv allow .From then on, the flake environment loads automatically on every
cdinto the directory, provided the repository contains an.envrcwithuse flake.
The installation README comes down to these 5 steps. No more package list to maintain, no more "it works on my machine": the source of truth is the flake.
Common thread: Python + Terraform + Ansible project
Here is how the six use cases combine in a realistic project. The ci shell ships everything the pipeline calls, ansible-lint included: a tool missing from the shell would make the job fail with command not found. The file is already in the nixfmt format, which its own check verifies.
{ description = "Infra : Python + Terraform + Ansible";
inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; };
outputs = { self, nixpkgs, flake-utils, }: flake-utils.lib.eachDefaultSystem ( system: let # Terraform is under the BSL licence: nixpkgs refuses to evaluate it without explicit permission pkgs = import nixpkgs { inherit system; config.allowUnfreePredicate = pkg: builtins.elem (nixpkgs.lib.getName pkg) [ "terraform" ]; }; src = ./.; in { # Use case 1: several shells by role devShells = { default = pkgs.mkShell { name = "infra-full"; packages = with pkgs; [ python312 python312Packages.boto3 terraform ansible ansible-lint kubectl kubernetes-helm shellcheck shfmt ]; shellHook = '' echo "Full environment active" ''; }; ci = pkgs.mkShell { # Lighter shell for CI: no interactive elements name = "infra-ci"; packages = with pkgs; [ python312 terraform ansible ansible-lint kubectl shellcheck ]; }; };
# Use case 3: automated checks checks = { shellcheck = pkgs.runCommandLocal "check-shellcheck" { buildInputs = [ pkgs.shellcheck ]; } '' find ${src}/scripts -name "*.sh" -exec shellcheck {} + touch $out ''; nixfmt = pkgs.runCommandLocal "check-nixfmt" { buildInputs = [ pkgs.nixfmt ]; } '' nixfmt --check ${src}/flake.nix touch $out ''; };
# Use case 4: packageable internal tool packages.default = pkgs.writeShellApplication { name = "infra-status"; runtimeInputs = with pkgs; [ kubectl jq python312 ]; text = '' echo "=== Cluster status ===" kubectl get nodes -o json | jq -r '.items[].metadata.name' ''; }; } );}The matching pipeline calls the ci shell for each tool, and builds the internal tool exposed under packages.default, hence a nix build with no attribute:
name: Infra CI
on: [push, pull_request]
permissions: {}
jobs: checks: runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} extra_nix_config: | experimental-features = nix-command flakes - run: nix flake check - run: nix develop .#ci --command terraform -chdir=infra validate - run: nix develop .#ci --command ansible-lint playbooks/ - run: nix buildDevOps-specific pitfalls
These pitfalls are not about Nix syntax but about its use in a team: they go unnoticed on an isolated workstation and show up in CI or at the first version change.
| Pitfall | Impact | Fix |
|---|---|---|
git add forgotten before nix develop | Nix rejects the file: Path '…' is not tracked by Git | Systematic git add -A before any Nix command |
flake.lock missing from the repository | Each machine or CI job resolves a different revision | Commit flake.lock, it is a feature, not a throwaway artifact |
| Terraform without licence permission | Refusing to evaluate package 'terraform-…' because it has an unfree license | allowUnfreePredicate limited to terraform, or move to opentofu |
helm instead of kubernetes-helm | An audio synthesiser gets installed, helm version does not answer | Use the kubernetes-helm attribute |
| No Nix cache in CI | Slow first job (several minutes of downloads) | Set up Cachix or a persistent runner with /nix mounted |
Too many tools in a single devShell | Long entry time, conflicts more likely | Several devShells named by role (default, sec, ci) |
Heavy shellHook | Slows down every nix develop | Keep the shellHook short, delegate to scripts |
Ignoring nix flake check in PRs | Structure errors detected too late | Add nix flake check --no-build as the first job |
Updating flake.lock without a dedicated branch | Impossible to roll back cleanly | Always a dedicated branch + PR for updates |
Key points
- The flake becomes the contract between the development workstation, the CI and colleagues, a single source of truth for the whole team
nix develop --command ...in CI consumes exactly the same environment as locally, no more divergenceflake.lockmust be versioned in Git, it guarantees that everyone and the CI work with the same revisions- Nix checks are cached: a check that already passed does not run a second time
writeShellApplicationis the clean way to package scripts with their dependencies, placed at the front of the script's own PATH- Onboarding comes down to: install Nix once +
git clone+nix develop - Update
flake.lockregularly through a dedicated PR, never directly on the main branch
Knowledge check
Check that the essentials of this guide are acquired. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Understanding Linux cgroups
A new full quiz with random questions
Work again only on the questions you missed