Aller au contenu
Administration Linux medium

Langage Nix par la pratique

30 min de lecture

Dans ce guide, j’adopte une approche différente : plutôt que de lister la syntaxe de façon abstraite, nous allons construire ensemble un projet concret du début à la fin. À chaque étape, j’introduis un nouveau concept du langage Nix que nous appliquons immédiatement.

Nous allons créer un environnement de développement Python complet avec :

  • Python 3.12 avec des bibliothèques spécifiques
  • Des outils de développement (git, vim, ripgrep)
  • Des variables d’environnement personnalisées
  • Une configuration modulaire et réutilisable

À la fin de ce guide, vous aurez un shell.nix fonctionnel et vous comprendrez chaque ligne.

Un fichier shell.nix est une recette déclarative qui décrit un environnement de développement. Quand vous lancez nix-shell dans un dossier contenant ce fichier, Nix :

  1. Lit le fichier shell.nix
  2. Télécharge les paquets nécessaires (s’ils ne sont pas en cache)
  3. Crée un shell isolé avec exactement ces outils disponibles
  4. Vous place dans cet environnement temporaire
Problème classiqueSolution avec shell.nix
”Ça marche sur ma machine”Environnement identique pour tous
Conflits de versions entre projetsChaque projet a son propre environnement
Installation manuelle d’outilsUn seul nix-shell suffit
Documentation des dépendancesLe fichier shell.nix est la documentation

Différence avec les environnements virtuels Python

Section intitulée « Différence avec les environnements virtuels Python »

Contrairement à venv qui ne gère que Python, shell.nix peut inclure n’importe quel outil : compilateurs, bases de données, outils CLI, etc. Et contrairement à Docker, il n’y a pas de conteneur : les outils sont directement accessibles dans votre terminal.

Créons notre premier fichier dans un nouveau dossier :

Fenêtre de terminal
mkdir mon-projet-python && cd mon-projet-python

Créez un fichier shell.nix avec ce contenu :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = [ pkgs.python312 ];
}

Testons immédiatement :

Fenêtre de terminal
nix-shell
python3 --version # Python 3.12.x
exit # Sortir du shell

Ça fonctionne ! Mais qu’avons-nous écrit exactement ? Décortiquons chaque élément dans les sections suivantes.

Regardons à nouveau notre shell.nix :

{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = [ pkgs.python312 ];
}

La première ligne { pkgs ? import <nixpkgs> {} }: est mystérieuse. C’est en fait une fonction. Pourquoi ? Parce que Nix a besoin de savoir où trouver les paquets comme python312. On lui dit : “donne-moi pkgs et je te construis un environnement”.

En Nix, une fonction s’écrit avec : qui sépare l’argument du corps :

argument: ce_que_la_fonction_retourne

Exemple concret - une fonction qui double un nombre :

x: x * 2
  • x est l’argument (le paramètre d’entrée)
  • x * 2 est le corps (ce qui est calculé et retourné)

Pour appeler cette fonction, on lui passe une valeur :

Fenêtre de terminal
nix repl
nix-repl> double = x: x * 2
nix-repl> double 5
10
nix-repl> double 100
200

Nix ne supporte qu’un argument par fonction, mais on peut les chaîner :

a: b: a + b

Cela signifie : “prends a, puis retourne une fonction qui prend b et retourne a + b”.

Fenêtre de terminal
nix-repl> add = a: b: a + b
nix-repl> add 3 4
7

Notre shell.nix utilise une syntaxe avec accolades { } :

{ pkgs }:

Cela signifie : “j’attends un dictionnaire (attrset) qui contient une clé pkgs”. C’est plus lisible quand il y a plusieurs arguments :

{ nom, age, ville }:
"Je m'appelle ${nom}, j'ai ${toString age} ans, j'habite à ${ville}"

Le ? définit une valeur utilisée si l’argument n’est pas fourni :

{ pkgs ? import <nixpkgs> {} }:

Traduction : “Si on me donne pkgs, je l’utilise. Sinon, je charge moi-même nixpkgs avec import <nixpkgs> {}.”

Quand vous tapez nix-shell, voici ce qui se passe :

  1. Nix lit le fichier shell.nix
  2. Il détecte que c’est une fonction (à cause du :)
  3. Il l’appelle avec {} (un dictionnaire vide)
  4. Comme pkgs n’est pas fourni, la valeur par défaut import <nixpkgs> {} est utilisée
  5. La fonction retourne le résultat de pkgs.mkShell { ... }

C’est pourquoi cette première ligne est standard dans presque tous les fichiers Nix. Elle dit simplement : “charge les paquets disponibles”.

import charge et évalue un fichier Nix. C’est comme un require ou include dans d’autres langages :

# Si config.nix contient { port = 8080; }
let
config = import ./config.nix;
in
config.port # 8080

<nixpkgs> est un chemin spécial qui pointe vers la collection de paquets Nix installée sur votre système (via les channels). C’est un raccourci pour accéder aux ~80 000 paquets disponibles.

import <nixpkgs> {}

Cette expression :

  1. Charge le fichier default.nix de nixpkgs
  2. L’appelle avec {} (options vides)
  3. Retourne un attrset gigantesque contenant tous les paquets

C’est pourquoi ensuite, on peut écrire pkgs.python312, pkgs.git, etc.

Le REPL (Read-Eval-Print Loop) est un terminal interactif qui permet de tester des expressions Nix à la volée. Vous tapez une expression, Nix l’évalue et affiche le résultat. C’est idéal pour expérimenter :

Fenêtre de terminal
nix repl
nix-repl> pkgs = import <nixpkgs> {}
nix-repl> pkgs.python312
«derivation /nix/store/...-python3-3.12...»
nix-repl> pkgs.git
«derivation /nix/store/...-git-...»

Le texte «derivation ...» signifie que c’est un paquet Nix prêt à être construit ou utilisé. Tapez :q pour quitter le REPL.

Un attrset (attribute set) est un dictionnaire clé-valeur. C’est la structure de données la plus importante en Nix :

{
name = "Alice";
age = 30;
active = true;
}

On accède aux valeurs avec un point :

nix repl
nix-repl> personne = { name = "Alice"; age = 30; active = true; }
nix-repl> personne.name
"Alice"
nix-repl> personne.age
30
nix-repl> personne.active
true
:q

Regardons à nouveau notre code :

pkgs.mkShell {
packages = [ pkgs.python312 ];
}

pkgs.mkShell est une fonction qui attend un attrset en argument. Nous lui passons { packages = [ pkgs.python312 ]; }.

Enrichissons notre environnement avec git, vim et ripgrep :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = [
pkgs.python312
pkgs.git
pkgs.vim
pkgs.ripgrep
];
}

Rechargez l’environnement :

Fenêtre de terminal
nix-shell
git --version # Disponible !
vim --version # Disponible !
rg --version # Disponible !

Répéter pkgs. devient fastidieux. Le mot-clé with importe tous les attributs d’un attrset dans le scope :

shell.nix (amélioré)
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
python312
git
vim
ripgrep
];
}

C’est équivalent, mais plus lisible. with pkgs; signifie “dans la suite, cherche d’abord dans pkgs”.

Imaginons que nous voulions définir le nom du projet et sa version à un seul endroit. Le bloc let ... in crée des variables locales :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
projectName = "mon-api";
pythonVersion = pkgs.python312;
in
pkgs.mkShell {
name = projectName;
packages = with pkgs; [
pythonVersion
git
vim
];
}
let
variable1 = valeur1;
variable2 = valeur2;
in
expression_qui_utilise_les_variables

Les variables peuvent référencer celles définies avant :

let
a = 1;
b = 2;
sum = a + b; # 3
in
sum * 2 # 6

Nous voulons Python avec des bibliothèques comme requests, flask ou pytest. Mais pkgs.python312 est Python “nu” sans bibliothèques.

nixpkgs fournit une fonction python312.withPackages qui crée un Python avec les bibliothèques demandées :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
# Python avec bibliothèques
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
]);
in
pkgs.mkShell {
packages = with pkgs; [
pythonEnv
git
vim
ripgrep
];
}

Testons :

Fenêtre de terminal
nix-shell
python3 -c "import requests; print(requests.__version__)"
# 2.31.0 (ou version similaire)

ps: [ ps.requests ps.flask ] est une fonction anonyme (lambda) :

  • ps est l’argument (les paquets Python disponibles)
  • [ ps.requests ps.flask ] est le corps (la liste retournée)

C’est équivalent à écrire :

(ps: [ ps.requests ps.flask ps.pytest ])

Cette fonction est appelée par withPackages qui lui passe tous les paquets Python disponibles.

shellHook est un script shell exécuté à l’entrée dans nix-shell :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
]);
in
pkgs.mkShell {
packages = with pkgs; [
pythonEnv
git
vim
ripgrep
];
shellHook = "echo '🐍 Environnement Python activé !' && echo \"Python: $(python3 --version)\"";
}

En Nix, les chaînes multilignes utilisent deux apostrophes simples consécutives. Voici l’équivalent avec une string standard :

# String simple avec \n
"Première ligne\nDeuxième ligne"
# En shellHook, combinez plusieurs echo :
shellHook = "echo 'Ligne 1' && echo 'Ligne 2'";

L’indentation minimale commune est automatiquement supprimée dans les chaînes multilignes Nix.

Ajoutez des variables avec les attributs de l’attrset :

shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
]);
in
pkgs.mkShell {
packages = with pkgs; [
pythonEnv
git
vim
ripgrep
];
# Variables d'environnement
PROJECT_NAME = "mon-api";
FLASK_ENV = "development";
FLASK_DEBUG = "1";
shellHook = "echo '🐍 Projet:' $PROJECT_NAME && echo 'Flask: mode' $FLASK_ENV";
}

Testons :

Fenêtre de terminal
nix-shell
echo $PROJECT_NAME # mon-api
echo $FLASK_ENV # development

L’interpolation permet d’insérer des expressions dans des chaînes :

let
name = "Alice";
in
"Bonjour, ${name} !"
# "Bonjour, Alice !"

Seules les chaînes peuvent être interpolées. Pour les nombres, utilisez toString :

let
port = 8080;
in
"Serveur sur le port ${toString port}"
# "Serveur sur le port 8080"
shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
projectName = "mon-api";
projectVersion = "1.0.0";
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
]);
in
pkgs.mkShell {
name = "${projectName}-dev";
packages = with pkgs; [
pythonEnv
git
];
PROJECT_NAME = projectName;
PROJECT_VERSION = projectVersion;
shellHook = "echo '📦' ${projectName} v${projectVersion} && echo \"Python: $(python3 --version)\"";
}

Étape 10 : Figer les versions (reproductibilité)

Section intitulée « Étape 10 : Figer les versions (reproductibilité) »

Jusqu’ici, nous utilisons import <nixpkgs> {}. Le problème ? <nixpkgs> pointe vers le channel système qui change à chaque mise à jour. Aujourd’hui vous avez Python 3.12.3, demain peut-être 3.12.4.

Pour un environnement vraiment reproductible, il faut figer la version de nixpkgs.

shell.nix
{ pkgs ? import (fetchTarball {
# nixpkgs 24.05 - figé à ce commit
url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz";
sha256 = "sha256:1lr1h35prqkd1mkmzriwlpvxcb34kmhc9dnr48gkm8hh089hifmx";
}) {}
}:
pkgs.mkShell {
packages = [ pkgs.python312 ];
}

Maintenant, peu importe quand ou où vous lancez nix-shell, vous aurez exactement la même version de Python.

Deux méthodes :

Méthode 1 : Laissez Nix le calculer (mettez une valeur bidon) :

sha256 = ""; # Nix affichera le bon hash dans l'erreur

Méthode 2 : Utilisez nix-prefetch-url :

Fenêtre de terminal
nix-prefetch-url --unpack https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz

Pour encore plus de précision, utilisez un commit spécifique :

shell.nix
{ pkgs ? import (fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/5ef6c425980847c78a80d759abc476e941a9bf42.tar.gz";
sha256 = "sha256:0123456789abcdef...";
}) {}
}:
pkgs.mkShell {
packages = [ pkgs.python312 ];
}

Pour voir quelle version d’un paquet est disponible :

Fenêtre de terminal
# Dans le REPL
nix repl
nix-repl> pkgs = import <nixpkgs> {}
nix-repl> pkgs.python312.version
"3.12.12"
nix-repl> pkgs.nodejs.version
"22.20.0"

Le site Nixhub.io permet de rechercher quelle version de nixpkgs contient une version précise d’un paquet.

Par exemple, si vous avez besoin de Python 3.11.4 exactement, Nixhub vous indiquera quel commit de nixpkgs utiliser.

Cette méthode fonctionne, mais elle a des inconvénients :

  • Pas de fichier de verrouillage partageable facilement
  • Mise à jour manuelle des hashes
  • Pas de gestion des dépendances entre projets

C’est pourquoi les flakes ont été créés : ils résolvent ces problèmes avec un fichier flake.lock automatique. Un guide dédié aux flakes sera bientôt disponible.

En Nix, if est une expression qui retourne une valeur :

if condition then valeur1 else valeur2

Ajoutons des outils de debug uniquement en mode développement. Désormais, nous utilisons une version figée de nixpkgs :

shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let
debugMode = true; # Changez à false pour production
pythonEnv = pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
]);
# Outils de debug conditionnels
debugTools = if debugMode then [
pkgs.htop
pkgs.ncdu
] else [];
in
pkgs.mkShell {
packages = with pkgs; [
pythonEnv
git
] ++ debugTools; # ++ concatène les listes
shellHook =
(if debugMode then "echo '🔧 Mode DEBUG activé'\n" else "");
}

++ concatène deux listes :

[ 1 2 ] ++ [ 3 4 ]
# [ 1 2 3 4 ]

Notre shell.nix grandit. Séparons la configuration dans des fichiers distincts.

mon-projet-python/
├── shell.nix # Point d'entrée
├── python-env.nix # Configuration Python
└── config.nix # Variables de configuration
config.nix
{
projectName = "mon-api";
projectVersion = "1.0.0";
debugMode = true;
flaskPort = 5000;
}
python-env.nix
{ pkgs }:
pkgs.python312.withPackages (ps: [
ps.requests
ps.flask
ps.pytest
ps.black
ps.mypy
])

Ce fichier est une fonction qui attend pkgs en argument.

shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let
# Import des modules
config = import ./config.nix;
pythonEnv = import ./python-env.nix { inherit pkgs; };
debugTools = if config.debugMode then with pkgs; [
htop
ncdu
] else [];
in
pkgs.mkShell {
name = "${config.projectName}-dev";
packages = with pkgs; [
pythonEnv
git
vim
ripgrep
] ++ debugTools;
PROJECT_NAME = config.projectName;
PROJECT_VERSION = config.projectVersion;
FLASK_RUN_PORT = toString config.flaskPort;
}

Vous avez remarqué qu’on demande à utiliser la version figée de nixpkgs dans shell.nix et qu’on la transmet à python-env.nix via import ./python-env.nix \{ inherit pkgs; \};.

inherit pkgs est un raccourci pour pkgs = pkgs :

# Ces deux lignes sont équivalentes :
import ./python-env.nix { pkgs = pkgs; }
import ./python-env.nix { inherit pkgs; }

C’est très courant pour passer des variables qui portent le même nom.

L’opérateur // fusionne deux attrsets. Le côté droit écrase le gauche :

{ a = 1; b = 2; } // { b = 3; c = 4; }
# { a = 1; b = 3; c = 4; }

Créons un système de configuration avec des valeurs par défaut :

config.nix
let
defaults = {
projectName = "mon-projet";
debugMode = false;
flaskPort = 5000;
flaskHost = "127.0.0.1";
};
overrides = {
debugMode = true;
flaskPort = 8080;
};
in
defaults // overrides
# Résultat : debugMode=true, flaskPort=8080, le reste inchangé

Créons une fonction utilitaire pour formater les messages :

shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let
config = import ./config.nix;
pythonEnv = import ./python-env.nix { inherit pkgs; };
# Fonction utilitaire - retourne une chaîne pour shellHook
formatBanner = title: version:
''
echo "╔════════════════════════════════╗"
echo "║ ${title} v${version}"
echo "╚════════════════════════════════╝"
'';
in
pkgs.mkShell {
packages = [ pythonEnv pkgs.git ];
shellHook = formatBanner config.projectName config.projectVersion;
}

Pour plus de clarté, utilisez des arguments nommés :

let
formatBanner = { title, version, debug ? false }:
''
Project: ${title} v${version}
'' + (if debug then "⚠️ DEBUG MODE\n" else "");
in
formatBanner {
title = "mon-api";
version = "1.0.0";
debug = true;
}

Nix fournit des fonctions intégrées (builtins). Voici les plus utiles :

map (x: x * 2) [ 1 2 3 ]
# [ 2 4 6 ]

Cas pratique - ajouter un préfixe à chaque élément :

let
libs = [ "requests" "flask" "pytest" ];
in
map (lib: "python-${lib}") libs
# [ "python-requests" "python-flask" "python-pytest" ]
builtins.filter (x: x > 2) [ 1 2 3 4 5 ]
# [ 3 4 5 ]
builtins.attrNames { a = 1; b = 2; c = 3; }
# [ "a" "b" "c" ]
builtins.attrValues { a = 1; b = 2; c = 3; }
# [ 1 2 3 ]

L’opérateur ? teste si un attribut existe :

let config = { port = 8080; };
in
config ? port # true
config ? host # false

Combiné avec or pour des valeurs par défaut :

let config = { port = 8080; };
in
config.host or "localhost"
# "localhost"

Voici notre projet final avec tous les concepts :

mon-projet-python/
├── shell.nix
├── config.nix
├── python-env.nix
└── lib/
└── utils.nix
lib/utils.nix
{
# Retourne des commandes shell pour afficher une bannière
formatBanner = { title, version, debug ? false }:
''
echo "┌────────────────────────────────┐"
echo "│ 🐍 ${title} v${version}"
'' + (if debug then ''echo "│ ⚠️ Debug mode enabled"\n'' else "") + ''
echo "└────────────────────────────────┘"
'';
mkEnvVars = config: {
PROJECT_NAME = config.projectName;
PROJECT_VERSION = config.projectVersion;
FLASK_RUN_PORT = toString config.flaskPort;
FLASK_RUN_HOST = config.flaskHost;
FLASK_DEBUG = if config.debugMode then "1" else "0";
};
}
config.nix
let
defaults = {
projectName = "mon-api";
projectVersion = "1.0.0";
debugMode = false;
flaskPort = 5000;
flaskHost = "127.0.0.1";
};
# Surcharges pour le développement local
localOverrides = {
debugMode = true;
flaskPort = 8080;
};
in
defaults // localOverrides
python-env.nix
{ pkgs, config }:
let
basePackages = ps: [
ps.requests
ps.flask
ps.python-dotenv
];
devPackages = ps: [
ps.pytest
ps.black
ps.mypy
ps.ipython
];
allPackages = ps:
basePackages ps ++
(if config.debugMode then devPackages ps else []);
in
pkgs.python312.withPackages allPackages
shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let
# Imports
config = import ./config.nix;
utils = import ./lib/utils.nix;
pythonEnv = import ./python-env.nix { inherit pkgs config; };
# Outils de base
baseTools = with pkgs; [
git
vim
ripgrep
jq
];
# Outils de debug
debugTools = with pkgs; [
htop
ncdu
curl
];
# Tous les paquets
allPackages = [ pythonEnv ] ++
baseTools ++
(if config.debugMode then debugTools else []);
in
pkgs.mkShell ({
name = "${config.projectName}-dev";
packages = allPackages;
shellHook = (utils.formatBanner {
title = config.projectName;
version = config.projectVersion;
debug = config.debugMode;
}) + "\necho \"Run 'flask run' to start the development server\"";
} // utils.mkEnvVars config)

Testez le résultat :

Fenêtre de terminal
nix-shell
# Affiche la bannière et les infos
echo $FLASK_RUN_PORT # 8080
echo $FLASK_DEBUG # 1
python3 -c "import flask; print(flask.__version__)"
ConceptSyntaxeDescription
Fonctionsarg: corpsTout est fonction en Nix. Les fichiers .nix sont souvent des fonctions qui attendent pkgs ou d’autres arguments.
Attrsets{ clé = valeur; }Dictionnaires clé-valeur, structure fondamentale. On y accède avec . (ex: pkgs.git).
let … inlet x = 1; in xVariables locales immuables. Permettent de structurer et réutiliser des valeurs.
withwith pkgs;Importe les attributs d’un attrset dans le scope pour éviter les répétitions.
importimport ./file.nixCharge et évalue un fichier Nix. Permet la modularisation en plusieurs fichiers.
inheritinherit xRaccourci pour x = x. Permet de passer des variables du même nom.
Opérateur //a // bFusionne deux attrsets. Le droit écrase le gauche. Idéal pour les surcharges.
if-then-elseif c then a else bExpression conditionnelle. Le else est obligatoire car tout doit retourner une valeur.

Vous maîtrisez maintenant les bases du langage Nix en pratique. Voici les prochaines étapes pour approfondir :

  • Flakes Nix (guide à venir) : workflow moderne avec verrouillage des dépendances et reproductibilité garantie
  • NixOS : configurer un système d’exploitation entier de manière déclarative
RessourceDescription
Nix LanguageDocumentation officielle du langage
Nix PillsTutoriel approfondi pas à pas
nix.devGuides pratiques et bonnes pratiques

Le langage Nix peut sembler déroutant au premier abord, mais comme vous l’avez vu dans ce guide, ses concepts sont finalement simples : tout est expression, les attrsets structurent les données, les fonctions paramétrent les configurations, et l’immutabilité garantit la reproductibilité.

En construisant progressivement notre environnement Python, vous avez appris à lire et écrire du Nix de façon pratique. La prochaine étape naturelle sera d’explorer les flakes (guide à venir) pour bénéficier du verrouillage des dépendances et d’une structure de projet standardisée.