Aller au contenu principal

Infrastructure · 15/10/2026 · 11 min de lecture

Ansible pour un petit SaaS : trois playbooks plutôt que Kubernetes

Photo d'Emmanuel BALLERY, fondateur de x10 Emmanuel BALLERY · Directeur Technique (CTO) freelance & Architecte logiciel
Un chef d'orchestre devant trois pupitres, l'une des partitions en ambre

Un SaaS qui démarre sur quelques serveurs suit souvent le même chemin. Au lancement, tout se fait à la main, et c'est un choix raisonnable : une seule personne, peu de machines, aucune raison d'outiller ce qu'on ne fait qu'une fois. Les serveurs sont montés en SSH, les déploiements suivent une procédure notée dans un fichier texte, et le .env.local de production n'existe qu'à un seul endroit : sur le serveur. Tout fonctionne, jusqu'au jour où il faut reconstruire une machine, changer de serveur web ou confier les déploiements à quelqu'un d'autre.

C'est le cas d'une application Symfony que je déploie et exploite depuis son premier jour. Tout faire à la main était le plus simple au départ. Avec plusieurs environnements et des serveurs à faire évoluer, ça ne l'était plus. La tentation, dans ce cas, est de tout porter d'un coup sur Kubernetes. J'ai fait un autre choix : décrire l'existant avec Ansible, en trois playbooks séparés et quelques garde-fous. Cet article présente ce découpage, les protections qui le rendent sûr à lancer, la gestion des secrets, et trois erreurs de permissions trouvées en relisant le tout.

Le bon outil pour la bonne taille

Kubernetes répond à des besoins réels : de nombreuses applications, des déploiements fréquents, une montée en charge horizontale, une équipe d'exploitation pour tenir le cluster. Avec une seule application et quelques serveurs, ces besoins n'existent pas encore. Le cluster devient alors le système le plus complexe à exploiter. J'ai décrit un cas où il se justifie dans un article sur le déploiement sans interruption sur OpenShift : une plateforme de plusieurs applications, pas un premier SaaS.

Un petit SaaS a des besoins plus modestes, et plus urgents :

  • pouvoir reconstruire un serveur — sans dépendre de la mémoire de la personne qui l'a monté
  • déployer de la même façon à chaque fois — et savoir, avant de lancer, ce qui va changer
  • avoir une copie des données ailleurs que sur le serveur — une sauvegarde stockée sur la machine qu'elle protège disparaît avec elle

Ansible coche ces trois cases sans agent à installer sur les serveurs : il se connecte en SSH et applique l'état décrit. Le choix de l'hébergement est un autre sujet, que je traite dans cloud ou on-premise pour une PME. Ici, les serveurs existaient déjà : il fallait les rendre reproductibles.

Trois playbooks, trois périmètres

Première décision : ne pas écrire un playbook unique qui « fait tout ». Un playbook qui installe les paquets, réécrit la configuration de nginx et déploie le code, on finit par ne plus oser le lancer. J'en ai écrit trois, chacun avec un périmètre et, surtout, une liste explicite de ce qu'il ne touche pas.

Playbook Périmètre Ne touche pas
site.yml le serveur : dépôts apt, paquets, nginx et ses sites, PHP-FPM, MySQL, fail2ban, relais SMTP, certificats, crons, journaux le code déployé, la configuration applicative, les bases
deploy.yml un environnement : .env.local, git pull, build front, Composer, cache, dump de la base, migrations la configuration système
backup.yml une copie hors serveur de chaque environnement : dump MySQL daté et copie rsync des données de public/ rien sur le serveur, hors un dump temporaire

Le découpage suit le rythme de chaque opération. site.yml tourne rarement : à l'installation d'un serveur, à une montée de version de PHP, à l'ajout d'un site. deploy.yml tourne à chaque livraison. backup.yml tourne avant toute opération risquée, et à intervalles réguliers le reste du temps. Avec un seul playbook, chaque déploiement risquerait de modifier le système.

site.yml est lui-même découpé en rôles, chacun avec son tag : system, php, mysql, nginx, fail2ban, mail, cron. On peut ainsi ne relancer que la partie concernée par un changement. L'ordre des rôles compte : le relais SMTP passe avant les crons. C'est lui qui fournit sendmail, par lequel partent les rapports des tâches planifiées. Sans lui, un cron qui plante ne prévient personne.

Des garde-fous avant d'appuyer sur Entrée

La répétition à blanc

Le premier garde-fou est natif : --check exécute le playbook sans rien modifier, et --diff montre chaque fichier tel qu'il serait réécrit. Je fais cette répétition avant chaque passage réel.

ansible-playbook site.yml --check --diff --limit web1
ansible-playbook site.yml --diff --limit web1

Deux limites à connaître. D'abord, les commandes à effet (certbot, npm ci…) sont simplement sautées en mode check : on ne voit pas ce qu'elles auraient fait. Ensuite, et c'est le point le plus important :

--diff affiche les secrets en clair.

Un fichier de configuration qui contient un mot de passe, comme celui du relais SMTP ou le .env.local, apparaît dans le diff avec sa valeur. La sortie d'une répétition à blanc ne se colle donc ni dans un ticket, ni dans une messagerie, ni dans les logs d'un job CI. Ansible propose no_log et diff: false sur une tâche. Encore faut-il penser à les ajouter, et on ne peut plus alors relire les changements du fichier concerné.

Les tâches qui peuvent couper l'accès

Deux réglages peuvent fermer la porte derrière soi : SSH en authentification par clé seule, et le pare-feu. Une erreur sur l'un ou l'autre, et le serveur n'est plus joignable que par la console de l'hébergeur. Ces tâches portent le tag spécial never : elles ne s'exécutent jamais, sauf si on demande explicitement leur autre tag.

- name: Key-only SSH
  ansible.builtin.copy:
    dest: /etc/ssh/sshd_config.d/50-app.conf
    mode: "0644"
    content: |
      PasswordAuthentication no
      PermitRootLogin no
  notify: Reload sshd
  tags: [access, never]

- name: SSH allowed through the firewall
  community.general.ufw:
    rule: allow
    name: OpenSSH
  tags: [access, never]

- name: Firewall enabled
  community.general.ufw:
    state: enabled
    policy: deny
    direction: incoming
  tags: [access, never]

Un ansible-playbook site.yml ordinaire ne les touche jamais. Il faut écrire --tags access, c'est-à-dire décider de prendre ce risque. De préférence avec une seconde session SSH ouverte, pour pouvoir revenir en arrière.

Un seul site à la fois

Le rôle nginx gère tous les sites d'un serveur : un site retiré de la liste disparaît du serveur. C'est logique quand on décrit un état, mais c'est beaucoup d'impact pour ajouter un sous-domaine. Une variable only_site limite le rôle à un seul site et à son certificat :

ansible-playbook site.yml --check --diff --limit web1 --tags nginx -e only_site=staging.example.com

Dans ce mode, les autres sites ne sont ni relus ni désactivés. Le rôle vérifie aussi que le site demandé existe : une faute de frappe échoue tout de suite, au lieu de ne rien faire en silence.

Le déploiement refuse un serveur modifié

deploy.yml commence par des vérifications, exécutées même en mode check : git fetch, état de l'arbre de travail, commits à déployer, migrations en attente. Si quelqu'un a modifié un fichier directement sur le serveur, le déploiement s'arrête et liste les fichiers concernés.

- name: Working tree
  ansible.builtin.command: git status --porcelain
  args:
    chdir: "{{ env.path }}"
  register: tree
  changed_when: false
  check_mode: false

- name: Working tree is clean
  ansible.builtin.assert:
    that: tree.stdout | trim == ''
    fail_msg: "Local changes in {{ env.path }}:\n{{ tree.stdout }}"

Un correctif fait à chaud sur le serveur ne fait donc plus échouer un git pull à mi-chemin : il est signalé avant, et on décide quoi en faire. Le playbook s'arrête aussi s'il n'y a aucun commit à déployer, sauf si on force le déploiement.

Un dump avant chaque migration

Les migrations sont la seule étape du déploiement qui touche aux données, et un retour arrière ne s'improvise pas. Un lot de migrations en attente contenait d'ailleurs un DROP TABLE. Le playbook fait donc un dump de la base juste avant de les jouer, une fois le build réussi : inutile de sauvegarder pour un déploiement qui va échouer sur npm run build. Ce dump est pris :

  • sans mot de passe à manipuler — sur Debian et Ubuntu, les identifiants du compte de maintenance sont dans /etc/mysql/debian.cnf, lisible par root seul, et mysqldump --defaults-file les utilise directement
  • sans bloquer le site — --single-transaction ne verrouille pas les tables InnoDB
  • hors de public/ — un dump placé dans le répertoire servi par nginx serait téléchargeable par n'importe qui ; il est écrit dans un répertoire en 0700 qui appartient à root
  • avec une rotation — les fichiers sont horodatés, et seuls les plus récents sont conservés

Les secrets : un coffre dont la clé ne vit ni dans git ni dans les conteneurs

Le .env.local de chaque environnement était tenu à la main sur le serveur. deploy.yml le génère désormais à partir de deux sources : les valeurs non sensibles, en clair dans les variables du dépôt, et les secrets, dans un fichier chiffré par ansible-vault.

Un coffre chiffré n'est pas plus sûr que sa clé. Trois règles ici :

  • hors de git — le mot de passe du coffre est dans un répertoire ignoré par git, en mode 600, désigné par vault_password_file dans ansible.cfg
  • hors des conteneurs — l'environnement de développement monte tout le dépôt dans ses conteneurs. Un volume anonyme masque ce répertoire : sans lui, le script postinstall de n'importe quelle dépendance npm pourrait lire la clé
  • ailleurs que sur un seul poste — si le mot de passe n'existe qu'à un seul endroit, sa perte rend le coffre illisible, et le secret n'a fait que changer de place : une copie unique sur le serveur remplacée par une copie unique sur un portable
services:
  php:
    volumes:
      - .:/app
      # Anonymous volume: hides the vault password that the bind mount above would expose.
      - /app/private
  node:
    volumes:
      - .:/app
      - /app/private

Le fichier généré a ses propres pièges. Dès que .env.local.php existe, Symfony ne lit plus aucun fichier .env : il charge ce fichier, compilé par composer symfony:dump-env prod. Sans recompilation, une valeur modifiée n'est jamais prise en compte. Le playbook déclenche donc cette compilation par un handler, exécuté tout de suite : un simple changement de configuration s'applique ainsi même sans nouveau code à déployer. Les valeurs sont écrites entre apostrophes simples, où le composant Dotenv lit \ et $ tels quels. Une valeur qui contient elle-même une apostrophe est refusée avant l'écriture, plutôt que déformée.

Dernier point, qui ne dépend pas d'Ansible : en rapatriant les secrets dans le coffre, certaines valeurs doivent être recopiées telles quelles, pas régénérées. C'est le cas d'APP_SECRET quand l'application s'en sert pour chiffrer des données en base : une nouvelle valeur rendrait ces données illisibles pour de bon. Avant de « nettoyer » un secret, il faut savoir ce qu'il protège.

Trois erreurs trouvées en relisant les permissions

Une fois l'outillage en place, j'ai relu chaque endroit où un secret ou une donnée est écrit sur le disque, avec toujours la même question : qui peut lire ce fichier, et pendant combien de temps ? Trois réponses étaient mauvaises.

Un mot de passe lisible par tous dans /tmp

La commande de sauvegarde interne de l'application écrivait un fichier d'options pour mysqldump, avec le mot de passe de la base, dans le répertoire temporaire du système. Elle lui donnait en plus explicitement le mode 0644. Or /tmp est accessible à tous les comptes de la machine, et le fichier y restait pendant tout le dump, soit plusieurs minutes en production. La correction tient en deux chiffres : 0600. Passer le mot de passe par un fichier plutôt qu'en argument était le bon réflexe, car un argument se lit avec ps. Encore faut-il que le fichier soit mieux protégé que l'argument.

Une copie du .env.local à chaque déploiement

Le module template d'Ansible propose l'option backup: true, qui garde une copie horodatée du fichier avant de le réécrire. L'intention était bonne. Mais la copie est posée à côté du fichier, à la racine du projet. Elle s'accumule à chaque déploiement, et garde les permissions du fichier d'origine. L'ancien .env.local, écrit à la main, était lisible par tous : ses copies aussi. Pour un fichier de secrets, l'historique est déjà dans le coffre ; l'option a été retirée, et le fichier généré est désormais écrit en 0640.

Une sauvegarde qui abandonnait au bout de 60 secondes

La troisième erreur n'est pas une fuite, mais une sauvegarde qui n'existait pas. La même commande interne lançait zip et mysqldump avec le composant Process de Symfony, dont le délai par défaut est de 60 secondes. Les fichiers déposés par les utilisateurs dépassaient le gigaoctet : la compression était interrompue. Il restait un répertoire daté à moitié rempli, sans archive finale, et aucun mail de compte rendu n'était envoyé. Or un mail qui n'arrive pas ne déclenche aucune alerte. Un délai explicite d'une heure sur chaque processus a réglé le problème.

Ces trois points rejoignent les bases de la sécurisation d'une application en production : les fuites les plus courantes ne demandent pas d'attaquant, seulement un fichier trop ouvert au mauvais endroit. Le dernier justifie aussi backup.yml : une sauvegarde qui quitte le serveur, et dont on voit le résultat.

Conclusion

Un petit SaaS n'a pas besoin d'une plateforme d'orchestration pour être bien exploité. Il a besoin d'un serveur décrit, d'un déploiement répétable et prudent, et de données copiées ailleurs. Trois playbooks séparés, une répétition à blanc systématique, des tâches dangereuses impossibles à lancer par accident, un déploiement qui refuse un serveur modifié et sauvegarde avant de migrer : c'est peu de code, et c'est ce qui permet de confier l'exploitation à quelqu'un d'autre. La relecture des permissions, elle, ne s'automatise pas : elle se fait une fois, attentivement, fichier par fichier.

Message clé

Pour un petit SaaS, la fiabilité tient moins à l'outil qu'aux garde-fous : un périmètre par playbook, une répétition avant chaque passage, une sauvegarde qui quitte le serveur.

Vos serveurs sont montés à la main et vos déploiements dépendent d'une seule personne ? Un audit technique fait l'inventaire de l'existant et priorise ce qu'il faut rendre reproductible. Parlons-en.

Questions fréquentes

Ce qu'on me demande souvent.

Faut-il Kubernetes pour héberger un petit SaaS ?

Rarement. Kubernetes répond à des besoins de plateforme : nombreuses applications, déploiements fréquents, montée en charge horizontale, équipe pour opérer le cluster. Pour une application sur quelques serveurs, les besoins urgents sont plus simples : pouvoir reconstruire un serveur, déployer de façon répétable et sauvegarder hors de la machine. Ansible y répond sans rien installer sur les serveurs.

Pourquoi séparer les playbooks Ansible par périmètre ?

Parce qu'un playbook qui fait tout devient un playbook qu'on n'ose plus lancer. Séparer le système, le déploiement d'un environnement et la sauvegarde permet de lancer chacun à son rythme, et garantit qu'un déploiement de code ne modifie jamais la configuration du serveur. Chaque playbook documente aussi ce qu'il ne touche pas.

Le mode --check d'Ansible est-il sans risque ?

Il ne modifie rien sur les serveurs, mais combiné à --diff il affiche le contenu des fichiers tel qu'il serait réécrit, secrets compris : mot de passe d'un relais SMTP, valeurs d'un .env.local. Sa sortie ne doit pas être partagée telle quelle. Les commandes à effet sont par ailleurs sautées en mode check, si bien qu'on ne voit pas ce qu'elles auraient fait.

Comment protéger la clé d'un coffre ansible-vault ?

En la gardant hors du dépôt git, dans un fichier en mode 600 désigné par vault_password_file, en la masquant dans les conteneurs de développement qui montent le dépôt, par exemple avec un volume anonyme, et en la conservant ailleurs que sur un seul poste, faute de quoi le coffre devient lui-même une copie unique.

Contact

Un projet ou une question sur ce sujet ?

Pas de discours commercial, pas de jargon inutile : un échange clair sur vos besoins et sur la manière dont je peux y répondre.

Démarrer votre projet arrow_forward