ITSkillsCenter
Blog

Provisionner un serveur LEMP avec Ansible pas à pas

13 min de lecture

📍 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

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.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité