Tutorial

Nix : Maîtrisez les environnements reproductibles

Dans ce tutoriel nous présenterons Nix et son utilisation au quotidien pour un développeur ou un administrateur système.

Comprendre les concepts fondamentaux de Nix et être capable de l'utiliser pour gérer des environnements de développement, packager une application simple.

Introduction

Si vous êtes un développeur ou un administrateur système vous avez sans doute rencontré ces situations :

Le code récupéré

Vous récupérez le code d'un collègue pour l'utiliser sur votre machine. Celui-ci vous avertit, le code est encore en cours d'écriture mais cela fonctionne sur ma machine. Après un git clone et une lecture rapide rapide du README.md vous vous lancez dans l'installation. Mais les ennuis commencent, vous utilisez Debian et votre collègue RedHat, les commandes et les noms des paquets ne sont pas les mêmes.

Ensuite la version d'openssl sur Debian est trop ancienne. Après 1h sur StackOverflow vous arrivez à installer la version sans casser tout le système. Maintenant cette librairie étrange. Elle n'est pas fournie dans Debian, il va falloir la compiler. Malheureusement vous n'avez pas installé de compilateur.

Après une après-midi vous arrivez enfin à lancer votre application. L'autre bonne nouvelle est que maintenant le README.md contient une entrée pour l'installation sur une Debian.

L'administrateur système

Notre administrateur système peut être confronté au même problème que le développeur au-dessus, à savoir faire tourner du code sur un serveur.

Mais il a aussi à gérer un parc de serveurs. Heureusement ils sont tous identiques et provisionnés avec Ansible. En y regardant de plus près il y a une différence de version sur une librairie entre deux serveurs. Ah oui un développeur avait besoin d'une version particulière et elle a été installée à la main. Il faudra faire attention en relançant ansible.

Et ce serveur pourquoi est-ce encore l'ancienne version de ce logiciel. Il semble que la mise à jour de sécurité automatique n'a pas fonctionné.

Comment Nix peut nous aider

On pourrait donner encore d'autres exemples (les images docker qui changent...) mais ces deux exemples suffisent.

Nos outils opèrent sur un état global et les installations peuvent avoir des effets de bord. Les installations ne sont pas reproductibles et sont souvent incrémentales.

Nix n'est pas juste un "meilleur apt". C'est une nouvelle fondation qui offre des garanties que les autres outils ne peuvent pas fournir :

  • Reproductibilité parfaite : Fini le "ça marche sur ma machine". Si une dépendance est définie, elle sera reconstruite exactement à l'identique, bit par bit, sur votre machine, sur celle de votre collègue et en CI.
  • Isolation totale : Installez 5 versions de Python et 10 versions de openssl sans aucun conflit. Chaque projet vit dans sa bulle hermétique, sans jamais affecter le système global ou les autres projets.
  • Gestion déclarative: Vous ne décrivez plus une série d'instructions ("installe ça, puis ça, puis configure ça"). Vous décrivez l'état final désiré dans un seul fichier. Nix se charge de le matérialiser. La dérive de configuration devient impossible.
  • Rollbacks atomiques : Une mise à jour a tout cassé ? Une seule commande vous ramène instantanément à l'état précédent qui fonctionnait. Fini les restaurations de snapshots angoissantes.

Ce tutoriel va vous montrer, étape par étape, comment passer de la gestion du chaos à la maîtrise de votre environnement. Prêt à commencer ?

Premiers pas - Les environnements à la demande

Nous allons commencer à utiliser Nix sur une Debian déjà configurée.

Avant de commencer on va parler du nix store et du FHS, Filesystem Hierarchy Standard.

Le FHS

Le FHS vous devez connaître, c'est ce qui organise le système de fichiers de la plupart des systèmes UNIX (Linux, macOS, etc.). Il nous dit que les exécutables des utilisateurs se trouvent dans /usr/bin, les bibliothèques partagées dans /usr/lib, et les fichiers de configuration dans /etc.

Quand on installe un logiciel avec apt ou un autre gestionnaire de paquets on va mettre des fichiers dans ou plusieurs de ces répertoires. Et tous les utilisateurs de la machine peuvent y accéder.

C'est un problème si on a besoin de mettre à jour un version de librairie, il faut le faire pour toutes les applications. Et si une application est cassée et n'est pas compatible avec la nouvelle version ce n'est pas simple de revenir en arrière.

Le /nix/store

C'est ici que Nix opère une rupture philosophique radicale. Au lieu d'installer notre application dans des répertoires partagés, Nix utilise un entrepôt immuable : le répertoire /nix/store.

À quoi ressemble un chemin dans le Nix store :

/nix/store/1h9m1w6rg365005k7f4zvn18f8v923k1-jq-1.6/bin/jq
/nix/store/s2z997f3nq3zm1dp6cf8v283p32is1h7-openssl-3.0.7/lib/libssl.so

Décortiquons cette structure :

  1. /nix/store/ : La racine de l'entrepôt. Tout ce que Nix gère se trouve ici. Ce répertoire est en lecture seule pour les utilisateurs standards.
  2. 1h9m1w6rg365005k7f4zvn18f8v923k1 : C'est un hash cryptographique qui n'est pas calculé sur le nom du paquet, mais sur tous ses ingrédients de construction (sa "dérivation"). Cela inclut :
    • Le code source.
    • Toutes les dépendances (ex: gcc, openssl), qui sont elles-mêmes des chemins du Nix store.
    • Le script de build.
    • Les flags de compilation.
    • L'architecture du système.
  3. -jq-1.6 : Un nom lisible pour l'humain, pour faciliter l'identification.

Qu'est-ce que cela signifie ?

  • Immutabilité : Une fois qu'un paquet est construit et placé dans le store (par exemple, ...-jq-1.6), son contenu ne changera jamais. Si vous voulez une version patchée de jq, Nix créera un nouveau répertoire avec un nouveau hash. L'ancien reste intact.
  • Coexistence sans conflit : Vous voulez openssl version 1.1 et 3.0 sur le même système ? Aucun problème. Elles vivront dans deux répertoires distincts du store, avec deux hash différents. Une application qui a besoin de la v1.1 pointera explicitement vers le chemin /nix/store/hash-de-la-v1.1/... et l'autre vers /nix/store/hash-de-la-v3.0/.... Elles ne peuvent pas entrer en conflit.
  • Reproductibilité garantie : Si vous et votre collègue construisez le même paquet avec les mêmes ingrédients, vous obtiendrez exactement le même hash et le même chemin dans le /nix/store. C'est la fin du "ça marche sur ma machine".

Nix ne gère pas un système de fichiers. Il gère une base de données d'objets de construction où chaque objet est adressable par son contenu. C'est ce qui rend possible les environnements isolés, les rollbacks atomiques et la reproductibilité parfaite.

Un shell éphémère

Pour commencer à utilise Nix nous allons créer un shell éphémère. Cet environnement va nous permettre d'utiliser un programme sans l'installer.

Bonjour le monde, style nix

Le passage obligatoire de toute démonstration est le classique "Hello World !". Nous allons utiliser le bien nommé paquet hello qui fournit la commande... hello.

Vérifions que la commande hello n'est pas disponible sur notre machine

hello

Sans surprise, le système répondra par une erreur, car cette commande n'est pas installée :

hello: command not found

Ceci est notre état de base, le système est propre.

Maintenant la commande nix shell va nous permettre de créer un shell contenant la commande hello.

nix shell nixpkgs#hello

Patientons quelques instants.

La commande nix shell nixpkgs#hello permet d’ouvrir un shell dans lequel le package hello sera disponible. Et sans installation globale ni modification permanente de votre système.

Le prompt de notre terminal a changé pour signaler que nous sommes dans un nouveau shell (Modification pour la compréhension du tutoriel). Dans ce shell nous relançons la même commande.

hello

Et nous obtenons le résultat

Hello, world!

Nous pouvons vérifier la version du binaire hello avec la commande :

hello --version

Et nous pouvons vérifier que la commande hello provient bien du nix store et n'est pas installé dans un répertoire système classique :

which hello
/nix/store/b1ayn0ln6n8bm2spz441csqc2ss66az3-hello-2.12.2/bin/hello

La commande nix shell

