📍 Article principal : Ansible 2026 : la stack pratique pour automatiser Linux et Windows
Vous avez l’inventaire dynamique et les rôles propres. On les met au travail en provisionnant une vraie pile applicative — Nginx, PHP-FPM, MariaDB sur un VPS fraîchement loué.
Ce que vous aurez à la fin
Un serveur Debian ou Ubuntu prêt à servir un site PHP : Nginx 1.24+ avec un vhost paramétrable, PHP-FPM 8.3 avec OPcache et JIT activés, MariaDB 11.x avec un utilisateur applicatif limité, certificat Let’s Encrypt automatique. Le tout idempotent — relancer le playbook ne casse rien et ne réécrit que ce qui a réellement changé.
Prérequis
- ansible-core 2.20 ou supérieur, projet structuré en rôles.
- Un VPS Debian 12 (bookworm) ou Ubuntu 24.04 LTS accessible en SSH avec sudo.
- Un nom de domaine pointant vers l’IP du VPS (un enregistrement A suffit pour ce premier tour).
- Le port 80 et 443 ouverts côté pare-feu cloud.
- Niveau attendu : avoir déjà installé une LEMP à la main une fois.
- Temps : 90 minutes à 2 heures en lecture active.
Étape 1 — Architecture du projet
On structure le projet pour réutiliser des rôles existants quand ils sont solides, et n’écrire soi-même que les rôles spécifiques à l’application. Pour LEMP, l’écosystème offre des rôles communautaires bien testés. On déclare dans roles/requirements.yml :
---
roles:
- name: geerlingguy.nginx
version: 3.3.0
- name: geerlingguy.php
version: 6.0.0
- name: geerlingguy.mysql
version: 6.3.1
- name: geerlingguy.certbot
version: 5.4.1
Les rôles geerlingguy.* sont mainternus par Jeff Geerling depuis plus de dix ans, leur API est stable et ils gèrent les particularités Debian/Ubuntu/RHEL. Pour MariaDB on utilise geerlingguy.mysql qui supporte MariaDB nativement via une variable de configuration — pas besoin d’un rôle séparé.
On installe :
ansible-galaxy role install -r roles/requirements.yml -p roles/
Étape 2 — Le playbook orchestrateur
On crée playbooks/lemp.yml qui appelle les rôles dans le bon ordre. L’ordre compte : MariaDB doit être prête avant qu’on crée la base ; PHP doit être installé avant que Nginx connaisse les upstreams FastCGI.
---
- name: Provisionner une stack LEMP
hosts: role_web
become: true
vars_files:
- ../group_vars/all/secrets.yml
pre_tasks:
- name: Mettre à jour le cache APT
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
roles:
- role: geerlingguy.mysql
- role: geerlingguy.php
- role: geerlingguy.nginx
- role: geerlingguy.certbot
post_tasks:
- name: Créer le répertoire de l'application
ansible.builtin.file:
path: "{{ app_root }}"
state: directory
owner: www-data
group: www-data
mode: '0755'
La directive vars_files charge les secrets chiffrés. pre_tasks fait tourner la mise à jour APT avant les rôles, ce qui évite que chaque rôle relance son propre apt update. post_tasks ajoute les actions spécifiques à l’application, après que tous les services standards soient en place.
Étape 3 — Variables de la stack dans group_vars
Les rôles geerlingguy.* consomment leurs variables depuis group_vars/role_web.yml. C’est là qu’on définit l’API de notre stack — les choix de versions, les paramètres de tuning, les éléments propres à l’application.
---
# Application
app_domain: "lab.example.com"
app_root: "/var/www/{{ app_domain }}"
app_db_name: "lab_app"
app_db_user: "lab_app"
# MariaDB via geerlingguy.mysql
mysql_packages:
- mariadb-server
- mariadb-client
- python3-pymysql
mysql_root_password: "{{ vault_mysql_root_password }}"
mysql_databases:
- name: "{{ app_db_name }}"
encoding: utf8mb4
collation: utf8mb4_unicode_ci
mysql_users:
- name: "{{ app_db_user }}"
host: localhost
password: "{{ vault_app_db_password }}"
priv: "{{ app_db_name }}.*:ALL"
# PHP
php_version: "8.3"
php_packages_extra:
- php8.3-fpm
- php8.3-mysql
- php8.3-mbstring
- php8.3-xml
- php8.3-curl
- php8.3-zip
- php8.3-intl
- php8.3-opcache
php_enable_php_fpm: true
php_opcache_enabled_in_ini: true
php_opcache_jit_buffer_size: "100M"
# Nginx
nginx_remove_default_vhost: true
nginx_vhosts:
- listen: "80"
server_name: "{{ app_domain }}"
root: "{{ app_root }}/public"
index: "index.php index.html"
extra_parameters: |
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
}
# Certbot
certbot_create_if_missing: true
certbot_admin_email: "ops@example.com"
certbot_certs:
- domains:
- "{{ app_domain }}"
certbot_create_method: standalone
certbot_auto_renew: true
Trois choix méritent une explication. Pour MariaDB, le paquet Python python3-pymysql est explicitement listé — sans lui, les modules Ansible mysql_db et mysql_user tombent en erreur cryptique. Pour PHP, on active OPcache JIT avec un buffer de 100 Mo : c’est l’optimisation moteur qui apporte 15 à 30 % de gain sur les applications PHP modernes (Symfony, Laravel) en production. Pour le vhost Nginx, on inclut snippets/fastcgi-php.conf fourni par le paquet Debian — il contient les fastcgi_param standards qu’on n’a pas envie de réécrire.
Étape 4 — Les secrets de la stack
Deux secrets sensibles dans cette configuration : le mot de passe root MariaDB et le mot de passe utilisateur applicatif. On les chiffre avec Vault et on les stocke dans group_vars/all/secrets.yml :
openssl rand -base64 24 | tr -d '/+=' | head -c 32 \
| xargs -I{} ansible-vault encrypt_string \
--vault-password-file ~/.ansible/vault-pass-dev \
--name 'vault_mysql_root_password' '{}'
On répète pour vault_app_db_password. On colle les deux blocs dans group_vars/all/secrets.yml. Le préfixe vault_ est une convention : les variables vraiment sensibles portent ce préfixe, les variables qui les référencent restent en clair (mysql_root_password: "{{ vault_mysql_root_password }}"). Ainsi les rôles voient des noms simples, et un audit du dépôt voit immédiatement quelles variables sont protégées.
Étape 5 — Premier déploiement
Le pré-requis Certbot avec la méthode standalone est qu’aucun processus ne tienne le port 80 au moment de la demande de certificat. Pour un premier déploiement sur un serveur vide, ce n’est pas un problème. Pour les déploiements suivants, on bascule sur la méthode webroot qui laisse Nginx en place. Pour ce tutoriel, on reste sur standalone.
ansible-playbook -i inventory/ playbooks/lemp.yml \
--vault-password-file ~/.ansible/vault-pass-dev \
--limit role_web \
--diff
Le drapeau --diff affiche, pour chaque fichier modifié, le delta exact entre l’état précédent et le nouveau. C’est précieux pour comprendre ce que les rôles ont vraiment changé sur la machine.
La première exécution dure entre cinq et quinze minutes selon la latence APT et la taille des paquets. La sortie défile, regroupée par rôle. À la fin, le récap doit montrer plusieurs changed par hôte mais zéro failed.
Étape 6 — Vérification de la stack
On vérifie chaque couche indépendamment, plutôt que de se contenter de constater qu’une page s’affiche. Cette discipline paie quand un composant tombera dans six mois.
Côté MariaDB :
# tasks ad-hoc lancée depuis un playbook ou via -m
- name: Lister les bases présentes
community.mysql.mysql_info:
login_user: root
login_password: "{{ vault_mysql_root_password }}"
filter: databases
register: db_info
- name: Afficher les noms de bases
ansible.builtin.debug:
msg: "{{ db_info.databases | dict2items | map(attribute='key') | list }}"
On préfère ici community.mysql.mysql_info à un appel mysql -p<mot-de-passe> en ligne de commande : le mot de passe ne transite jamais par argv visible dans ps aux, le module gère l’échappement et utilise un fichier de configuration temporaire en mode 600. La sortie attendue liste information_schema, mysql, performance_schema, sys, et lab_app. La présence de lab_app confirme la création.
Côté PHP, on dépose un fichier de test :
- name: Déposer un fichier d'info PHP de test
ansible.builtin.copy:
dest: "{{ app_root }}/public/info.php"
content: "<?php phpinfo();"
owner: www-data
group: www-data
mode: '0644'
Un curl https://lab.example.com/info.php | grep -E 'OPcache|JIT' doit retourner les lignes confirmant que les deux optimisations sont activées. Une fois la vérification faite, on supprime info.php — le laisser en production révèle l’environnement à un attaquant.
Côté TLS :
curl -I https://lab.example.com/
L’en-tête HTTP/2 200 confirme la disponibilité. openssl s_client -connect lab.example.com:443 -servername lab.example.com permet d’inspecter le certificat — il doit être émis par Let’s Encrypt R3 ou R10 et avoir une validité de 90 jours.
Étape 7 — Idempotence et configuration drift
On relance le même playbook une seconde fois. Le récap doit afficher changed=0 ou un nombre très faible (un ou deux changements liés à la rotation des certificats). Si plusieurs tâches restent à changed=1 sans raison, on identifie lesquelles dans la sortie et on examine leur définition. La cause habituelle est un module shell ou command sans changed_when:.
Pour vérifier qu’aucune dérive n’est apparue depuis le dernier run sans appliquer de changement, on lance en mode check :
ansible-playbook -i inventory/ playbooks/lemp.yml \
--vault-password-file ~/.ansible/vault-pass-dev \
--check --diff --limit role_web
Tout changed dans cette sortie indique une divergence entre la configuration déclarée et l’état réel — souvent la trace d’une intervention manuelle qu’il faut soit valider en l’intégrant au playbook, soit annuler.
Étape 8 — Logique de déploiement applicatif
Le playbook précédent prépare la pile. Il ne déploie pas l’application. C’est délibéré — on sépare la configuration des composants (rôle d’Ansible) de la livraison du code (rôle d’un pipeline CI/CD ou d’un outil dédié). Pour un projet simple, on ajoute un rôle local roles/app_deploy qui clone le dépôt Git et installe les dépendances Composer :
# roles/app_deploy/tasks/main.yml
---
- name: Cloner le dépôt applicatif
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: "{{ app_root }}"
version: "{{ app_branch | default('main') }}"
become_user: www-data
- name: Installer les dépendances Composer
ansible.builtin.command:
cmd: "composer install --no-dev --optimize-autoloader"
chdir: "{{ app_root }}"
creates: "{{ app_root }}/vendor/autoload.php"
become_user: www-data
- name: Recharger PHP-FPM pour vider OPcache
ansible.builtin.systemd_service:
name: "php{{ php_version }}-fpm"
state: reloaded
Le creates: rend la commande composer install idempotente — elle ne tourne que si vendor/autoload.php n’existe pas encore. Pour les déploiements suivants on utilisera plutôt un changed_when: basé sur le SHA Git, ou on déclenchera depuis CI plutôt que depuis Ansible.
Étape 9 — Surveillance minimale et logs
Une stack en production sans surveillance se transforme rapidement en boîte noire. On ajoute deux briques minimales avant de considérer le serveur comme terminé : la rotation des logs Nginx et un endpoint de santé qu’un service externe peut interroger.
La rotation des logs est gérée par défaut par logrotate sur Debian et Ubuntu via /etc/logrotate.d/nginx. On vérifie qu’elle est active :
ansible -i inventory/ role_web -m ansible.builtin.command \
-a 'logrotate -d /etc/logrotate.d/nginx'
La sortie affiche en mode dry-run ce que logrotate ferait. Si on voit rotating log /var/log/nginx/access.log, le mécanisme est en place. Pour une rétention plus longue ou une compression différente, on dépose un fichier files/logrotate-nginx.conf dans son rôle local et on l’installe via le module copy.
Le endpoint de santé est un fichier statique servi par Nginx que les superviseurs externes (UptimeRobot, Better Stack, monitoring interne) peuvent appeler. On l’ajoute dans extra_parameters du vhost :
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
Le access_log off; évite que les sondes saturent les logs avec des requêtes de santé qui n’apportent aucune information utile en cas d’incident.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
php-fpm.sock introuvable côté Nginx |
Mauvaise version de PHP référencée dans le vhost | Utiliser {{ php_version }} partout, pas la valeur en dur |
| Certbot échoue avec Unable to bind 0.0.0.0:80 | Nginx déjà démarré au moment de la demande standalone | Passer en certbot_create_method: webroot dès le second déploiement |
| MariaDB démarre puis s’arrête | Conflits avec MySQL résiduel | Sur Debian/Ubuntu fraîches le problème ne se pose pas ; sur images recyclées, désinstaller mysql-server avant le rôle |
| OPcache désactivé après déploiement | Variable php_opcache_enabled_in_ini non posée à true |
Vérifier dans php.ini via php -i | grep opcache.enable |
Page blanche sur index.php |
Permissions de {{ app_root }} incorrectes |
Le rôle Nginx tourne en www-data, le clone Git aussi : aligner les owner/group |
| Certificat émis mais Nginx sert le HTTP nu | Vhost HTTPS non créé | Ajouter un second vhost dans nginx_vhosts avec listen: 443 ssl et les chemins du certificat |
Tutoriels frères
Pour explorer plus loin
- Rôle Nginx de Jeff Geerling — variables et options détaillées
- Configuration OPcache PHP
- Documentation Certbot officielle
- 🔝 Retour à l’article principal du dossier Ansible
FAQ
Pourquoi Nginx + PHP-FPM plutôt qu’Apache ?
Sur les workloads applicatifs modernes, Nginx en frontal avec PHP-FPM en backend FastCGI consomme moins de mémoire et passe mieux à l’échelle qu’Apache + mod_php. Pour des sites statiques ou des PHP très simples, Apache reste équivalent. Pour Symfony, Laravel, WordPress sous trafic réel, Nginx domine.
Faut-il MariaDB ou MySQL ?
Les deux fonctionnent. MariaDB est le choix par défaut sur Debian et Ubuntu, mieux supporté par les paquets distribution, et son écosystème est entièrement open source. MySQL Community Edition reste une option, notamment si vous avez des contraintes de compatibilité avec Oracle Cloud ou des outils tiers qui ciblent strictement MySQL.
OPcache JIT en vaut-il la peine ?
Pour des frameworks PHP modernes oui — gain mesurable de 15 à 30 % sur le débit de requêtes Symfony et Laravel. Pour des CMS classiques (WordPress non optimisé), le gain est plus marginal mais reste positif. Le coût mémoire (100 Mo de buffer) est faible devant le bénéfice.
Comment déployer plusieurs sites sur le même serveur ?
On ajoute des entrées dans nginx_vhosts (un par domaine) et autant de répertoires dans le rôle app_deploy. PHP-FPM peut gérer un pool par site pour isoler les limits de CPU et de mémoire — la variable php_fpm_pools du rôle le permet directement.
Faut-il configurer un pare-feu en plus ?
Oui. Le pare-feu cloud (Hetzner, AWS) protège la couche réseau, mais un ufw ou nftables sur la machine ajoute la défense en profondeur. C’est l’objet du tutoriel suivant sur le durcissement Linux.
Combien de temps un certificat Let’s Encrypt reste-t-il valide ?
90 jours. Le rôle geerlingguy.certbot active le renouvellement automatique via systemd timer (certbot.timer). Vérifier avec systemctl list-timers | grep certbot.