Aller au contenu
Cloud medium

Images NixOS pour Outscale

24 min de lecture

logo 3ds outscale

Dans ce guide, je vous explique comment produire des Outscale Machine Images (OMI) pour NixOS. Je génère une image QCOW2 sécurisée, prête à être déployée sur le cloud Outscale, en réutilisant ma configuration NixOS-WSL comme base de travail.

Aucune connaissance préalable d’Outscale n’est requise. Si vous n’avez pas encore d’environnement NixOS-WSL fonctionnel, je vous recommande de suivre d’abord mon guide d’installation NixOS dans WSL.

Les images cloud traditionnelles posent plusieurs problèmes :

  • Dérive de configuration : les instances s’éloignent de leur état initial au fil du temps
  • Difficultés de mise à jour : patcher une AMI/OMI existante est fastidieux
  • Manque de reproductibilité : recréer exactement la même image des mois plus tard est incertain
  • Surface d’attaque accrue : les images génériques contiennent souvent des services inutiles

NixOS résout ces problèmes :

  • Déclaratif : l’image entière se définit dans des fichiers de configuration versionnés
  • Reproductible : même configuration = même image, bit pour bit
  • Atomique : les mises à jour sont tout-ou-rien, pas de système dans un état intermédiaire
  • Auditabilité : chaque changement est tracé dans Git
  • Un environnement NixOS-WSL fonctionnel (voir le guide précédent)
  • Un compte Outscale avec accès à l’API
  • Les outils osc-cli ou l’accès à la console Cockpit
  • Une clé age pour le chiffrement des secrets (je vais la créer)

Le projet s’organise ainsi :

nixos-config/
├── flake.nix # Point d'entrée avec génération QCOW2
├── hosts/
│ ├── wsl.nix # Configuration WSL (existante)
│ └── outscale.nix # Configuration pour l'image cloud
├── modules/
│ ├── docker.nix # Module Docker
│ ├── tools.nix # Outils CLI
│ ├── zsh.nix # Configuration Zsh
│ └── vscode.nix # Compatibilité VS Code
├── secrets/
│ └── password-hash.txt # Hash du mot de passe (chiffré dans Git)
├── git-agecrypt.toml # Configuration git-agecrypt
└── .gitattributes # Filtres Git pour le chiffrement

nixos-generators est l’outil qui permet de générer des images dans différents formats (QCOW2, ISO, AWS AMI, etc.) à partir d’une configuration NixOS.

Je modifie mon flake.nix pour ajouter cette dépendance :

{
description = "Ma configuration NixOS-WSL";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixos-wsl.url = "github:nix-community/NixOS-WSL";
nixos-wsl.inputs.nixpkgs.follows = "nixpkgs";
sops-nix.url = "github:Mic92/sops-nix";
sops-nix.inputs.nixpkgs.follows = "nixpkgs";
# Ajout de nixos-generators pour la génération d'images
nixos-generators.url = "github:nix-community/nixos-generators";
nixos-generators.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, nixos-wsl, sops-nix, nixos-generators, ... }:
{
# Configuration WSL (existante)
nixosConfigurations.wsl = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
nixos-wsl.nixosModules.wsl
sops-nix.nixosModules.sops
./hosts/wsl.nix
];
};
# Configuration Outscale (pour tests)
nixosConfigurations.outscale = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./hosts/outscale.nix
];
};
# Image QCOW2 pour Outscale
packages.x86_64-linux.outscale-qcow = nixos-generators.nixosGenerate {
system = "x86_64-linux";
format = "qcow";
modules = [
./hosts/outscale.nix
({ config, pkgs, ... }: {
# Taille du disque QCOW2 : 10 Go
virtualisation.diskSize = 10 * 1024; # en Mo
})
];
};
};
}

Points importants :

  • nixos-generators.inputs.nixpkgs.follows assure que tout utilise la même version de nixpkgs
  • format = "qcow" génère une image QCOW2 compatible Outscale
  • virtualisation.diskSize définit la taille du disque (ici 10 Go)

Un problème se pose : comment inclure un mot de passe dans l’image sans le commiter en clair dans Git ?

J’ai d’abord essayé sops-nix, mais il déchiffre les secrets au démarrage du système, pas au build. Pour une image cloud, j’ai besoin que le hash du mot de passe soit disponible pendant la génération de l’image.

La solution : git-agecrypt. Cet outil chiffre les fichiers dans Git, mais les déchiffre automatiquement dans le répertoire de travail. Ainsi :

  • Dans Git (et sur GitHub) : le fichier est chiffré
  • Localement : le fichier est en clair, utilisable pendant le build
Fenêtre de terminal
# Installer git-agecrypt
nix profile install github:vlaci/git-agecrypt
# Générer une clé age (si vous n'en avez pas)
age-keygen -o ~/.config/age/keys.txt

La commande age-keygen affiche votre clé publique :

Public key: age1stq2rxg545r3m5stdv7ch60e3t30qepqz4h0tg4wsqh5whl4443sx582sg

Conservez précieusement le fichier ~/.config/age/keys.txt — c’est votre clé privée.

Je crée le fichier de configuration git-agecrypt.toml :

Fenêtre de terminal
cat > git-agecrypt.toml << 'EOF'
[config]
"secrets/password-hash.txt" = ["age1stq2rxg545r3m5stdv7ch60e3t30qepqz4h0tg4wsqh5whl4443sx582sg"]
EOF

Remplacez la clé publique par la vôtre.

Je crée le fichier .gitattributes pour activer le filtre :

Fenêtre de terminal
cat > .gitattributes << 'EOF'
secrets/password-hash.txt filter=git-agecrypt diff=git-agecrypt
EOF

J’initialise git-agecrypt dans le dépôt :

Fenêtre de terminal
git-agecrypt init

Je génère un hash de mot de passe et le stocke :

Fenêtre de terminal
mkdir -p secrets
# Générer un hash pour le mot de passe "changeme"
# (à remplacer par un vrai mot de passe en production !)
mkpasswd -m sha-512 "votremotdepasse" > secrets/password-hash.txt

Je vérifie que le chiffrement fonctionne :

Fenêtre de terminal
# Le fichier local est en clair
cat secrets/password-hash.txt
# Affiche : $6$uY4uFIM7VeAHL6qh$3uiwDtRY0Z...
# Mais dans Git, il sera chiffré
git add secrets/password-hash.txt
git show :secrets/password-hash.txt | head -1
# Affiche : -----BEGIN AGE ENCRYPTED FILE-----

Note importante : Toute personne qui clone le dépôt sans la clé privée age verra le fichier chiffré. Seuls ceux qui possèdent la clé peuvent le déchiffrer.

Je crée le fichier hosts/outscale.nix avec la configuration complète de l’image :

{ config, pkgs, lib, modulesPath, ... }:
let
# Hash chiffré avec git-agecrypt (déchiffré automatiquement dans le working tree)
passwordHash = lib.strings.trim (builtins.readFile ../secrets/password-hash.txt);
in
{
#############################################################################
# IMPORTS
#############################################################################
imports = [
"${modulesPath}/profiles/qemu-guest.nix"
];
#############################################################################
# SYSTEM
#############################################################################
system.stateVersion = "25.11";
#############################################################################
# BOOT
#############################################################################
boot = {
loader = {
grub.device = lib.mkDefault "/dev/vda";
timeout = lib.mkForce 0;
};
# Console série pour QEMU/cloud avec debug verbeux
kernelParams = [
"console=ttyS0,115200n8"
"console=tty0"
"systemd.show_status=true"
"loglevel=7"
];
# Afficher les logs cloud-init sur la console série
kernel.sysctl."kernel.printk" = "7 4 1 7";
};
#############################################################################
# FILESYSTEM
#############################################################################
fileSystems."/" = lib.mkDefault {
device = "/dev/vda1";
fsType = "ext4";
};
#############################################################################
# NETWORKING
#############################################################################
networking = {
hostName = "nixos-outscale";
useNetworkd = true;
firewall = {
enable = true;
allowedTCPPorts = [ 22 ];
rejectPackets = true;
logRefusedConnections = true;
};
};
#############################################################################
# USERS & GROUPS
#############################################################################
users = {
mutableUsers = false;
groups.outscale = { };
users.outscale = {
isNormalUser = true;
description = "Outscale admin user";
home = "/home/outscale";
createHome = true;
group = "outscale";
uid = 1000;
extraGroups = [ "wheel" "network" "docker" ];
shell = pkgs.zsh;
hashedPassword = passwordHash;
};
};
#############################################################################
# SECURITY
#############################################################################
security = {
sudo.wheelNeedsPassword = false; # a adapter en production
apparmor = {
enable = true;
killUnconfinedConfinables = false;
packages = [ pkgs.apparmor-profiles ];
};
};
#############################################################################
# NIX
#############################################################################
nix = {
settings.experimental-features = [ "nix-command" "flakes" ];
gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 30d";
};
};
#############################################################################
# PROGRAMS
#############################################################################
programs.zsh.enable = true;
#############################################################################
# ENVIRONMENT
#############################################################################
environment.systemPackages = with pkgs; [
# Éditeurs et outils de base
vim
git
htop
curl
wget
# Firewall
iptables
# AppArmor
apparmor-utils
apparmor-profiles
];
#############################################################################
# SERVICES
#############################################################################
services = {
# SSH
openssh = {
enable = true;
ports = [ 22 ];
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PubkeyAuthentication = true;
AllowUsers = [ "outscale" ];
ChallengeResponseAuthentication = false;
MaxAuthTries = 3;
LoginGraceTime = "20s";
};
};
# Cloud-init pour récupérer les clés SSH depuis les métadonnées Outscale
cloud-init = {
enable = true;
network.enable = true;
settings = {
chpasswd.expire = false;
users = [ "default" ];
system_info.default_user = {
name = "outscale";
lock_passwd = false;
gecos = "Outscale admin user";
groups = [ "wheel" "docker" ];
sudo = [ "ALL=(ALL) NOPASSWD:ALL" ];
shell = "/run/current-system/sw/bin/zsh";
};
ssh_pwauth = true;
disable_root = true;
preserve_hostname = true;
# Debug: logs verbeux
output.all = "| tee -a /dev/ttyS0";
};
};
};
#############################################################################
# SYSTEMD
#############################################################################
systemd.services = {
# Rediriger les logs cloud-init vers la console série
cloud-init.serviceConfig = {
StandardOutput = "journal+console";
StandardError = "journal+console";
};
cloud-init-local.serviceConfig = {
StandardOutput = "journal+console";
StandardError = "journal+console";
};
cloud-final.serviceConfig = {
StandardOutput = "journal+console";
StandardError = "journal+console";
};
};
}

Points importants de cette configuration :

  • SSH sur port 22 : port standard, cloud-init injecte les clés SSH
  • Root interdit en SSH : PermitRootLogin = "no"
  • Firewall restrictif : seul le port 22 est ouvert
  • AppArmor activé : confinement des applications
  • cloud-init configuré : récupère les clés SSH depuis les métadonnées Outscale
  • Logs debug : sortie sur console série pour faciliter le diagnostic

Je commite les changements et génère l’image :

Fenêtre de terminal
cd /mnt/c/Users/votre-user/Nixos-WSL
# Versionner les changements
git add .
git commit -m "feat: Ajout génération image Outscale"
# Générer l'image QCOW2
nix build .#outscale-qcow -o ./result-outscale

La génération prend plusieurs minutes. Le résultat est un lien symbolique vers l’image :

Fenêtre de terminal
ls -la result-outscale/
# nixos.qcow2

Avant de déployer sur Outscale, je teste l’image localement :

Fenêtre de terminal
# Copier l'image pour ne pas modifier l'original
cp result-outscale/nixos.qcow2 /tmp/nixos-test.qcow2
# Lancer QEMU
qemu-system-x86_64 \
-m 2048 \
-cpu qemu64 \
-drive file=/tmp/nixos-test.qcow2,format=qcow2,if=virtio \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-pci,netdev=net0 \
-nographic

Une fois la VM démarrée, je peux me connecter en SSH :

Fenêtre de terminal
# Depuis un autre terminal (port local 2222 redirigé vers port 22 de la VM)
ssh -p 2222 outscale@localhost

J’installe le SDK Outscale via pipx :

Fenêtre de terminal
# Installer osc-sdk
pipx install osc-sdk
# Créer le répertoire de configuration
mkdir -p ~/.osc
chmod 700 ~/.osc
# Créer le fichier de configuration (remplacez par vos credentials)
cat > ~/.osc/config.json << EOF
{
"default": {
"access_key": "$OSC_ACCESS_KEY",
"secret_key": "$OSC_SECRET_KEY",
"host": "outscale.com",
"https": true,
"method": "POST",
"region_name": "eu-west-2"
}
}
EOF
chmod 600 ~/.osc/config.json

Je configure s3cmd pour accéder à Outscale Object Storage :

Fenêtre de terminal
# Créer le fichier de configuration (remplacez par vos credentials)
cat > ~/.s3cfg << EOF
[default]
access_key = $OSC_ACCESS_KEY
secret_key = $OSC_SECRET_KEY
host_base = oos.eu-west-2.outscale.com
host_bucket = %(bucket)s.oos.eu-west-2.outscale.com
use_https = True
signature_v2 = False
EOF
chmod 600 ~/.s3cfg
Fenêtre de terminal
# Créer le bucket
s3cmd mb s3://nixos-images
# Uploader l'image QCOW2
s3cmd put ./result-outscale/nixos.qcow2 s3://nixos-images/ --progress

L’API Outscale nécessite une URL accessible pour créer le snapshot :

Fenêtre de terminal
# URL valide 1 semaine (604800 secondes)
EXPIRY=$(($(date +%s) + 604800))
PRESIGNED_URL=$(s3cmd signurl s3://nixos-images/nixos.qcow2 $EXPIRY)
echo "URL: $PRESIGNED_URL"

Lors de la création du snapshot, Outscale demande la taille virtuelle de l’image en bytes :

Fenêtre de terminal
# La taille virtuelle (pas la taille fichier) est nécessaire pour le snapshot
VIRTUAL_SIZE=$(qemu-img info --output=json ./result-outscale/nixos.qcow2 \
| jq -r '."virtual-size"')
VOLUME_SIZE_GIB=$(( (VIRTUAL_SIZE + 1073741823) / 1073741824 ))
echo "Taille virtuelle: $VIRTUAL_SIZE bytes ($VOLUME_SIZE_GIB GiB)"

Et en GiB (arrondi supérieur) pour la création de l’OMI.

Maintenant, je crée le snapshot depuis l’image uploadée :

Fenêtre de terminal
# Créer le snapshot depuis l'image
SNAPSHOT_RESULT=$(osc-cli api CreateSnapshot \
--FileLocation "$PRESIGNED_URL" \
--SnapshotSize "$VIRTUAL_SIZE" \
--Description "Snapshot NixOS 25.11")
SNAPSHOT_ID=$(echo "$SNAPSHOT_RESULT" | jq -r '.Snapshot.SnapshotId')
echo "Snapshot créé: $SNAPSHOT_ID"
# Attendre que le snapshot soit prêt
while true; do
STATE=$(osc-cli api ReadSnapshots \
--Filters "{\"SnapshotIds\": [\"$SNAPSHOT_ID\"]}" \
| jq -r '.Snapshots[0].State')
echo "État: $STATE"
[ "$STATE" = "completed" ] && break
sleep 10
done

La boucle attend que le snapshot soit complètement créé avant de continuer.

On peut enfin créer l’OMI en mappant le snapshot au bon device :

Fenêtre de terminal
# Créer l'OMI avec le bon mapping de disque (Adapter la taille si besoin)
OMI_RESULT=$(osc-cli api CreateImage \
--ImageName "NixOS-25.11" \
--RootDeviceName "/dev/sda1" \
--Architecture "x86_64" \
--Description "NixOS 25.11 image" \
--BlockDeviceMappings "[{
\"DeviceName\": \"/dev/sda1\",
\"Bsu\": {
\"SnapshotId\": \"$SNAPSHOT_ID\",
\"VolumeSize\": $VOLUME_SIZE_GIB,
\"VolumeType\": \"gp2\",
\"DeleteOnVmDeletion\": true
}
}]")
OMI_ID=$(echo "$OMI_RESULT" | jq -r '.Image.ImageId')
echo "OMI créée: $OMI_ID"

On peut tester l’OMI en lançant une VM de test :

Fenêtre de terminal
# Créer une VM de test
osc-cli api CreateVms \
--ImageId "$OMI_ID" \
--VmType tinav7.c4r4p1 \
--KeypairName ma-keypair \
--SecurityGroupIds '["sg-xxxxxxxx"]'

On récupère l’IP publique de la VM depuis la console Cockpit ou avec `osc-cli api ReadVms:

Fenêtre de terminal
osc-cli api ReadVms --Filters '{"ImageIds": ["'"$OMI_ID"'"]}' | jq -r '.Vms[0].PublicIp'
<IP-PUBLIQUE-DE-LA-VM>

Connexion :

Fenêtre de terminal
ssh -i ~/.ssh/ma-cle.pem outscale@<IP-PUBLIQUE-DE-LA-VM>

Pour éviter de commiter accidentellement des secrets en clair, j’ajoute des outils de détection dans modules/tools.nix :

{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
# ... autres outils ...
# Détection de secrets
gitleaks # Scan Git pour les secrets
trufflehog # Détection avancée de secrets
];
}

Utilisation :

Fenêtre de terminal
# Scanner le dépôt avec gitleaks
gitleaks detect --source .
# Scanner avec trufflehog
trufflehog git file://.

Pour résumer, voici le workflow de mise à jour de l’image :

Fenêtre de terminal
# 1. Modifier la configuration
vim hosts/outscale.nix
# 2. Versionner
git add .
git commit -m "feat: Ma modification"
# 3. Reconstruire l'image
nix build .#outscale-qcow -o ./result-outscale
# 4. Tester localement avec QEMU
cp result-outscale/nixos.qcow2 /tmp/nixos-test.qcow2
qemu-system-x86_64 -m 2048 -cpu qemu64 \
-drive file=/tmp/nixos-test.qcow2,format=qcow2,if=virtio \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-pci,netdev=net0 -nographic
# 5. Uploader vers OOS
s3cmd put ./result-outscale/nixos.qcow2 s3://nixos-images/
# 6. Créer snapshot + OMI (voir étape 6 pour les détails)
# ...
# 7. Déployer une nouvelle instance
osc-cli api CreateVms --ImageId $OMI_ID --VmType tinav7.c4r4p1 \
--KeypairName ma-keypair

Ce guide met en place un pipeline complet de génération d’images cloud NixOS :

  • Images reproductibles : même configuration = même image
  • Secrets protégés : git-agecrypt chiffre les données sensibles dans Git
  • Sécurité renforcée : SSH durci, firewall, AppArmor
  • Tests locaux : validation avec QEMU avant déploiement
  • Déploiement automatisé : scripts create-omi et osc-setup

L’avantage majeur de cette approche est l’auditabilité : chaque changement dans l’image est tracé dans Git, et il est possible de reconstruire exactement la même image des mois plus tard.

Pour fêter la réussite de ce projet, j’ai écris un motd dynamique pour accueillir les utilisateurs lors de la connexion SSH. 🎉

motd NixOS Outscale

Après avoir maîtrisé la génération d’images OMI NixOS, complétez votre expertise :

RessourceDescription
nixos-generatorsGénération d’images multi-formats
git-agecryptChiffrement transparent dans Git
Outscale DocumentationDocumentation cloud Outscale
NixOS Cloud WikiWiki NixOS pour le déploiement cloud