Prenons un moment pour détailler la commande nix shell nixpkgs#hello.

  • nix shell est la nouvelle interface de la commande nix, utilisée pour lancer un shell où les paquets spécifiés sont disponibles.
  • nixpkgs fait référence au dépôt principal de paquets Nix, souvent la branche unstable par défaut.
  • #hello demande le paquet dans le dépôt.

L'environnement ouvert est temporaire et reproductible : aucune modification n’est faite au système de base.

Ces environnements sont utilisés pour tester ou utiliser des exécutables sans polluer l'espace utilisateur.

Note

💡On peut faire référence à d'autres dépôts que nixpkgs et la branche instable. Par exemple pour la branche actuellement stable on utilisera github:nixos/nixpkgs/nixos-25.05. Cela correspond à la version de mai 2025 qui est dans la branche nixos-25.05

🛠️ Petit exercice : comment installer une version vieille de 4 ans, disons celle de mai 2021 ?

On peut ouvrir un shell avec plusieurs paquets comme hello et cowsay.

On peut aussi vouloir lancer une commande directement sans ouvrir de shell interactif.

Important

⚠️ Si on veut lancer directement plusieurs commandes il faut passer la commande par un shell :

nix shell nixpkgs#hello nixpkgs#cowsay --command /bin/sh -c "hello | cowsay"

Les scripts éphémères ?

On peut tout à fait lancer des scripts python sans avoir installé python :

cat > mon_script.py <<EOF
#! /usr/bin/env nix
#! nix shell nixpkgs#python3 --command python

print("Bonjour python !")
EOF
chmod +x mon_script.py
./mon_script.py

Un environnement persistent

Nix permet de définir des environnements de développement (ou de test, production...) qui sont persistants, c'est à dire reproductibles et durables. Une des façons de faire, et qui me semble être la façon "moderne" de faire est d'utiliser des flakes.

Les flakes

Les flakes proposent une structure standardisée : chaque projet repose sur un fichier flake.nix placé à la racine. Ce fichier déclare explicitement les dépendances (inputs) ainsi que les outputs (packages, devShell, configurations NixOS, etc.). Chaque version est verrouillée dans un fichier flake.lock. Cela permet la reproductibilité et la traçabilité de l'environnement.

Les dépendances sont figées à des versions précises (commit git, hash de fichier d'archive...) ce qui rend l'environnement persistant et cohérent dans le temps. L'utilisation de l'environnement sera identique pour tous les utilisateurs et toutes les machines, sans modifier l'environnement de l'utilisateur ou sans que celui-ci modifie notre environnement de travail.

Un environnement de développement python

Nous allons commencer par déclarer un environnement de développement, très simple, pour écrire du python.

mkdir -p dev-python
cd dev-python
cat > flake.nix <<EOF
{
   description = "Un environnement de développement pour python";

   inputs =  {
      nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
      flake-utils.url = "github:numtide/flake-utils";
   };

   outputs = { self, nixpkgs, flake-utils }:
      let
        systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ];
      in
        # équivalent à flake-utils.lib.eachDefaultSystem
        flake-utils.lib.eachSystem systems (system:
      let
        pkgs = import nixpkgs { inherit system; };
      in 
      {
        devShells.default = pkgs.mkShell {
           buildInputs = [ pkgs.python3 ];
        };
      }
    );
}
EOF
Note

💡On peut aussi utiliser l'éditeur de texte pour modifier les fichiers.

Pour se placer dans l'environnement on utilise la commande

nix develop

Le prompt de notre shell a changé et nous signale que nous sommes dans un shell nix.

La commande python est maintenant accessible.

On peut créer un fichier mon_script.py pour afficher un hello world.

Cliquer pour voir un exemple de script
#!/usr/bin/env python3

print("Bonjour python !")

Notre script est on ne peut plus simple. Si on veut faire plus de choses on peut avoir besoin de paquets python. Mais pas question de les installer à la main ou avec pip, on va utiliser les paquets distribués dans le nixpkgs.

On va modifier les buildInputs de cette façon :

           buildInputs = [ 
            pkgs.python3
            (pkgs.python3.withPackages (ps: with ps; [
              numpy
              pandas
              requests
            ]))

On modifie les outils qui seront disponibles dans le shell pour ajouter 3 paquets, en plus de python3. withPackages est une fonction qui va retourner la liste des paquets à inclure dans notre environnement. Nous retrouverons dans le shell l'exécutable python avec les 3 modules installés, comme avec un pip install.

On peut lancer dans le shell :

python -c "import numpy as np; print(np.random.default_rng(seed=42).integers(low=0, high=10, size
=3))"

flake.lock

Si on regarde le contenu de notre répertoire de travail on remarque un fichier flake.lock. Il contient les versions des entrées de notre flake.

Dans notre cas la version de nixpkgs est verrouillée sur la version 25.05 sur un commit précis.

Si on veut mettre à jour la source de nos paquets (un apt update) il faudra utiliser la commande nix flake update.

Pour partager notre environnement nous devrons transmettre les deux fichiers flake.nix et flake.lock.

Cette façon de faire permet d'avoir un environnement reproductible et avec des versions figées.

Plusieurs environnements dans le même flake

Vous avez peut-être remarqué que nous définissons notre environnement avec le nom devShells.default. C'est le shelle qui sera initialisé si on lance nix develop sans plus de précision.

Mais on peut être plus précis, on peut vouloir lancer un shell particulier. Ajoutons ce bloc à la suite du devShells.default.

        devShells.jupyter = pkgs.mkShell {
            buildInputs = [    (pkgs.python3.withPackages (ps: with ps; [
              numpy
              scipy
              matplotlib
              pandas
              jupyter
              ]))
            ];
        };

Nous venons de définir un nouveau shell du nom de jupyter. Pour l'instancier nous devons lancer la commande nix develop .#jupyter.

Avoir plusieurs shells permet de séparer les choses, avoir un environnement de dev et un autre de test par exemple.

Note

💡Avez-vous remarqué qu'on ne précise pas dans buildInputs qu'on souhaite pkgs.python3 ? Ce n'est pas nécessaire quand on utilise pkgs.python3.withPackages.

Détails du flake

Regardons d'un peu plus près notre flake. Nous avons vu qu'un flake peut proposer plusieurs sorties ou types de sorties. Comment savoir ce qu'un flake propose ?

Demandons à la commande nix flake show :

nix flake show
path:/home/laborant/dev-python?lastModified=1757923848&narHash=sha256-IXwtaHxYMEwrZyTsRdcIpSddGuZsHp6HBud9fUFE/fM%3D
└───devShells
    ├───aarch64-darwin
    │   ├───default omitted (use '--all-systems' to show)
    │   └───jupyter omitted (use '--all-systems' to show)
    ├───aarch64-linux
    │   ├───default omitted (use '--all-systems' to show)
    │   └───jupyter omitted (use '--all-systems' to show)
    ├───x86_64-darwin
    │   ├───default omitted (use '--all-systems' to show)
    │   └───jupyter omitted (use '--all-systems' to show)
    └───x86_64-linux
        ├───default: development environment 'nix-shell'
        └───jupyter: development environment 'nix-shell'

Nous voyons que notre flake propose des devShells, default et jupyter (et pour plusieurs architectures).

Comme on l'a rapidement évoqué il peut y avoir d'autres sorties, nous allons voir ça dans la prochaine partie.

Un environnement de développement Go

Dans cette partie nous continuons l'exploration des flake en créant un environnment pour le développement d'une application en Go.

Notre application écoute sur un port HTTP et affiche "Bonjour le monde !".

Le fichier main.go :

package main

import (
        "fmt"
        "net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "<h1>Bonjour le monde !</h1>")
}

func check(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "<h1>Tout va bien</h1>")
}

func main() {
        http.HandleFunc("/", index)
        http.HandleFunc("/health", check)
        fmt.Println("Démarrage du serveur.")
        http.ListenAndServe(":2000", nil)
}

Et le go.mod correspondant :

module montfort.fr/monapp

go 1.24.0

Les outils Go

Aucun outil pour développer en Go n'est installé sur la machine. La première chose à faire est de créer un flake avec un devShell :

{
  description = "mon app";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    let
      systems = [ "x86_64-linux" ];
    in
    flake-utils.lib.eachSystem systems (
      system:
      let
        pkgs = import nixpkgs { inherit system; };

        nativeBuildInputs = with pkgs; [
          go_1_24
          gopls
        ];
        buildInputs = with pkgs; [];
      in {
        devShells.default = pkgs.mkShellNoCC { inherit nativeBuildInputs buildInputs; };
      }
    );
}

On retrouve beaucoup de choses déjà vues dans le flake pour l'environnement python.

Dans un souci de clarté la liste des systèmes est limitée à l'architecture de notre machine (x86_64).

Deux nouvelles variables apparaissent nativeBuildInputs et buildInputs. La première contient les outils nécessairent à la construction de notre application, alors que la seconde contient les programmes ou bibliothèques nécessaires à l'exécution de notre application.

Nous utilisons ces variables pour la création d'un shell (mkShellNoCC). Ce shell ne contiendra pas d'outils de compilation en C (le NoCC) car nous allons utiliser Go.

Comme vu précédemment on utilise ce shell avec la commande nix develop.

Après avoir placé les deux fichiers main.go et go.mod dans le répertoire ~/dev-go on peut lancer l'application avec la commande :

go run main.go

Elle affiche un message de démarrage et attend d'être arrêtée par un Ctrl+C par exemple. Elle écoute sur le port 2000, chose qu'on peut vérifier avec la commande curl :

curl localhost:2000
curl localhost:2000/health

Packaging de notre application

Notre application est tellement bien que nous allons en faire un paquet pour faciliter son usage.

{
  description = "mon app";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
  let
    systems = [ "x86_64-linux" ];
  in
  flake-utils.lib.eachSystem systems (
    system:
    let
      pkgs = import nixpkgs { inherit system; };

      nativeBuildInputs = with pkgs; [
        go_1_24
        gopls
      ];
      buildInputs = with pkgs; [];
    in {
      devShells.default = pkgs.mkShellNoCC { inherit nativeBuildInputs buildInputs; };

      packages.default = pkgs.buildGoModule {
        name = "monapp";
        src = ./.;

        inherit buildInputs;

        vendorHash = null;
      };
    })
}

Ce code définit un paquet, ce qui pour notre application correspondra à un binaire. Il sera placé dans le répertoire result et dans l'arborescence convenue, bin/monapp.

Pour compiler notre application on utilisera la commande

nix build

Et le binaire produit pourra être lancé avec la commande :

./result/bin/monapp

Lancement de notre application

On peut déjà lancer notre application avec la commande :

nix run

La cible packages par defaut étant définie et en l'absence de cible apps, elle est utilisée.

Mais nous allons rendre les choses plus claires en ajoutant une cible apps par défaut. Et on va aussi ajouter une cible hello pour afficher "hello world", bien sûr, mais surtout pour montrer qu'on peut avoir plusieurs cibles dans le même flake.

        apps.default = {
          type = "app";
          program = "${self.packages.${system}.monapp}/bin/monapp";
        };
        apps.hello = {
          type = "app";
          program = "${pkgs.hello}/bin/hello";
        };
Le flake.nix complet est disponible en cliquant ici.
{
  description = "mon app";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
  let
    systems = [ "x86_64-linux" ];
  in
  flake-utils.lib.eachSystem systems (system:
      let
        pkgs = import nixpkgs { inherit system; };

        nativeBuildInputs = with pkgs; [
          go_1_24
          gopls
          jq
          dive
        ];
        buildInputs = with pkgs; [];
      in rec {
        devShells.monapp = pkgs.mkShellNoCC {inherit nativeBuildInputs buildInputs;};
        devShells.default = devShells.monapp;

        packages.default = pkgs.buildGoModule {
          name = "monapp";
          src = ./.;

          inherit buildInputs;

          vendorHash = null;
        };

        apps.default = {
          type = "app";
          program = "${self.packages.${system}.monapp}/bin/monapp";
        };
        apps.hello = {
          type = "app";
          program = "${pkgs.hello}/bin/hello";
        };
      }
    );
}

Notre cible apps par défaut va lancer la commande donnée dans l'option program. Analysons cette option :

  • self fait référence au flake en cours.
  • packages.${system}
    • packages est l'ensemble des paquets fournis par ce flake.
    • ${system} est la variable définie qui représente la plateforme actuelle. Le tout sélectionne l'ensemble des paquets compilés pour l'architecture actuelle.
  • monapp permet d'accèder au paquet spécifique monapp (qu'on a déclaré précédemment).
  • /bin/monapp est le chemin vers l'exécutable dans le paquet nix.

La ligne complète pourrait ressembler à quelque chose comme ça : /nix/store/<hash>-monapp/bin/monapp

Pour l'application hello l'option programme est plus simple : pkgs est la liste des paquest nix pour notre architecture et on choisit le paquet hello.

On peut lancer les applications avec les commandes :

nix run
nix run .#default
nix run .#hello

Image docker

Il est aussi possible dans un flake de définir une sortie pour construire une image docker. Pour cela on va déclarer un nouveau package avec le nom docker qui sera le résultat de la fonction pkgs.dockerTools.buildLayeredImage.

        packages.docker = pkgs.dockerTools.buildLayeredImage {
          name = "monapp";
          tag = "latest";
          contents = [ pkgs.hello self.packages.${system}.monapp ];
          config = {
            Cmd = [ "/bin/monapp" ];
            ExposedPorts = {"2000/tcp" = {};};
          };
        };

Le contenu de notre image sera le résultat de notre paquet monapp. On précise la commande à lancer au démarrage ainsi que le port à exposer.

On construit l'image avec la commande

nix build .#docker

L'image n'est pas automatiquement chargée dans le registre locale. Le fichier result pointe dans l /nix/store vers l'archive tar.gz représentant l'image. Il faut la charger dans le registre avec la commande :

docker load < result
Note

💡On peut construire l'image et la charger en une seule commande avec docker load -i $(nix build .#docker --print-out-paths).

Pour tester notre image il faut la lancer avec

docker run -p 2000:2000 monapp

et dans un autre terminal on peut faire

curl localhost:2000

Si on veut tester notre application nix nous propose une fonctionnalité puissante pour tester une application et une configuration. Et toujours de manière reproductible et facilement distribuable

Tests

Nixpkgs fournit un environnement de test pour automatiser les tests d'intégration. Il permet de définir des tests basés sur un ensemble de configurations déclaratives de machines NixOS et d'utiliser un shell Python pour interagir avec via QEMU en tant que backend. Ces tests sont largement utilisés pour s'assurer que NixOS fonctionne comme prévu, et sont donc généralement appelés tests NixOS. Ils peuvent être écrits et lancés en dehors de NixOS, sur n'importe quelle machine Linux. Les tests d'intégration sont reproductibles grâce aux propriétés de conception de Nix, ce qui est intérsessant pour un pipeline d'intégration continue (CI).

Important

⚠️ Le lab ne fournit pas le support de QEMU, il faudra tester sur votre machine. Mais je suis sûr que vous êtes déjà conquis par nix et en train de l'installer partout !

        nixosModules.monapp =  {
          systemd.services.monapp = {
            after = [ "network.target" ];
            wantedBy = [ "multi-user.target" ];

            serviceConfig = {
              DynamicUser = true;
              ExecStart = "${packages.monapp}/bin/monapp";
            };
          };
        };

Pour commencer nous allons définir un module NixOS.

Un module NixOS est un composant de base du système de configuration déclaratif de NixOS Il permet de structurer et d'organiser la définition des services, des paquets ou des options système. Une configuration de machines utilise plusieurs modules qui sont évalués par Nix. Cela permet d'écrire des configurations puissantes et reproductibles.

Nous allons commencer par écrire un module très simple qui définit un service systemd qui lance notre application.

        nixosModules.monapp =  {
          systemd.services.monapp = {
            after = [ "network.target" ];
            wantedBy = [ "multi-user.target" ];

            serviceConfig = {
              DynamicUser = true;
              ExecStart = "${packages.monapp}/bin/monapp";
            };
          };
        };

Ensuite nous allons écrire notre test. Il est constitué d'un nom, d'une définition d'une machine (ou plusieurs) et d'un script de test.

     checks.monapp = pkgs.nixosTest {
       name = "monapp";
       nodes.machine = { config, pkgs, ... }: {
         imports = [
           nixosModules.monapp
         ];

         environment.systemPackages = [ pkgs.curl ];

         system.stateVersion = "25.05";
       };

       testScript = ''
         start_all()
         machine.wait_for_unit("monapp.service")
         machine.wait_for_open_port(2000)
         output = machine.succeed("curl localhost:2000")
         assert "<h1>Bonjour le monde !</h1>" in output, f"La sortie '{output}' ne correspond pas."
       '';
    };

Le premier nœud machine est simple. Il utilise NixOS version 25.05, notre module nixos monapp et dispose de la commande curl. Enfin le test démarre les machines, attend que le service soit disponible et le port accessible et vérifie la sortie de la commande curl.

Pour lancer notre test la commande est :

nix flake check
nix flake check -L # Pour avoir les logs sur la sortie

Si tous les test se déroulent sans erreur il n'y a pas de message.

Si on besoin de débugger ou de lancer des commandes à la main on peut lancer le test en interactif.

nix run '.#checks.x86_64-linux.monapp.driverInteractive'

On se retrouve alors positionné dans le shell Python. Il faudra lancer les commandes à la main.

Commençons par

machine.start()

La machine est démarrée et une fenêtre QEMU s'ouvre. On peut se connecter en root (sans mot de passe) sur la machine.

On peut continuer à dérouler les commandes de notre script de test dans la console Python. Mais on aussi a aussi accès a l'entièreté du système au travers de la console QEMU.

Voilà pour cette approche des tests NixOS.

Conclusion

J'espère que ce tutoriel vous a intéressé et convaincu d'essayer nix et ensuite NixOS.

Le flake.nix final est disponible en cliquant ici.
{
  description = "mon app";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
  let
    systems = [ "x86_64-linux" ];
  in
  flake-utils.lib.eachSystem systems (system:
      let
        pkgs = import nixpkgs { inherit system; };

        nativeBuildInputs = with pkgs; [
          go_1_24
          gopls
          jq
          dive
        ];
        buildInputs = with pkgs; [];
      in rec {
        devShells.monapp = pkgs.mkShellNoCC {inherit nativeBuildInputs buildInputs;};
        devShells.default = devShells.monapp;

        packages.monapp = pkgs.buildGoModule {
          name = "monapp";
          src = ./.;

          inherit buildInputs;

          vendorHash = null;
        };
        packages.default = packages.monapp;
        packages.docker = pkgs.dockerTools.buildLayeredImage {
          name = "monapp";
          tag = "latest";
          contents = [ pkgs.hello self.packages.${system}.monapp ];
          config = {
            Cmd = [ "/bin/monapp" ];
            ExposedPorts = {"2000/tcp" = {};};
          };
        };

        apps.default = {
          type = "app";
          program = "${self.packages.${system}.monapp}/bin/monapp";
          meta = {
           description = "Une courte description";
           maintainers = [ "nom" ];
           license = "MIT";
          };
        };
        apps.hello = {
          type = "app";
          program = "${pkgs.hello}/bin/hello";
          meta = {
           description = "Une courte description";
           maintainers = [ "nom" ];
           license = "MIT";
          };
        };

        nixosModules.monapp =  {
          systemd.services.monapp = {
            after = [ "network.target" ];
            wantedBy = [ "multi-user.target" ];

            serviceConfig = {
              DynamicUser = true;
              ExecStart = "${packages.monapp}/bin/monapp";
            };
          };
        };

        checks.monapp = pkgs.nixosTest {
        name = "monapp";
        nodes.machine = { config, pkgs, ... }: {
            imports = [
                nixosModules.monapp
            ];

            environment.systemPackages = [ pkgs.curl ];

            system.stateVersion = "25.05";
        };

        testScript = ''
            start_all()
            machine.wait_for_unit("monapp.service")
            machine.wait_for_open_port(2000)
            output = machine.succeed("curl localhost:2000")
            assert "<h1>Bonjour le monde !</h1>" in output, f"La sortie '{output}' ne correspond pas."
            '';
        };

      }
    );
}

About the Author

Thomas Montfort

Thomas Montfort

Find this author online

More tutorials you might like

Learn by doing, not just by reading or watching

Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.

Sign up for free