Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

DevOps use cases with Nix: standardise environments and make CI/CD reliable

By updated

Improve this page

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.lock updates 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 install in 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 helm in 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 develop and nix flake check.

Structure of the reference project

Create this project in your lab directory. All the use cases rely on this same base:

Terminal window
mkdir -p ~/Projects/lab-nix && cd ~/Projects/lab-nix
mkdir -p nix scripts src
git init
  • Ré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.

flake.nix
{
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.

nix/devshells.nix
{ 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.

Terminal window
cd ~/Projects/lab-nix
git add flake.nix nix/devshells.nix
nix develop
DevOps 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:

Terminal window
# Locally
nix develop --command bash -c "shellcheck scripts/*.sh && terraform -chdir=infra validate"
# In CI: exactly the same command
nix 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.

.github/workflows/ci.yml
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
"

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.

nix/checks.nix
{ 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.

Terminal window
cd ~/Projects/lab-nix
git add nix/checks.nix scripts/
# All the checks
nix flake check
# A single check, with a detailed trace on error
nix build .#checks.x86_64-linux.shellcheck --show-trace
Result (end of output)
running 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-darwin
Use '--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:

Terminal window
nix flake check

It 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.

nix/packages.nix
{ 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.

Terminal window
cd ~/Projects/lab-nix
git add nix/packages.nix
# Build
nix build .#k8s-audit
# Test (the binary is in ./result/bin/)
./result/bin/k8s-audit
# Use temporarily without installing
nix shell .#k8s-audit --command k8s-audit
# Install into the user profile
nix profile add .#k8s-audit

The 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.

  1. Create a dedicated branch

    Terminal window
    git checkout -b chore/nix-update-$(date +%Y-%m)
  2. Update all the dependencies

    Terminal window
    nix flake update

    This recreates flake.lock with the most recent revisions of each declared input.

  3. Inspect what changed

    Terminal window
    git diff flake.lock
    "rev": "<previous revision, 40 characters>",
    "rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",

    The nixpkgs revision is the only real information. Look at github.com/NixOS/nixpkgs/compare/<old-rev>...<new-rev> to see the changes included.

  4. Validate locally

    Terminal window
    nix flake check
    nix develop --command bash -c "terraform version && kubectl version --client"
  5. Open a PR/MR

    The CI validates the checks automatically. The PR is merged if everything is green.

  6. Roll back if there is a problem

    Terminal window
    # Simply go back to the previous nixpkgs revision
    git revert HEAD
    # Or pin a specific revision
    nix flake lock --override-input nixpkgs \
    github:nixos/nixpkgs/<40-character-revision>

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.

FrequencyUse case
WeeklyTeams with heavy CI/CD activity, many dependencies
MonthlyStable projects, tooling that changes little
On demandCritical 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.

  1. Install Nix (only once, 3-5 minutes)

    Terminal window
    curl -fsSL -o install-nix.sh https://releases.nixos.org/nix/nix-2.35.2/install
    echo "$(curl -fsSL https://releases.nixos.org/nix/nix-2.35.2/install.sha256) install-nix.sh" \
    | sha256sum --check
    sh install-nix.sh --no-daemon
    source ~/.nix-profile/etc/profile.d/nix.sh
  2. Enable flakes

    Terminal window
    mkdir -p ~/.config/nix
    echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
  3. Clone and enter the environment

    Terminal window
    git clone https://gitlab.mon-org.example.com/infra/devops-tools.git
    cd devops-tools
    nix develop
  4. Check

    Terminal window
    terraform version
    kubectl version --client
    helm version
    ansible --version

    Every version shown is identical to those of the rest of the team.

  5. (Optional) Automatic activation with direnv

    Terminal window
    nix profile add nixpkgs#direnv
    echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
    source ~/.bashrc
    direnv allow .

    From then on, the flake environment loads automatically on every cd into the directory, provided the repository contains an .envrc with use 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.

flake.nix (complete version)
{
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:

.github/workflows/ci.yml (common thread)
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 build

DevOps-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.

PitfallImpactFix
git add forgotten before nix developNix rejects the file: Path '…' is not tracked by GitSystematic git add -A before any Nix command
flake.lock missing from the repositoryEach machine or CI job resolves a different revisionCommit flake.lock, it is a feature, not a throwaway artifact
Terraform without licence permissionRefusing to evaluate package 'terraform-…' because it has an unfree licenseallowUnfreePredicate limited to terraform, or move to opentofu
helm instead of kubernetes-helmAn audio synthesiser gets installed, helm version does not answerUse the kubernetes-helm attribute
No Nix cache in CISlow first job (several minutes of downloads)Set up Cachix or a persistent runner with /nix mounted
Too many tools in a single devShellLong entry time, conflicts more likelySeveral devShells named by role (default, sec, ci)
Heavy shellHookSlows down every nix developKeep the shellHook short, delegate to scripts
Ignoring nix flake check in PRsStructure errors detected too lateAdd nix flake check --no-build as the first job
Updating flake.lock without a dedicated branchImpossible to roll back cleanlyAlways 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 divergence
  • flake.lock must 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
  • writeShellApplication is 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.lock regularly 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

6 questions
6 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn