Aller au contenu
Virtualisation medium

Terraform + libvirt (KVM) : déployer des VMs avec Cloud-Init

41 min de lecture

Logo KVM

Créer une VM KVM à la main, c'est bien pour apprendre. Mais pour un lab de 5, 10 ou 20 machines ? Terraform automatise la création de VMs libvirt avec des configurations reproductibles. Ce guide vous montre comment faire proprement, avec Cloud-Init et les bonnes pratiques 2026.

À la fin de ce guide, vous aurez :

  • Un template Terraform réutilisable pour créer des VMs KVM
  • Une configuration Cloud-Init robuste (SSH, hostname, packages)
  • Les bonnes pratiques sécurité (pas de security_driver = none)
  • Des commandes de debug pour dépanner sans interface graphique
ÉlémentVersionVérification
KVM/libvirtUbuntu 22.04+ ou équivalentvirsh version
Terraform ou OpenTofu≥ 1.6.0terraform version ou tofu version
Image cloudUbuntu 24.04 QCOW2Téléchargement ci-dessous
Clé SSHEd25519 recommandéels ~/.ssh/id_ed25519.pub
terraform-kvm/
├── main.tf # Ressources principales
├── variables.tf # Variables configurables
├── outputs.tf # Sorties (IP, hostname)
├── versions.tf # Providers et versions
└── cloudinit/
├── user-data.yaml # Configuration Cloud-Init
└── network-config.yaml

Architecture cible :

┌─────────────────────────────────────────────────────────┐
│ Hôte KVM │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Pool default │ │ Réseau NAT │ │
│ │ (images) │ │ (192.168.122.x)│ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ VM créée par Terraform │ │
│ │ • Ubuntu 24.04 (cloud image) │ │
│ │ • Cloud-Init configuré │ │
│ │ • qemu-guest-agent installé │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
  1. Vérifier que libvirtd fonctionne

    Fenêtre de terminal
    systemctl is-active libvirtd

    Sortie attendue : active

  2. Vérifier votre appartenance au groupe libvirt

    Fenêtre de terminal
    groups | grep -E "libvirt|kvm"

    Si absent :

    Fenêtre de terminal
    sudo usermod -aG libvirt,kvm $USER
    # Puis déconnectez-vous et reconnectez-vous
  3. Tester l'accès à qemu:///system

    Fenêtre de terminal
    virsh -c qemu:///system list --all

    Cette commande doit s'exécuter sans sudo. Si elle échoue, voir la section Dépannage.

Les images cloud sont des QCOW2 minimalistes, optimisées pour Cloud-Init :

Fenêtre de terminal
# Créer un dossier pour les images de base
sudo mkdir -p /var/lib/libvirt/images/base
# Télécharger Ubuntu 24.04 cloud image
sudo wget -O /var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img \
https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Vérifier le téléchargement
qemu-img info /var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img
Fenêtre de terminal
# Générer une clé Ed25519 (plus sécurisée que RSA)
ssh-keygen -t ed25519 -C "terraform-kvm" -f ~/.ssh/id_ed25519 -N ""
Fenêtre de terminal
mkdir -p ~/terraform-kvm/cloudinit
cd ~/terraform-kvm

Créez versions.tf :

versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.9.0" # Version 2025+ avec breaking changes
}
}
}
provider "libvirt" {
# Connexion au démon libvirt système (pas session utilisateur)
uri = "qemu:///system"
}

Créez variables.tf :

variables.tf
variable "vm_name" {
description = "Nom de la VM"
type = string
default = "tf-kvm-01"
}
variable "memory_mb" {
description = "Mémoire RAM en Mo"
type = number
default = 2048
}
variable "vcpu" {
description = "Nombre de vCPU"
type = number
default = 2
}
variable "disk_size_gb" {
description = "Taille du disque en Go (expansion de l'image cloud)"
type = number
default = 20
}
variable "base_image_path" {
description = "Chemin vers l'image cloud de base"
type = string
default = "/var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img"
}
variable "ssh_public_key_path" {
description = "Chemin vers la clé SSH publique"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "network_name" {
description = "Nom du réseau libvirt"
type = string
default = "default"
}
variable "pool_name" {
description = "Nom du pool de stockage libvirt"
type = string
default = "default"
}

Créez main.tf :

main.tf
# ============================================================
# Provider libvirt 0.9.x - Configuration complète
# ============================================================
# 1) Volume de base (image cloud Ubuntu - copie locale)
resource "libvirt_volume" "base" {
name = "ubuntu-24.04-base.qcow2"
pool = var.pool_name
target = {
format = { type = "qcow2" }
}
create = {
content = {
url = var.base_image_path
}
}
}
# 2) Volume OS de la VM (overlay CoW sur l'image de base)
resource "libvirt_volume" "os_disk" {
name = "${var.vm_name}.qcow2"
pool = var.pool_name
capacity = var.disk_size_gb * 1024 * 1024 * 1024 # Conversion en bytes
target = {
format = { type = "qcow2" }
}
# Utilise l'image de base comme backing store (Copy-on-Write)
backing_store = {
path = libvirt_volume.base.path
format = { type = "qcow2" }
}
}
# 3) Cloud-Init : génération de l'ISO de configuration
resource "libvirt_cloudinit_disk" "init" {
name = "${var.vm_name}-cloudinit"
user_data = templatefile("${path.module}/cloudinit/user-data.yaml", {
hostname = var.vm_name
public_key = file(pathexpand(var.ssh_public_key_path))
})
# meta_data est OBLIGATOIRE en 0.9.x
meta_data = yamlencode({
instance-id = var.vm_name
local-hostname = var.vm_name
})
network_config = file("${path.module}/cloudinit/network-config.yaml")
}
# 4) Volume pour l'ISO Cloud-Init (upload dans le pool)
resource "libvirt_volume" "cloudinit" {
name = "${var.vm_name}-cloudinit.iso"
pool = var.pool_name
create = {
content = {
url = libvirt_cloudinit_disk.init.path
}
}
}
# 5) Domaine (VM) - Syntaxe 0.9.x avec bloc devices
resource "libvirt_domain" "vm" {
name = var.vm_name
type = "kvm" # Type obligatoire en 0.9.x
memory = var.memory_mb * 1024 # Attention : en KiB, pas MiB !
vcpu = var.vcpu
# CPU host-passthrough : expose tous les flags CPU de l'hôte (AES-NI,
# SSE4.x, AVX...). Sans ça, libvirt utilise mode=custom model=qemu64
# qui n'expose que des instructions x86_64 minimales, et les kernels
# récents (RHEL/AlmaLinux 10, Fedora ≥ 40) bloquent au chargement
# des modules crypto.
cpu = {
mode = "host-passthrough"
}
os = {
type = "hvm"
type_machine = "q35"
firmware = "efi"
# Désactive Secure Boot : libvirt 10+ enrôle par défaut les clés
# Microsoft (OVMF_CODE_4M.ms.fd) qui rejettent les kernels non
# signés MS. Les images cloud Linux démarrent puis se bloquent à
# /init faute de modules virtio chargés.
# Ordre alphabétique des features OBLIGATOIRE : le provider 0.9.x
# retourne les features triées et compare strictement avec ce
# qu'on lui donne (sinon "Provider produced inconsistent result").
firmware_info = {
features = [
{ enabled = "no", name = "enrolled-keys" },
{ enabled = "no", name = "secure-boot" },
]
}
}
features = {
acpi = true
}
# Bloc devices : structure obligatoire en 0.9.x
devices = {
# Disque OS
disks = [
{
# Driver explicite obligatoire pour qcow2 !
driver = {
name = "qemu"
type = "qcow2"
}
source = {
volume = {
pool = var.pool_name
volume = libvirt_volume.os_disk.name
}
}
target = {
dev = "vda"
bus = "virtio"
}
},
# Disque Cloud-Init (CDROM)
{
device = "cdrom"
driver = {
name = "qemu"
type = "raw"
}
source = {
volume = {
pool = var.pool_name
volume = libvirt_volume.cloudinit.name
}
}
target = {
dev = "sda"
bus = "sata"
}
}
]
# Interface réseau
interfaces = [
{
type = "network"
model = { type = "virtio" }
source = {
network = { network = var.network_name }
}
}
]
# Console série (indispensable pour debug)
serials = [
{
type = "pty"
target = {
port = 0
type = "isa-serial"
}
}
]
# Support graphique (optionnel, pour virt-manager)
graphics = [
{
spice = {
autoport = "yes"
}
}
]
}
running = true
}

Cloud-Init est le standard pour configurer les VMs cloud au premier démarrage. Il gère : hostname, utilisateurs, clés SSH, packages, commandes.

Créez cloudinit/user-data.yaml :

cloudinit/user-data.yaml
#cloud-config
# Hostname de la VM
hostname: ${hostname}
fqdn: ${hostname}.local
manage_etc_hosts: true
# Utilisateur par défaut
users:
- name: ubuntu
groups: [adm, sudo, docker]
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
lock_passwd: true # Désactive le mot de passe (SSH key only)
ssh_authorized_keys:
- ${public_key}
# Paquets à installer
packages:
- qemu-guest-agent # Indispensable pour récupérer l'IP
- curl
- vim
- htop
# Activer l'agent QEMU au démarrage
runcmd:
- systemctl enable --now qemu-guest-agent
# Autoriser SSH par clé uniquement
ssh_pwauth: false
# Mise à jour des paquets au premier boot
package_update: true
package_upgrade: false # Évite les surprises de temps
# Message final
final_message: |
Cloud-init terminé après $UPTIME secondes.
Hostname: ${hostname}
Connectez-vous avec: ssh ubuntu@<IP>

Créez cloudinit/network-config.yaml :

cloudinit/network-config.yaml
version: 2
ethernets:
default:
match:
driver: virtio* # Match n'importe quelle interface virtio
dhcp4: true
dhcp6: false

En provider 0.9.x, l'IP n'est plus accessible directement via network_interface[0].addresses. On utilise un data source dédié :

Créez outputs.tf :

outputs.tf
# Data source pour récupérer les IPs via DHCP leases
data "libvirt_domain_interface_addresses" "vm" {
domain = libvirt_domain.vm.name
source = "lease" # Utilise le serveur DHCP de libvirt
depends_on = [libvirt_domain.vm]
}
output "vm_name" {
description = "Nom de la VM"
value = libvirt_domain.vm.name
}
output "vm_id" {
description = "ID libvirt de la VM"
value = libvirt_domain.vm.id
}
output "vm_uuid" {
description = "UUID de la VM"
value = libvirt_domain.vm.uuid
}
# Toutes les IPs (format 0.9.x : interfaces[].addrs[].addr)
output "ip_addresses" {
description = "Adresses IP de la VM"
value = [
for iface in data.libvirt_domain_interface_addresses.vm.interfaces :
[for a in iface.addrs : a.addr]
]
}
# IP principale pour faciliter l'usage
output "primary_ip" {
description = "Adresse IP principale de la VM"
value = try(
data.libvirt_domain_interface_addresses.vm.interfaces[0].addrs[0].addr,
"Non disponible"
)
}
output "ssh_command" {
description = "Commande SSH pour se connecter"
value = try(
"ssh ubuntu@${data.libvirt_domain_interface_addresses.vm.interfaces[0].addrs[0].addr}",
"IP non disponible - attendre cloud-init puis: tofu refresh"
)
}
  1. Initialiser Terraform

    Fenêtre de terminal
    cd ~/terraform-kvm
    terraform init

    Sortie attendue :

    Initializing the backend...
    Initializing provider plugins...
    - Finding dmacvicar/libvirt versions matching "~> 0.8"...
    - Installing dmacvicar/libvirt v0.8.1...
    Terraform has been successfully initialized!
  2. Valider la configuration

    Fenêtre de terminal
    terraform validate

    Sortie attendue : Success! The configuration is valid.

  3. Prévisualiser les changements

    Fenêtre de terminal
    terraform plan

    Vérifiez que 3 ressources seront créées :

    • libvirt_volume.os_disk
    • libvirt_cloudinit_disk.init
    • libvirt_domain.vm
  4. Appliquer

    Fenêtre de terminal
    terraform apply

    Tapez yes pour confirmer.

  5. Récupérer l'IP

    Fenêtre de terminal
    terraform output ip_addresses
Fenêtre de terminal
# État de la VM
virsh list --all
# Détails
virsh dominfo tf-kvm-01
# IP via l'agent QEMU
virsh domifaddr tf-kvm-01 --source agent
Fenêtre de terminal
# Récupérer la commande SSH
terraform output ssh_command
# Ou directement (remplacez l'IP)
ssh ubuntu@192.168.122.xxx

Si la VM ne démarre pas ou n'obtient pas d'IP, la console série est votre meilleur ami :

Fenêtre de terminal
virsh console tf-kvm-01

Pour quitter : Ctrl + ]

Depuis la console ou SSH :

Fenêtre de terminal
# Statut global
cloud-init status --long
# Logs détaillés
sudo cat /var/log/cloud-init-output.log
# Configuration appliquée
sudo cat /var/lib/cloud/instance/user-data.txt
StatutSignification
status: doneCloud-Init terminé avec succès
status: runningEncore en cours (patience)
status: errorErreur, consultez les logs
SymptômeCause probableSolution
Error: virsh: command not foundlibvirt non installéGuide installation
failed to connect to the hypervisorlibvirtd arrêté ou permissionssudo systemctl start libvirtd + vérifier groupe
IP vide même après 1 minuteqemu-guest-agent non installéVérifier Cloud-Init user-data
Permission denied sur le poolDroits fichier ou AppArmorVoir section Sécurité ci-dessous
Disque corrompu après applyImage source modifiéeUtiliser une image "golden" en lecture seule
Boot bloqué dans /init (AlmaLinux/RHEL 10)Secure Boot enrôlé par libvirt + clés MSAjouter firmware_info.features avec secure-boot=no + enrolled-keys=no
Kernel panic au démarrage (distros récentes)CPU qemu64 générique sans flags modernesAjouter cpu = { mode = "host-passthrough" } au domain
Provider produced inconsistent result sur firmware_info.featuresBug provider 0.9.x : tri alphabétique au retourDéclarer les features dans l'ordre alphabétique du name
Fenêtre de terminal
# Logs en temps réel
journalctl -u libvirtd -f
# Erreurs récentes
journalctl -u libvirtd --since "10 minutes ago" | grep -i error

Partie 5 : Sécurité, Ne pas désactiver les protections

Section intitulée « Partie 5 : Sécurité, Ne pas désactiver les protections »

Quand Terraform (ou vous) crée un fichier dans /var/lib/libvirt/images, il peut avoir les mauvais labels de sécurité. libvirt refuse alors de démarrer la VM avec une erreur de permission.

La solution "facile" (et dangereuse) : désactiver le driver de sécurité.

  1. Vérifier le profil AppArmor

    Fenêtre de terminal
    sudo aa-status | grep libvirt
  2. Ajouter le chemin au profil si nécessaire

    Si vous utilisez un pool personnalisé (ex: /data/vms), modifiez :

    Fenêtre de terminal
    sudo nano /etc/apparmor.d/local/usr.sbin.libvirtd

    Ajoutez :

    /data/vms/** rwk,
  3. Recharger AppArmor

    Fenêtre de terminal
    sudo systemctl reload apparmor
    sudo systemctl restart libvirtd
Fenêtre de terminal
# Vérifier le propriétaire
ls -la /var/lib/libvirt/images/
# Doit appartenir à root:root ou libvirt-qemu selon la distro
# Avec les permissions 711 sur le dossier

Si vous avez des configurations existantes avec le provider 0.7 ou 0.8, voici les changements à appliquer.

ÉlémentProvider 0.8.xProvider 0.9.x
libvirt_volumesource, formatcreate.content.url, target.format.type
backing filebase_volume_idbacking_store = { path, format }
libvirt_cloudinit_diskmeta_data optionnelmeta_data obligatoire
libvirt_domainblocs disk {}, network_interface {}devices = { disks = [...], interfaces = [...] }
memoryen MiBen KiB (multiplier par 1024)
driver qcow2auto-détectéexplicite obligatoire
récupération IPnetwork_interface[0].addressesdata.libvirt_domain_interface_addresses
CPU pour distros récentesimplicitecpu = { mode = "host-passthrough" } souvent obligatoire
Secure Boot UEFInon auto-enrôléauto-enrôlé sur libvirt 10+ → firmware_info.features à désactiver
resource "libvirt_volume" "os_disk" {
name = "vm.qcow2"
pool = "default"
source = "/var/lib/libvirt/images/base.img"
format = "qcow2"
size = 20 * 1024 * 1024 * 1024
}
resource "libvirt_domain" "vm" {
name = "my-vm"
memory = 2048
vcpu = 2
disk {
volume_id = libvirt_volume.os_disk.id
}
network_interface {
network_name = "default"
wait_for_lease = true
}
cloudinit = libvirt_cloudinit_disk.init.id
console {
type = "pty"
target_port = "0"
target_type = "serial"
}
qemu_agent = true
}

Pour créer 3 VMs identiques, modifiez variables.tf :

variable "vm_count" {
description = "Nombre de VMs à créer"
type = number
default = 3
}

Et adaptez main.tf (syntaxe 0.9.x) :

# Volume de base (partagé par toutes les VMs)
resource "libvirt_volume" "base" {
name = "ubuntu-24.04-base.qcow2"
pool = var.pool_name
target = {
format = { type = "qcow2" }
}
create = {
content = {
url = var.base_image_path
}
}
}
# Volumes OS pour chaque VM (overlay CoW)
resource "libvirt_volume" "os_disk" {
count = var.vm_count
name = "${var.vm_name}-${count.index + 1}.qcow2"
pool = var.pool_name
capacity = var.disk_size_gb * 1024 * 1024 * 1024
target = {
format = { type = "qcow2" }
}
backing_store = {
path = libvirt_volume.base.path
format = { type = "qcow2" }
}
}
# Cloud-init pour chaque VM
resource "libvirt_cloudinit_disk" "init" {
count = var.vm_count
name = "${var.vm_name}-${count.index + 1}-cloudinit"
user_data = templatefile("${path.module}/cloudinit/user-data.yaml", {
hostname = "${var.vm_name}-${count.index + 1}"
public_key = file(pathexpand(var.ssh_public_key_path))
})
meta_data = yamlencode({
instance-id = "${var.vm_name}-${count.index + 1}"
local-hostname = "${var.vm_name}-${count.index + 1}"
})
network_config = file("${path.module}/cloudinit/network-config.yaml")
}
# Volumes cloud-init
resource "libvirt_volume" "cloudinit" {
count = var.vm_count
name = "${var.vm_name}-${count.index + 1}-cloudinit.iso"
pool = var.pool_name
create = {
content = {
url = libvirt_cloudinit_disk.init[count.index].path
}
}
}
# Domaines (VMs)
resource "libvirt_domain" "vm" {
count = var.vm_count
name = "${var.vm_name}-${count.index + 1}"
type = "kvm"
memory = var.memory_mb * 1024
vcpu = var.vcpu
os = { type = "hvm" }
features = { acpi = true }
devices = {
disks = [
{
driver = { name = "qemu", type = "qcow2" }
source = {
volume = {
pool = var.pool_name
volume = libvirt_volume.os_disk[count.index].name
}
}
target = { dev = "vda", bus = "virtio" }
},
{
device = "cdrom"
driver = { name = "qemu", type = "raw" }
source = {
volume = {
pool = var.pool_name
volume = libvirt_volume.cloudinit[count.index].name
}
}
target = { dev = "sda", bus = "sata" }
}
]
interfaces = [
{
type = "network"
model = { type = "virtio" }
source = { network = { network = var.network_name } }
}
]
serials = [{ type = "pty", target = { port = 0, type = "isa-serial" } }]
graphics = [{ spice = { autoport = "yes" } }]
}
running = true
}

Adaptez outputs.tf :

# Data source pour chaque VM
data "libvirt_domain_interface_addresses" "vm" {
count = var.vm_count
domain = libvirt_domain.vm[count.index].name
source = "lease"
depends_on = [libvirt_domain.vm]
}
output "vms" {
description = "Informations sur les VMs créées"
value = {
for i, vm in libvirt_domain.vm : vm.name => {
id = vm.id
ip = try(data.libvirt_domain_interface_addresses.vm[i].interfaces[0].addrs[0].addr, "Non disponible")
}
}
}

Pour des VMs avec des specs différentes :

variable "vms" {
description = "Map des VMs à créer"
type = map(object({
memory = number
vcpu = number
disk = number
}))
default = {
"master" = { memory = 4096, vcpu = 2, disk = 40 }
"worker1" = { memory = 2048, vcpu = 2, disk = 20 }
"worker2" = { memory = 2048, vcpu = 2, disk = 20 }
}
}

Quand vous faites terraform apply plusieurs fois avec des modifications de disque, vous pouvez créer des chaînes de snapshots involontaires.

Règle : si vous voulez une VM "propre", faites un terraform destroy puis terraform apply, pas des apply successifs.

Cloud-Init ne s'exécute qu'une fois par instance. Pour le rejouer :

Fenêtre de terminal
# Option 1 : Supprimer l'état Cloud-Init (VM existante)
sudo cloud-init clean
sudo rm /etc/machine-id
sudo reboot
# Option 2 : Recréer la VM (recommandé avec Terraform)
terraform destroy
terraform apply

Le fichier terraform.tfstate contient l'état de votre infrastructure. Sauvegardez-le :

Fenêtre de terminal
# Sauvegarder localement
cp terraform.tfstate terraform.tfstate.backup
# Ou utilisez un backend distant (S3, GitLab, etc.)

Si vous avez configuré un bridge (voir guide réseaux), modifiez la section interfaces dans le bloc devices :

# Syntaxe 0.9.x pour bridge
interfaces = [
{
type = "bridge"
model = { type = "virtio" }
source = {
bridge = { bridge = "br0" }
}
}
]
# Volume de données (syntaxe 0.9.x)
resource "libvirt_volume" "data_disk" {
name = "${var.vm_name}-data.qcow2"
pool = var.pool_name
capacity = 50 * 1024 * 1024 * 1024 # 50 Go
target = {
format = { type = "qcow2" }
}
}
# Dans libvirt_domain, ajouter le disque dans devices.disks :
devices = {
disks = [
# ... disque OS existant ...
{
driver = { name = "qemu", type = "qcow2" }
source = {
volume = {
pool = var.pool_name
volume = libvirt_volume.data_disk.name
}
}
target = {
dev = "vdb" # Second disque
bus = "virtio"
}
}
]
# ... reste de la config ...
}

OpenTofu est un fork open source de Terraform, compatible avec les configurations existantes.

Fenêtre de terminal
# Remplacer terraform par tofu
tofu init
tofu plan
tofu apply

Pour supprimer toutes les ressources créées :

Fenêtre de terminal
terraform destroy

Vérifiez que tout est supprimé :

Fenêtre de terminal
virsh list --all
virsh vol-list default
  1. Provider 0.9.x : structure complètement différente de 0.8.x, consultez la section Migration
  2. Driver qcow2 explicite : sans driver = { name = "qemu", type = "qcow2" }, la VM ne boot pas
  3. Memory en KiB : memory = 2048 * 1024 pour 2 Go (pas MiB)
  4. meta_data obligatoire : libvirt_cloudinit_disk requiert meta_data = yamlencode({...})
  5. Cloud-Init réseau : utilisez match: { driver: virtio* } au lieu de eth0
  6. Récupération IP : data source libvirt_domain_interface_addresses avec source = "lease"
  7. Sécurité : ne désactivez jamais security_driver, corrigez les permissions
  8. CPU host-passthrough : obligatoire pour AlmaLinux/RHEL 10 et Fedora récents, sinon stuck dans /init au boot
  9. Secure Boot UEFI : auto-enrôlé sur libvirt 10+ → désactiver via firmware_info.features pour les images cloud Linux non signées MS
  10. Debug : virsh console + cloud-init status --long + virsh net-dhcp-leases default

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn