SSD Nodes Learn
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-07-19

Tutoriel Ansible : premier playbook sur un VPS

Installez Ansible avec pipx sur Ubuntu 24.04, écrivez un inventaire et un premier playbook qui sécurise un VPS, plus les correctifs Permission denied et sudo.

Ce que vous allez construire

Une machine de contrôle avec Ansible installé, et un ou plusieurs VPS Ubuntu 24.04 tout neufs, sans rien d'autre que l'image de base. À la fin, vous aurez un fichier d'inventaire qui nomme vos serveurs, un ping ad hoc qui prouve que l'authentification fonctionne de bout en bout, et un playbook qui exécute toute la checklist d'un nouveau VPS sous forme de code : un utilisateur deploy avec votre clé SSH, un sshd durci, fail2ban, les mises à jour automatiques et un pare-feu qui autorise OpenSSH avant de tout refuser. Pointez-le vers un serveur ou vingt. Lancez-le deux fois : la seconde exécution ne change rien, et c'est tout l'intérêt.

Après quinze ans à provisionner des VPS, je peux vous décrire le schéma honnête : tout le monde configure les cinq premiers serveurs à la main, puis perd un week-end sur le sixième parce que personne ne se souvient de ce qui a été fait sur les cinq premiers. Ce guide approfondit le tour d'horizon de la gestion de plusieurs serveurs Linux ; ouvrez-le le jour où vous vous surprenez à taper le même apt install dans trois terminaux.

Ce qu'est vraiment Ansible, en un paragraphe

Ansible est sans agent. Il n'y a aucun démon à installer sur les serveurs qu'il gère : la machine de contrôle se connecte via un SSH ordinaire, copie un petit module Python sur la cible, l'exécute, lit le JSON qu'il affiche, puis le supprime. La seule chose dont une cible a besoin, c'est python3, que toute image Ubuntu de base possède déjà. Le mot qui compte est idempotent, et il veut dire quelque chose de simple : une tâche décrit un état, pas une action. state: present pour un paquet signifie « assure-toi que c'est installé », pas « lance l'installeur ». Si l'état est déjà atteint, Ansible ne touche à rien et le signale comme ok au lieu de changed. Cette propriété est tout le produit : c'est elle qui rend sûre la réexécution d'un playbook, et les réexécutions sûres sont ce qui transforme un script shell en infrastructure.

Prérequis, et les pièges à connaître d'emblée

  • Une machine de contrôle : votre ordinateur portable ou un petit VPS. Je suppose Ubuntu 24.04 ; macOS fonctionne de manière identique une fois pipx installé depuis Homebrew.
  • Un ou plusieurs VPS cibles sous Ubuntu 24.04 sur KVM, accessibles en root. Rien n'y est installé.
  • Une authentification SSH par clé vers chaque cible. Ansible est authentifié exactement comme votre commande ssh : si ssh root@host demande un mot de passe, Ansible échoue.
  • Sur Ubuntu 24.04, pip install ansible échoue avec error: externally-managed-environment. C'est une politique délibérée de la distribution, pas un bug. Utilisez pipx.
  • Les espaces en YAML font partie de la syntaxe. Une mauvaise indentation produit mapping values are not allowed in this context, et un caractère de tabulation où que ce soit est fatal.
  • Gardez une session SSH ouverte et fonctionnelle sur chaque cible pendant que le playbook durcit sshd. Chaque verrouillage que j'ai aidé un client à récupérer venait de la fermeture de la dernière session « pour tester au propre ».

Étape 1 : installez Ansible sur la machine de contrôle avec pipx, pas pip

Le réflexe classique est pip3 install ansible. Sur une image 24.04 vraiment neuve, cela échoue une étape plus tôt (Command 'pip3' not found, but can be installed with: sudo apt install python3-pip), et installer pip ne fait que vous conduire au vrai mur :

pip3 install ansible
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

Ubuntu 24.04 marque le Python système comme géré en externe (PEP 668) pour que pip n'entre pas en conflit avec apt sur les mêmes fichiers. N'utilisez pas --break-system-packages ; le nom du drapeau est honnête. La réponse propre est pipx, qui donne à Ansible son propre environnement virtuel isolé et place les binaires sur votre PATH :

sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible

Ouvrez un nouveau shell après pipx ensurepath pour que le changement de PATH prenne effet. --include-deps n'est pas décoratif : le paquet ansible ne fournit aucun script console propre. ansible, ansible-playbook et les autres sont des points d'entrée de sa dépendance ansible-core ; sans ce drapeau, pipx refuse donc l'installation avec No apps associated with package ansible or its dependencies. Et installez le paquet ansible, pas seulement ansible-core : le paquet complet regroupe les collections communautaires, et ce playbook utilise des modules de deux d'entre elles (ansible.posix et community.general).

ansible --version

Le résultat correct commence par une ligne du type ansible [core 2.19.x] et nomme le Python sous lequel il s'exécute ; n'importe quelle version récente de core convient pour tout ce qui suit. ansible: command not found signifie au contraire que ~/.local/bin n'est pas encore sur votre PATH : nouveau shell, ou source ~/.bashrc.

C'est toute l'installation. Les cibles ne reçoivent rien.

Étape 2 : accès par clé SSH à chaque cible

ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20

Ensuite, prouvez-le, une fois par hôte :

ssh root@10.0.0.10 true && echo ok

Cette seule ligne fait deux choses : elle confirme que l'authentification par clé fonctionne sans mot de passe, et elle enregistre la clé de l'hôte dans known_hosts. Faites-le maintenant, car Ansible présente une clé d'hôte non enregistrée sous la forme d'une invite interactive enfouie au milieu d'une exécution, ce qui ressemble exactement à un blocage.

Étape 3 : l'inventaire, INI d'abord, YAML quand il grandit

L'inventaire est un fichier texte qui liste les machines qu'Ansible peut toucher. Créez inventory.ini dans un nouveau répertoire de projet :

[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20

[vps:vars]
ansible_user=root

web1 est un alias que vous choisissez : c'est ce qui apparaît dans la sortie et ce que vous ciblez avec --limit web1. ansible_host est l'adresse réelle. [vps] est un groupe, et [vps:vars] définit des variables pour chaque hôte du groupe ; ansible_user est le compte sous lequel Ansible se connecte. À côté, un ansible.cfg pour ne plus jamais taper -i :

[defaults]
inventory = inventory.ini

Ansible lit ansible.cfg depuis le répertoire courant. Le même inventaire en YAML, enregistré sous inventory.yml puis référencé par ce nom dans ansible.cfg, est celui que vous préférerez une fois que chaque hôte portera plusieurs variables :

vps:
  hosts:
    web1:
      ansible_host: 10.0.0.10
    web2:
      ansible_host: 10.0.0.20
  vars:
    ansible_user: root

Ils sont équivalents. Le format INI est plus facile à lire d'un coup d'œil avec deux serveurs ; le YAML passe mieux à l'échelle avec vingt. Choisissez-en un et n'y pensez plus.

Étape 4 : les commandes ad hoc, le « pong » vert qui prouve tout

ansible all -m ping

Ce n'est pas de l'ICMP. Le module ping est une répétition générale complète : connexion SSH, copie du module, exécution de Python sur la cible, nettoyage. Le résultat correct est vert, un bloc par hôte :

web1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Un SUCCESS vert signifie que l'authentification, l'interpréteur Python et le transport fonctionnent tous, et le playbook fonctionnera donc aussi. Un UNREACHABLE! rouge signifie que le transport a échoué avant l'exécution du moindre module ; la chaîne exacte et le correctif se trouvent dans la section des modes d'échec plus bas. Deux autres commandes ad hoc à connaître :

ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --become

L'ad hoc sert aux actions ponctuelles et aux vérifications. Tout ce que vous exécuteriez deux fois a sa place dans un playbook.

Étape 5 : le premier playbook, la checklist du nouveau VPS en code

C'est tout ce que vous feriez à la main dans les dix premières minutes sur un nouveau serveur. Enregistrez-le sous site.yml :

---
- name: Baseline a fresh Ubuntu VPS
  hosts: vps
  become: true

  vars:
    deploy_user: deploy
    deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    baseline_packages:
      - fail2ban
      - unattended-upgrades
      - ufw
    baseline_services:
      - fail2ban
      - unattended-upgrades

  tasks:
    - name: Create the deploy user
      ansible.builtin.user:
        name: "{{ deploy_user }}"
        groups: sudo
        append: true
        shell: /bin/bash

    - name: Install the deploy user's SSH key
      ansible.posix.authorized_key:
        user: "{{ deploy_user }}"
        key: "{{ deploy_pubkey }}"

    - name: Passwordless sudo for the deploy user
      ansible.builtin.copy:
        dest: /etc/sudoers.d/deploy
        content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
        mode: "0440"
        validate: /usr/sbin/visudo -cf %s

    - name: Install baseline packages
      ansible.builtin.apt:
        name: "{{ baseline_packages }}"
        state: present
        update_cache: true

    - name: Enable and start baseline services
      ansible.builtin.service:
        name: "{{ item }}"
        state: started
        enabled: true
      loop: "{{ baseline_services }}"

    - name: Harden sshd with a drop-in
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        content: |
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PermitRootLogin prohibit-password
          X11Forwarding no
        mode: "0644"
        validate: /usr/sbin/sshd -t -f %s
      notify: Restart ssh

    - name: Allow OpenSSH through ufw
      community.general.ufw:
        rule: allow
        name: OpenSSH

    - name: Enable ufw with default deny
      community.general.ufw:
        state: enabled
        policy: deny

  handlers:
    - name: Restart ssh
      ansible.builtin.service:
        name: ssh
        state: restarted

Les lignes qui méritent d'être comprises plutôt que copiées :

Les variables vivent sous vars: et sont référencées avec "{{ deploy_user }}" : mettez toute l'expression entre guillemets quand une valeur commence par une accolade, sinon l'analyseur YAML la lit de travers. Le lookup('file', ...) lit votre clé publique sur la machine de contrôle au moment de l'exécution, si bien que le playbook ne transporte aucun élément de clé.

La boucle. loop: "{{ baseline_services }}" exécute la tâche de service une fois par élément, et la sortie montre chaque élément sur sa propre ligne. Notez que la tâche apt prend au contraire toute la liste de paquets d'un coup : une seule transaction apt est plus rapide et c'est le schéma préféré pour les paquets ; les boucles sont pour les modules qui agissent vraiment sur une seule chose à la fois.

Le handler est le concept à intégrer. notify: Restart ssh ne veut pas dire « redémarre ssh maintenant ». Il met le handler en file d'attente, lequel s'exécute une seule fois à la fin du play, et seulement si la tâche qui l'a notifié a réellement signalé changed. Relancez le playbook demain : le fichier drop-in est déjà correct, la tâche copy signale ok, et sshd n'est jamais redémarré. La ligne validate: est la sécurité sur la gâchette : sshd vérifie le fichier avant de remplacer l'ancien, donc une faute de frappe fait échouer la tâche au lieu de casser le démon.

PermitRootLogin prohibit-password, pas no, et c'est délibéré. Ce playbook se connecte en root avec une clé. prohibit-password coupe les connexions root par mot de passe tout en gardant la vôtre active. Une fois l'utilisateur deploy éprouvé (ssh deploy@10.0.0.10 sudo true, l'adresse brute, puisque web1 est un alias que seul Ansible connaît), passez ansible_user=deploy dans l'inventaire et resserrez à no lors d'une exécution ultérieure. Durcissez dans un ordre qui ne peut pas vous laisser bloqué.

Le préfixe 00- compte. Pour la plupart des mots-clés, sshd retient la première occurrence qu'il analyse, et le sshd_config d'Ubuntu inclut sshd_config.d/*.conf dans l'ordre lexical avant son propre corps. Les images cloud d'Ubuntu 24.04 livrent déjà un 60-cloudimg-settings.conf dans ce répertoire, et les hébergeurs qui activent les connexions par mot de passe via cloud-init ajoutent un 50-cloud-init.conf avec PasswordAuthentication yes ; nommer le nôtre 00-hardening.conf le fait trier en premier et l'emporter sur les deux.

L'ordre des tâches est la sécurité du pare-feu. Allow OpenSSH s'exécute avant Enable ufw avec une politique de refus : Ansible exécute les tâches strictement dans l'ordre listé, donc l'ouverture existe avant que le mur ne se dresse. fail2ban n'a besoin d'aucune configuration pour être utile ici ; ses réglages par défaut sur Ubuntu surveillent sshd d'emblée, et ce que font réellement les prisons (jails), ainsi que ce qu'il faut régler, est traité dans le guide fail2ban sur Ubuntu 24.04.

Étape 6 : essai à blanc avec --check, puis exécution réelle

ansible-playbook site.yml --check

Le mode check se connecte, calcule ce qu'il ferait et ne change rien. Lisez le total changed= dans le PLAY RECAP en bas : c'est le nombre de tâches qui modifieraient chaque hôte. Une réserve honnête : le mode check a une limite structurelle partout où une tâche ultérieure dépend des changements d'une tâche antérieure. L'image serveur standard d'Ubuntu livre ufw d'avance, donc ce playbook s'essaie à blanc proprement ; mais sur une image minimale qui ne l'a pas, les tâches ufw échouent en mode check, parce que le mode check n'a jamais réellement installé le paquet et que le module n'a alors rien à appeler. C'est une limite des essais à blanc, pas un bug de votre playbook. Quand le plan a l'air bon :

ansible-playbook site.yml

Chaque tâche affiche une ligne par hôte, changed en jaune, ok en vert, et le récapitulatif devrait se lire :

PLAY RECAP *********************************************************************
web1 : ok=10  changed=9  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
web2 : ok=10  changed=9  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Les dix ok correspondent à la collecte des faits, plus huit tâches, plus le handler. Votre changed peut différer du mien d'une ou deux unités : l'image standard d'Ubuntu livre déjà ufw et unattended-upgrades, et fail2ban se démarre lui-même dès qu'apt l'installe, si bien qu'une tâche peut légitimement signaler ok dès sa toute première exécution, l'état qu'elle déclare étant déjà atteint. Les nombres qui doivent être à zéro sont unreachable et failed. Une note sur become: true : c'est une formalité tant que vous vous connectez en root, mais dès que vous basculez ansible_user sur deploy, sudo devient réel, et le fichier sudoers NOPASSWD que ce playbook installe est justement ce qui garde -K hors de votre ligne de commande. Sans lui, vous obtenez Missing sudo password, traité plus bas.

Étape 7 : lancez-le deux fois, à quoi ressemble l'idempotence

Relancez immédiatement la même commande :

web1 : ok=9  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

changed=0, et ok a baissé d'une unité parce que le handler non notifié n'a jamais tourné. Rien n'a été réinstallé, sshd n'a pas été redémarré, ufw n'a pas été touché. C'est ce qui fait du playbook un audit autant qu'un outil de provisionnement : ajoutez web3 à l'inventaire le mois prochain et relancez ; la nouvelle machine est construite, les anciennes sont vérifiées. Un changed non nul sur une machine à laquelle vous n'avez pas touché est une dérive, et cela vous dit que quelqu'un a modifié à la main ce qui aurait dû l'être dans le playbook.

À partir de là, le schéma se cumule. Le prochain playbook à écrire pose un VPN WireGuard sur le même VPS et resserre la règle ufw pour que SSH ne réponde que sur le tunnel ; ensuite, un autre qui installe Docker et Compose sur chaque serveur applicatif. Quand site.yml dépasse trois écrans, découpez-le en rôles, mais pas avant.

Modes d'échec, avec les messages que vous verrez

UNREACHABLE avec Permission denied.

web1 | UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: root@10.0.0.10: Permission denied (publickey).",
    "unreachable": true
}

Le transport SSH a échoué avant l'exécution du moindre module : ansible_user est incorrect, la clé n'a jamais été copiée sur cet hôte, ou la mauvaise clé est présentée. Reproduisez avec un simple ssh root@10.0.0.10, puis ssh -v pour voir quelles clés ont été présentées. Si le SSH par mot de passe fonctionne mais pas Ansible, vous avez sauté ssh-copy-id.

Missing sudo password.

web1 | FAILED! => {
    "msg": "Missing sudo password"
}

Vous avez mis become: true, vous vous êtes connecté avec un utilisateur non-root, et cet utilisateur a besoin d'un mot de passe pour sudo. Soit ajoutez -K (--ask-become-pass) à la ligne de commande, soit donnez à l'utilisateur une entrée sudoers NOPASSWD, ce qui est précisément la raison pour laquelle le playbook en installe une pour deploy avant même que vous ne basculiez dessus.

error: externally-managed-environment. Vous avez lancé pip contre le Python système sur Ubuntu 24.04. Traité à l'étape 1 : pipx, pas pip, et surtout pas --break-system-packages.

mapping values are not allowed in this context.

ERROR! Syntax Error while loading YAML.
  mapping values are not allowed in this context

Presque toujours une histoire d'indentation : une clé à la mauvaise profondeur, ou une espace manquante après un deux-points. Le numéro de ligne indiqué pointe près de l'erreur, pas dessus ; vérifiez aussi la ligne au-dessus. Son cousin found character '\t' that cannot start any token signifie qu'une tabulation s'est glissée là ; YAML les interdit. Faites de ansible-playbook site.yml --syntax-check un réflexe avant chaque exécution, et réglez votre éditeur sur une indentation de deux espaces pour le YAML.

/usr/bin/python3: not found. Rare sur les images Ubuntu 24.04 standard, fréquent sur les images minimales ou netboot : l'exécution du module échoue parce que la cible n'a pas de Python. Amorcez-le avec le module raw, le seul module qui n'a besoin de rien en face : ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, puis relancez le playbook.

FAQ

Faut-il installer Ansible sur les serveurs qu'il gère ?

Non. Ansible est sans agent : la machine de contrôle pousse de petits modules Python via SSH, les exécute, puis les supprime. Une cible n'a besoin que de python3 et d'un accès SSH, deux choses que les images Ubuntu de base ont déjà. La seule installation de tout ce guide se fait sur votre machine de contrôle.

Pourquoi Ansible affiche-t-il « Permission denied (publickey) » ?

Le bloc UNREACHABLE! avec Permission denied (publickey) signifie que l'authentification SSH a échoué avant qu'Ansible n'exécute quoi que ce soit. Vérifiez qu'ansible_user dans l'inventaire correspond au compte que vous avez réellement configuré, que vous avez lancé ssh-copy-id vers cet hôte, et qu'un simple ssh user@host se connecte sans mot de passe. Ce qui répare la commande ssh simple répare Ansible, car c'est le même transport.

Que signifie idempotent dans Ansible ?

Une tâche déclare un état souhaité (« ce paquet est présent », « cette ligne est dans ce fichier ») plutôt qu'une action à réaliser. Si l'état est déjà atteint, Ansible ne fait rien et signale ok au lieu de changed. C'est pourquoi lancer un playbook deux fois affiche changed=0 la seconde fois, et pourquoi une réexécution est un audit sûr plutôt qu'une réinstallation risquée.

Faut-il utiliser pip ou pipx pour installer Ansible sur Ubuntu 24.04 ?

pipx. Ubuntu 24.04 marque le Python système comme géré en externe, donc pip install ansible échoue avec error: externally-managed-environment par conception. pipx install --include-deps ansible place Ansible dans un environnement virtuel isolé et expose proprement ansible, ansible-playbook et les autres sur votre PATH.

Quelle est la différence entre les paquets ansible et ansible-core ?

ansible-core est le moteur plus les seuls modules ansible.builtin. Le paquet ansible regroupe core avec les collections communautaires sélectionnées, dont ansible.posix (le module authorized_key) et community.general (le module ufw), toutes deux utilisées dans ce guide. Commencez par le paquet complet ; réduisez à core plus des collections choisies à la main seulement quand vous aurez une raison de le faire.