Projet Nébuleuse
Nébuleuse est un hébergeur cloud européen fictif qui propose des packs d'applications auto-hébergées : stockage de fichiers, photos, gestionnaire de mots de passe, le tout derrière une authentification unique. Chaque client dispose d'une machine virtuelle dédiée, configurée et maintenue par Ansible depuis un serveur central.
- Présentation du projet Nébuleuse
- Mise en place du serveur Ansible
- Inventaire et variables du client
- Articulation des services
- Socle système et Docker Engine
Présentation du projet Nébuleuse
Projet fictif. Nébuleuse n'existe pas : le nom, le domaine (nebuleuse.example), les adresses IP (plages de documentation 192.0.2.0/24 et 10.99.0.0/24) et tous les secrets sont inventés. Ils servent de support à un TP complet, inspiré d'un déploiement réel.
Nébuleuse, c'est quoi ?
Nébuleuse est un hébergeur cloud européen fictif qui propose des applications auto-hébergées commandées à la carte, sur le modèle des hébergeurs en ligne. Le client choisit ses applications sur un formulaire web, indique son nom de domaine, et reçoit une machine virtuelle dédiée, configurée et maintenue par Ansible depuis un serveur central.
| Composant | Rôle |
|---|---|
| Formulaire de commande | Recueille le client, son domaine et la liste des applications choisies |
| Serveur Ansible | Nœud de contrôle : héberge le dépôt, le Vault et lance les playbooks |
| Bastion Teleport | Point d'entrée unique des administrateurs vers les VM, avec authentification forte et journal des sessions |
| Réseau d'administration | Réseau privé 10.99.0.0/24, réservé à l'exploitation |
| VM client | Nœud géré : Debian 12 livrée avec une clé SSH, reçoit le socle puis les applications commandées |
Commande web → Fichier de variables du client → Serveur Ansible → VM dédiée → Docker Compose → Traefik → Application
Concrètement, le formulaire de commande et la création de la VM (provisioning) sont hors périmètre de cette documentation. Le formulaire produit un fichier de variables, et Ansible reçoit une machine Debian 12 vierge, accessible en SSH, dont il s'occupe de tout le reste.
Le catalogue
Le catalogue de Nébuleuse regroupe l'ensemble des applications que l'hébergeur sait déployer. Le client en choisit autant qu'il le souhaite : chaque application est un rôle Ansible indépendant, activé par une liste dans les variables du client.
| Application | Usage | Documentée dans ce TP |
|---|---|---|
| Nextcloud | Fichiers, agenda, contacts | Oui |
| Immich | Photos et vidéos | Oui |
| Vaultwarden | Gestionnaire de mots de passe | Oui |
| Jellyfin | Médiathèque | Non |
| PaperMerge | Gestion électronique de documents | Non |
| Home Assistant | Domotique | Non |
| Matrix (Element) | Messagerie | Non |
Note — Pour garder une documentation lisible, seules les applications de base sont détaillées ici. Les autres suivent exactement le même modèle de rôle : un dossier de templates, un enregistrement dans le SSO, une règle Traefik.
Le socle commun
Quelle que soit la commande, chaque VM reçoit le même socle. Il n'est pas facturé comme une application : il fait partie du service.
| Brique | Rôle |
|---|---|
| Durcissement et Docker | Système à jour, pare-feu, moteur de conteneurs |
| Traefik | Publie les applications en HTTPS |
| Authentik | Authentification unique (SSO) de toutes les applications |
| Fail2ban | Bloque les adresses qui multiplient les échecs de connexion |
| Agent Teleport | Rattache la VM au bastion pour l'administration |
Une VM par client : pourquoi ?
Deux architectures sont possibles pour héberger plusieurs clients. En multi-locataire, une seule installation sert tous les clients, ce qui est économique mais mélange les données et les pannes. En mono-locataire, chaque client a sa propre machine, ce qui coûte plus cher mais isole les données, les secrets et les incidents. Nébuleuse retient la seconde : une compromission ou une erreur de configuration chez un client ne peut pas toucher les autres, et la restauration d'un client est indépendante.
Le bastion
Les humains et l'automatisation n'utilisent pas le même chemin. L'automatisation (Ansible) se connecte par SSH depuis le serveur Ansible, dans le réseau d'administration. Les administrateurs, eux, passent par un bastion Teleport : une porte unique, protégée par une authentification forte, qui enregistre chaque session. Aucune VM n'accepte donc de connexion SSH depuis Internet, et toute intervention manuelle laisse une trace.
L'installation de Teleport et l'enrôlement d'une ressource sont détaillés dans le livre Teleport de cette documentation (pages « Installation » et « Enroler des resources »). Cette documentation ne reprend que la partie propre à Nébuleuse : le déploiement de l'agent par Ansible.
Objectifs et contraintes
- Reproductibilité : un nouveau client est déployé à partir d'une VM vierge avec une seule commande, hors saisie des variables.
- Commande à la carte : la liste des applications est une simple variable, sans modifier le code.
- Isolation : chaque client a ses secrets, son domaine et son fichier de variables chiffré.
- Aucun secret en clair dans le dépôt : tout passe par Ansible Vault.
- Administration tracée : SSH n'écoute que sur le réseau d'administration, et les interventions humaines passent par le bastion.
- Idempotence : rejouer le déploiement sur un client à jour ne modifie rien.
Adressage et nommage
| Machine | Nom | Adresse d'administration | Adresse publique |
|---|---|---|---|
| Serveur Ansible | ansible |
10.99.0.2 |
— |
| Bastion Teleport | bastion |
10.99.0.3 |
192.0.2.5 |
| Premier client | client-alpha |
10.99.0.21 |
192.0.2.21 |
| Second client | client-beta |
10.99.0.22 |
192.0.2.22 |
Chaque client possède son domaine (alpha.nebuleuse.example) et des sous-domaines par service : sso., cloud., photos., vault.. Les conteneurs sont nommés <client>_<service>, les jails Fail2ban nebuleuse-<service>, et les variables de rôle sont préfixées par le nom du rôle (traefik_, authentik_).
Structure du dépôt
nebuleuse/
├── ansible.cfg
├── requirements.txt
├── requirements.yml
├── site.yml
├── inventory/
│ ├── hosts.yml
│ ├── group_vars/all/
│ └── host_vars/client-alpha/
└── roles/
├── base-hardening/
├── docker-engine/
├── teleport-agent/
├── traefik/
├── authentik/
├── fail2ban/
├── nextcloud/
├── immich/
└── vaultwarden/
Comment lire cette documentation
Les pages se lisent dans l'ordre pour un premier déploiement. La page Articulation des services donne la vue d'ensemble : elle explique pourquoi les rôles sont appliqués dans cet ordre et comment les services se référencent entre eux. Chaque page de service suit ensuite le même plan : principe, prérequis, fichiers commentés, déploiement, vérification, pièges.
Note — Les commandes ont été testées sur Debian 12.
Page suivante : Mise en place du serveur Ansible.
Mise en place du serveur Ansible
Le serveur Ansible, c'est quoi ?
Le serveur Ansible est le nœud de contrôle : la seule machine qui détient le dépôt, la clé SSH d'administration et le mot de passe du Vault. Les VM clientes n'ont rien d'installé en plus que SSH et Python.
| Élément | Rôle |
|---|---|
| Environnement virtuel Python | Isole la version d'Ansible du système |
Clé SSH ed25519 |
Authentifie le serveur Ansible auprès des VM |
Fichier .vault_pass |
Contient le mot de passe de déchiffrement du Vault |
Dépôt Git → ansible-playbook → SSH (réseau d'administration) → VM client
Note — Le serveur Ansible est le chemin des machines : il se connecte aux VM de façon automatique. Les interventions humaines, elles, passent par le bastion Teleport (voir le livre Teleport et la page de présentation du projet).
Concrètement, la version d'Ansible fournie par apt sous Debian 12 est ancienne. L'installation se fait donc dans un environnement virtuel avec pip, ce qui permet de figer la version et de la mettre à jour sans toucher au système.
Prérequis
- Une VM Debian 12 dans le réseau d'administration (
10.99.0.2), avec 2 Go de mémoire. - Un utilisateur non-root avec
sudo: la suite est exécutée avec cet utilisateur. - Un accès SSH aux VM clientes par la clé publique, avec l'utilisateur
deploy(créé lors du provisioning).
Étape 1 : mettre le système à jour et installer les outils
# Mise à jour de l'index des paquets puis des paquets installés.
sudo apt update && sudo apt full-upgrade -y
# python3-venv : création d'environnements virtuels
# python3-pip : installateur Python
# git : gestion du dépôt
# jq, curl : utiles pour interroger des API (voir la page Authentik)
sudo apt install -y python3-venv python3-pip git jq curl
Étape 2 : créer l'environnement virtuel et installer Ansible
# Création du dépôt de travail. Tout le projet vivra ici.
mkdir -p ~/nebuleuse && cd ~/nebuleuse
git init
# Création de l'environnement virtuel dans un dossier caché du répertoire personnel.
python3 -m venv ~/.venv/ansible
# Activation : le prompt se préfixe de (ansible), et "pip" / "ansible" pointent
# désormais vers l'environnement virtuel.
source ~/.venv/ansible/bin/activate
Le fichier requirements.txt fige la version d'Ansible utilisée par le projet.
# requirements.txt
# ansible-core : le moteur d'Ansible, sans les collections de modules (installées à part).
# La plage de versions évite une montée de version majeure involontaire.
ansible-core>=2.17,<2.19
# jmespath : requis par le filtre "json_query", utile pour exploiter les réponses d'API.
jmespath
pip install -r requirements.txt
ansible --version # vérification : affiche la version et le chemin de l'interpréteur Python
Les collections de modules sont déclarées dans un second fichier, indépendant de la version du moteur.
# requirements.yml
collections:
# community.docker : modules docker_compose_v2, docker_network...
- name: community.docker
# community.general : modules divers (ufw, etc.) utilisés par certains rôles
- name: community.general
# ansible.posix : modules système (sysctl, authorized_key...)
- name: ansible.posix
ansible-galaxy collection install -r requirements.yml
Étape 3 : créer la clé SSH d'administration
# -t ed25519 : algorithme moderne, clés courtes et rapides
# -f : chemin du fichier de clé
# -C : commentaire pour reconnaître la clé dans les authorized_keys
# La clé n'a volontairement pas de passphrase : elle est utilisée par des playbooks
# non interactifs. En contrepartie, le fichier doit rester en mode 600 et le serveur
# Ansible n'est accessible que depuis le réseau d'administration.
ssh-keygen -t ed25519 -f ~/.ssh/nebuleuse_ed25519 -C "ansible@nebuleuse"
La clé publique (~/.ssh/nebuleuse_ed25519.pub) est celle que le processus de provisioning dépose dans ~deploy/.ssh/authorized_keys de chaque VM.
Étape 4 : le mot de passe du Vault
# Génère 32 caractères aléatoires et les écrit dans .vault_pass.
openssl rand -base64 24 > .vault_pass
# Lecture/écriture pour le propriétaire uniquement.
chmod 600 .vault_pass
Le fichier .gitignore empêche que le mot de passe du Vault parte dans le dépôt.
# .gitignore
# Ne jamais versionner le mot de passe du Vault ni la clé privée.
.vault_pass
*.pem
*_ed25519
Note — Le mot de passe du Vault doit aussi être conservé hors du serveur Ansible (gestionnaire de mots de passe, coffre physique). Sans lui, les fichiers chiffrés sont définitivement illisibles.
Étape 5 : le fichier de configuration ansible.cfg
# ansible.cfg
[defaults]
# Inventaire utilisé par défaut : évite de passer -i à chaque commande.
inventory = inventory/hosts.yml
# Dossier des rôles du projet.
roles_path = roles
# Utilisateur SSH sur les VM clientes.
remote_user = deploy
# Clé privée utilisée pour la connexion.
private_key_file = ~/.ssh/nebuleuse_ed25519
# Fichier contenant le mot de passe du Vault (créé à l'étape 4).
# Doit exister : Ansible échoue au démarrage si le fichier est introuvable.
vault_password_file = .vault_pass
# Nombre de machines traitées en parallèle.
forks = 10
# Détection automatique de l'interpréteur Python sur les cibles, sans avertissement.
interpreter_python = auto_silent
[privilege_escalation]
# Les tâches s'exécutent en root via sudo : l'utilisateur "deploy" a un sudo sans mot de passe.
become = True
become_method = sudo
[ssh_connection]
# Pipelining : réduit le nombre d'opérations SSH par tâche, donc accélère les playbooks.
pipelining = True
# ControlMaster : réutilise une connexion SSH ouverte pendant 60 s.
# accept-new : accepte la clé d'hôte d'une VM inconnue à la première connexion,
# puis refuse tout changement ultérieur (protection contre l'usurpation).
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
Étape 6 : un premier inventaire
# inventory/hosts.yml
all:
children:
clients: # groupe regroupant toutes les VM clientes
hosts:
client-alpha:
# Adresse du réseau d'administration, pas l'adresse publique.
ansible_host: 10.99.0.21
client-beta:
ansible_host: 10.99.0.22
Vérification
# Affiche l'arbre de l'inventaire : les deux clients doivent apparaître sous "clients".
ansible-inventory --graph
# Test de connectivité SSH et de Python (ce n'est pas un ping réseau).
ansible clients -m ansible.builtin.ping
Résultat attendu pour chaque client :
client-alpha | SUCCESS => {
"changed": false,
"ping": "pong"
}
Pièges
vault_password_fileintrouvable : le message « The vault password file ... was not found » apparaît dès la première commande. Il suffit de créer.vault_pass(étape 4) avant tout.Permission denied (publickey): la clé publique n'est pas dans lesauthorized_keysde l'utilisateurdeploy, ou la clé privée n'est pas en mode 600.- Mauvais
ansible: si l'environnement virtuel n'est pas activé, la commandeansibleappelle la version système. Le préfixe(ansible)du prompt sert de contrôle. - Pas de
changedsur unping: normal, ce module est en lecture seule.
Page suivante : Inventaire et variables.
Inventaire et variables du client
Inventaire et variables, c'est quoi ?
L'inventaire dit sur quelles machines Ansible agit. Les variables disent comment configurer chacune. Dans Nébuleuse, la commande d'un client se résume à un dossier de variables : le code (les rôles) ne change jamais d'un client à l'autre.
| Emplacement | Contenu | Chiffré |
|---|---|---|
inventory/hosts.yml |
Liste des machines et leur adresse d'administration | Non |
inventory/group_vars/all/vars.yml |
Valeurs communes à tous les clients | Non |
inventory/host_vars/<client>/vars.yml |
Valeurs propres à un client, dont la liste des applications | Non |
inventory/host_vars/<client>/vault.yml |
Secrets du client | Oui (Ansible Vault) |
Commande web → vars.yml du client → nebuleuse_apps → rôles activés par site.yml
Concrètement, le formulaire de commande génère deux fichiers dans host_vars/<client>/ : vars.yml (le domaine et les applications choisies) et vault.yml (des secrets générés aléatoirement, puis chiffrés). Un nouveau client, c'est donc un dossier de plus, sans modifier de rôle.
Prérequis
- Le serveur Ansible est en place (page précédente), avec
.vault_passcréé. - L'inventaire contient
client-alphadans le groupeclients.
Précédence des variables
Une même variable peut être définie à plusieurs niveaux. Ansible retient la valeur du niveau le plus fort. Dans ce projet, seuls ces niveaux sont utilisés, du plus faible au plus fort :
- Les valeurs par défaut du rôle (
roles/<rôle>/defaults/main.yml) - Les variables communes (
group_vars/all) - Les variables du client (
host_vars/<client>) - Les variables passées en ligne de commande (
-e)
Les valeurs par défaut portent donc les choix raisonnables, group_vars les choix de l'hébergeur, et host_vars ce qui est propre au client.
Les variables communes
# inventory/group_vars/all/vars.yml
# Valeurs partagées par tous les clients de Nébuleuse. Aucun secret ici.
# Domaine de l'hébergeur : sert au bastion et aux adresses techniques.
nebuleuse_domain: nebuleuse.example
# Adresse e-mail déclarée auprès de l'autorité de certification (Let's Encrypt).
# Elle reçoit les alertes d'expiration de certificat.
nebuleuse_acme_email: certificats@nebuleuse.example
# Réseau Docker partagé entre Traefik et les applications.
docker_proxy_network: proxy
# Réseau d'administration : seul à pouvoir joindre le port SSH.
admin_network_cidr: 10.99.0.0/24
# Adresse du bastion Teleport (proxy) auquel chaque agent se rattache.
teleport_proxy_addr: bastion.nebuleuse.example:443
# Catalogue : applications que Nébuleuse sait déployer.
# Une application commandée doit figurer ici, sinon le déploiement s'arrête.
nebuleuse_catalogue:
- nextcloud
- immich
- vaultwarden
- jellyfin
- papermerge
- homeassistant
- matrix
Les variables d'un client
# inventory/host_vars/client-alpha/vars.yml
# Généré à partir de la commande du client. Aucun secret ici.
# Identifiant court du client : sert de préfixe aux conteneurs (alpha_nextcloud...).
client_name: alpha
# Domaine du client : chaque application aura un sous-domaine.
client_domain: alpha.nebuleuse.example
# Contact administrateur du client (compte initial du SSO, notifications).
client_admin_email: admin@alpha.nebuleuse.example
# Applications commandées. C'est la seule variable qui pilote ce qui est déployé.
nebuleuse_apps:
- nextcloud
- immich
- vaultwarden
# Sous-domaines de chaque service. Le SSO est toujours présent (socle).
client_subdomains:
sso: sso
nextcloud: cloud
immich: photos
vaultwarden: vault
Les secrets d'un client
Les secrets suivent une convention : chaque secret est défini dans vault.yml sous un nom préfixé par vault_, puis référencé dans vars.yml ou dans un rôle sous son nom normal. Ainsi, on repère d'un coup d'œil les variables sensibles, et on voit où une valeur est utilisée sans déchiffrer quoi que ce soit.
# inventory/host_vars/client-alpha/vault.yml (AVANT chiffrement)
# Toutes les valeurs ci-dessous sont fictives.
vault_authentik_secret_key: "FICTIF-ab12cd34ef56ab12cd34ef56ab12cd34ef56"
vault_authentik_db_password: "FICTIF-db-motdepasse-1"
vault_nextcloud_db_password: "FICTIF-db-motdepasse-2"
vault_nextcloud_admin_password: "FICTIF-admin-motdepasse-3"
vault_immich_db_password: "FICTIF-db-motdepasse-4"
vault_vaultwarden_admin_token: "FICTIF-token-admin-5"
# Chiffrement du fichier avec le mot de passe de .vault_pass (voir ansible.cfg).
ansible-vault encrypt inventory/host_vars/client-alpha/vault.yml
# Pour modifier plus tard : ouvre le fichier déchiffré dans l'éditeur, puis le rechiffre à la sauvegarde.
ansible-vault edit inventory/host_vars/client-alpha/vault.yml
Après chiffrement, le fichier commence par $ANSIBLE_VAULT;1.1;AES256 suivi d'un bloc illisible. Il peut être versionné sans risque tant que le mot de passe du Vault reste secret.
Dans un rôle, le secret est consommé sous son nom propre :
# roles/authentik/defaults/main.yml
# Le rôle ne connaît que "authentik_secret_key" : il ignore d'où vient la valeur.
authentik_secret_key: "{{ vault_authentik_secret_key }}"
authentik_db_password: "{{ vault_authentik_db_password }}"
Vérifier la commande avant de déployer
Un garde-fou en début de playbook arrête le déploiement si le client commande une application inconnue ou oublie un secret. Mieux vaut échouer tout de suite que découvrir l'erreur au milieu d'un déploiement.
# site.yml (extrait : tâches de contrôle)
pre_tasks:
- name: Vérifier que toutes les applications commandées existent au catalogue
ansible.builtin.assert:
that:
# "difference" retourne les éléments de la commande absents du catalogue.
- nebuleuse_apps | difference(nebuleuse_catalogue) | length == 0
fail_msg: >-
Application(s) inconnue(s) : {{ nebuleuse_apps | difference(nebuleuse_catalogue) }}
quiet: true
- name: Vérifier que le client a bien un domaine
ansible.builtin.assert:
that:
- client_domain is defined
- client_domain | length > 0
fail_msg: "La variable client_domain doit être définie pour {{ inventory_hostname }}."
quiet: true
Vérification
# Affiche toutes les variables fusionnées d'un client (le Vault est déchiffré).
# Attention : la sortie contient les secrets en clair, ne pas la partager.
ansible-inventory --host client-alpha --yaml
# Contrôle la syntaxe du playbook sans rien exécuter.
ansible-playbook site.yml --syntax-check
Pour s'assurer qu'aucun fichier de secrets n'est resté en clair avant un commit :
# Liste les vault.yml qui NE commencent PAS par l'en-tête de chiffrement.
# Aucune ligne ne doit s'afficher.
grep -L '^\$ANSIBLE_VAULT' inventory/host_vars/*/vault.yml
Pièges
- Variable
vault_...non définie : un secret référencé dans un rôle mais absent devault.ymlprovoque « is undefined » au milieu du déploiement. Les tâches de contrôle ci-dessus évitent ce cas pour les variables essentielles. - Un fichier
host_vars/client-alpha.ymlet un dossierhost_vars/client-alpha/: les deux sont lus, ce qui rend l'origine d'une valeur difficile à retrouver. Il faut choisir un seul format, ici le dossier. - Secret affiché dans un journal : une tâche qui manipule un secret doit porter
no_log: true, sinon la valeur apparaît dans la sortie d'Ansible. - Fichier versionné avant chiffrement : si
vault.ymlest commité en clair, le secret reste dans l'historique Git même après chiffrement. Il faut alors le renouveler, pas seulement chiffrer le fichier. - Mot de passe du Vault perdu : les fichiers sont illisibles et les secrets doivent être régénérés. D'où la sauvegarde hors serveur recommandée à la page précédente.
Page suivante : Articulation des services.
Articulation des services
Pourquoi cette page ?
Les services d'une VM Nébuleuse ne sont pas indépendants. Une application attend que le SSO existe, le SSO attend que le reverse proxy le publie, et le pare-feu applicatif lit les journaux des deux. Cette page donne la vue d'ensemble : qui dépend de qui, ce que chaque service doit savoir des autres, et dans quel ordre Ansible doit les installer. Les pages suivantes détaillent chaque service ; celle-ci explique pourquoi ils s'emboîtent.
| Service | Rôle dans l'ensemble |
|---|---|
| Traefik | Reçoit tout le trafic web et le dirige selon le nom de domaine |
| Authentik | Identifie les utilisateurs pour toutes les applications |
| Applications | S'appuient sur Traefik pour être publiées, sur Authentik pour connecter les utilisateurs |
| Fail2ban | Lit les journaux et bloque les adresses fautives |
| Agent Teleport | Rattache la VM au bastion, indépendamment du reste |
Utilisateur → Traefik → Application → redirection vers Authentik → retour à l'application connecté
Vue d'ensemble
Les relations, service par service
Chaque ligne ci-dessous est une information qu'un service doit connaître d'un autre. Si l'une d'elles est fausse, la fonctionnalité correspondante ne marche pas, et le symptôme est rarement évident. C'est pourquoi ces valeurs sont des variables partagées, définies une seule fois.
| Service | A besoin de | Information échangée |
|---|---|---|
| Traefik | Le réseau Docker proxy |
Nom du réseau, pour trouver les conteneurs |
| Traefik | Un e-mail ACME | Pour obtenir les certificats HTTPS |
| Chaque application | Le réseau proxy |
Pour être joignable par Traefik |
| Chaque application | Des labels Traefik | Nom de domaine (Host(...)) et port interne |
| Chaque application | Authentik | Adresse de l'émetteur (issuer), identifiant client, secret client |
| Authentik | Chaque application | Adresse de retour (redirect URI) exacte |
| Fail2ban | Le journal d'accès de Traefik | Chemin du fichier, format des lignes |
| Fail2ban | La chaîne DOCKER-USER |
Le point d'insertion des blocages |
| Agent Teleport | Le bastion | Adresse du proxy et jeton d'enrôlement |
Deux relations demandent une attention particulière :
- L'adresse de retour (redirect URI) doit être identique, caractère pour caractère, dans Authentik et dans l'application. Un
/final en trop suffit à provoquer une erreur de connexion. - Les adresses réelles des visiteurs : les applications ne voient que l'adresse de Traefik, sauf si elles sont configurées pour lire l'en-tête
X-Forwarded-Forenvoyé par un proxy de confiance. Sans cela, les journaux ne contiennent que l'adresse interne, et Fail2ban bloquerait la mauvaise adresse, ou aucune.
Ordre de déploiement
L'ordre des rôles dans site.yml n'est pas arbitraire. Chaque étape suppose que la précédente est terminée.
- Socle et Docker : le système est à jour, Docker est installé, le réseau
proxyexiste et la chaîneDOCKER-USERest disponible. - Agent Teleport : l'accès administrateur est établi avant les services applicatifs. Si un déploiement ultérieur échoue, la machine reste accessible par le bastion.
- Traefik : il écoute sur les ports 80 et 443 et obtient les certificats. Il doit exister avant les applications, qui déclarent leurs routes par labels.
- Authentik : une fois démarré derrière Traefik, son API est joignable. Les applications en ont besoin pour créer leurs connexions SSO.
- Applications commandées : chacune crée sa connexion dans Authentik (API), récupère l'identifiant et le secret, puis génère son fichier Compose.
- Fail2ban : il vient en dernier, car ses règles pointent vers des journaux qui n'existent qu'une fois Traefik et les applications démarrés.
# site.yml (extrait : ordre d'application des rôles)
roles:
# 1. Socle : système, pare-feu, Docker et réseau "proxy".
- { role: base-hardening, tags: [base] }
- { role: docker-engine, tags: [docker] }
# 2. Accès administrateur par le bastion.
- { role: teleport-agent, tags: [teleport] }
# 3. Publication web et certificats.
- { role: traefik, tags: [traefik] }
# 4. SSO : toujours présent, il fait partie du socle.
- { role: authentik, tags: [sso] }
# 5. Applications : déployées uniquement si elles figurent dans la commande.
- { role: nextcloud, tags: [nextcloud], when: "'nextcloud' in nebuleuse_apps" }
- { role: immich, tags: [immich], when: "'immich' in nebuleuse_apps" }
- { role: vaultwarden, tags: [vaultwarden], when: "'vaultwarden' in nebuleuse_apps" }
# 6. Protection : en dernier, elle lit les journaux des rôles précédents.
- { role: fail2ban, tags: [fail2ban] }
Note — La condition when d'un rôle s'applique à chacune de ses tâches : si l'application n'est pas commandée, aucune tâche du rôle ne s'exécute.
Comment une valeur voyage d'un service à l'autre
Un identifiant client créé par Authentik doit se retrouver dans le fichier Compose de l'application. Il ne vient pas d'une variable écrite à l'avance : il est créé pendant le déploiement. Ansible le récupère donc dans la réponse de l'API, le mémorise, puis le fournit au modèle de fichier.
Appel API Authentik → réponse (identifiant, secret) → variable de la tâche suivante → modèle docker-compose.yml.j2 → fichier sur la VM
C'est ce qui oblige à placer Authentik avant les applications, et à rendre ces appels rejouables : si la connexion existe déjà, la tâche récupère ses valeurs au lieu de la recréer. Le détail est donné dans la page Authentik.
Attendre qu'un service soit prêt
Démarrer un conteneur ne signifie pas que le service répond. Authentik met plusieurs dizaines de secondes à devenir joignable, et une tâche qui l'appelle trop tôt échoue sans raison valable. Le rôle attend donc que l'adresse de santé réponde, en réessayant à intervalles réguliers.
# roles/authentik/tasks/main.yml (extrait)
- name: Attendre que l'API d'Authentik réponde
# curl -f : retourne une erreur si le serveur répond autre chose qu'un succès.
# -s -S : silencieux, mais affiche les erreurs.
ansible.builtin.command: >-
curl -fsS https://sso.{{ client_domain }}/-/health/live/
register: authentik_health
# Réessaie jusqu'à ce que la commande réussisse : 30 essais, 10 secondes d'écart.
until: authentik_health.rc == 0
retries: 30
delay: 10
# Ne modifie rien : l'indicateur "changed" n'a pas de sens ici.
changed_when: false
Vérification
# Depuis le serveur Ansible : liste l'ordre exact des tâches pour un client,
# sans rien exécuter, pour contrôler que les rôles sont enchaînés comme prévu.
ansible-playbook site.yml --limit client-alpha --list-tasks
# Sur la VM : tous les conteneurs doivent être "Up" et sur le réseau "proxy".
docker ps --format '{{.Names}}\t{{.Status}}'
docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{end}}'
Pièges
- Dépendance circulaire apparente : Authentik est publié par Traefik, et les applications ont besoin d'Authentik. Il n'y a pas de cycle tant que l'ordre Traefik, puis Authentik, puis applications est respecté.
- Rejouer une partie seulement : relancer uniquement le tag d'une application ne recrée pas les éléments de l'étape précédente. Si une valeur de Traefik ou d'Authentik change, il faut relancer aussi les rôles qui en dépendent.
- Fail2ban lancé trop tôt : une jail dont le fichier journal n'existe pas ne démarre pas. D'où sa position en dernier.
- Résolution du nom du SSO depuis un conteneur : une application doit contacter
sso.<domaine>depuis l'intérieur de la VM. Selon la manière dont l'hébergeur attache l'adresse publique, ce chemin peut ne pas aboutir. Le cas est traité dans la page Traefik.
Page suivante : Socle système et Docker Engine.
Socle système et Docker Engine
Le socle, c'est quoi ?
Le socle est ce que reçoit toute VM Nébuleuse avant la moindre application : un système à jour, un accès SSH restreint, un pare-feu, puis Docker avec son réseau partagé. Il est porté par deux rôles, base-hardening et docker-engine, appliqués dans cet ordre.
| Rôle | Rôle dans le socle |
|---|---|
base-hardening |
Mises à jour, SSH restreint au réseau d'administration, pare-feu ufw |
docker-engine |
Docker depuis le dépôt officiel, journaux limités, réseau proxy |
VM Debian 12 vierge → base-hardening → docker-engine → machine prête à recevoir Traefik
Concrètement, la VM livrée par le provisioning possède déjà un utilisateur deploy autorisé par clé SSH et membre de sudo sans mot de passe. Le socle ne crée pas cet utilisateur : il en dépend, et il le verrouille comme seul compte autorisé à se connecter.
Prérequis
- Les pages précédentes sont en place : inventaire, variables communes (
admin_network_cidr) etansible ... -m pingfonctionnel. - L'utilisateur
deployse connecte par clé et dispose desudosans mot de passe. - L'adresse
ansible_hostde chaque client est son adresse du réseau d'administration, car c'est celle sur laquelle SSH écoutera.
Arborescence des deux rôles
roles/
├── base-hardening/
│ ├── defaults/main.yml
│ ├── handlers/main.yml
│ ├── tasks/main.yml
│ └── templates/sshd-nebuleuse.conf.j2
└── docker-engine/
├── defaults/main.yml
├── handlers/main.yml
├── tasks/main.yml
└── templates/daemon.json.j2
Le rôle base-hardening
Variables
# roles/base-hardening/defaults/main.yml
# Seul utilisateur autorisé à ouvrir une session SSH.
base_ssh_user: deploy
# Adresse sur laquelle SSH écoute : celle du réseau d'administration.
# "ansible_host" vaut cette adresse dans l'inventaire (voir page Inventaire).
base_ssh_listen_address: "{{ ansible_host }}"
# Réseau autorisé à joindre SSH (défini une fois pour tous dans group_vars/all).
base_admin_network_cidr: "{{ admin_network_cidr }}"
# Ports ouverts à tout Internet : uniquement le web.
base_public_tcp_ports:
- 80
- 443
Configuration SSH
# roles/base-hardening/templates/sshd-nebuleuse.conf.j2
# Fichier géré par Ansible : toute modification manuelle sera écrasée.
# Pas de connexion directe en root.
PermitRootLogin no
# Authentification par clé uniquement : aucun mot de passe n'est accepté.
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Un seul compte peut se connecter.
AllowUsers {{ base_ssh_user }}
# SSH n'écoute que sur l'adresse d'administration, jamais sur l'adresse publique.
ListenAddress {{ base_ssh_listen_address }}
Note — Sous Debian 12, le fichier principal /etc/ssh/sshd_config commence par une directive Include /etc/ssh/sshd_config.d/*.conf. Pour chaque paramètre, SSH retient la première valeur lue : le fichier déposé dans ce dossier l'emporte donc sur le fichier principal, sans avoir à le modifier.
Tâches
# roles/base-hardening/tasks/main.yml
- name: Mettre à jour l'index des paquets et les paquets installés
ansible.builtin.apt:
update_cache: true
# "dist" équivaut à "apt dist-upgrade" : gère aussi les changements de dépendances.
upgrade: dist
# Ne relit l'index qu'au bout d'une heure : évite un téléchargement inutile à chaque passage.
cache_valid_time: 3600
- name: Installer les paquets du socle
ansible.builtin.apt:
name:
- ufw # pare-feu de l'hôte
- unattended-upgrades # installation automatique des mises à jour de sécurité
- ca-certificates # certificats racine pour les téléchargements HTTPS
- curl # utilisé par d'autres rôles
state: present
- name: Activer les mises à jour de sécurité automatiques
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
owner: root
group: root
mode: "0644"
- name: Déposer la configuration SSH durcie
ansible.builtin.template:
src: sshd-nebuleuse.conf.j2
dest: /etc/ssh/sshd_config.d/10-nebuleuse.conf
owner: root
group: root
mode: "0644"
# Vérifie la syntaxe avant d'écraser le fichier : une erreur ici couperait l'accès.
validate: /usr/sbin/sshd -t -f %s
notify: Redémarrer ssh
# Le pare-feu : l'ordre compte. La règle SSH est créée AVANT l'activation
# du pare-feu, sinon la connexion en cours serait coupée.
- name: Refuser par défaut tout trafic entrant
community.general.ufw:
default: deny
direction: incoming
- name: Autoriser SSH depuis le réseau d'administration uniquement
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ base_admin_network_cidr }}"
- name: Autoriser le web (HTTP et HTTPS) depuis Internet
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ base_public_tcp_ports }}"
- name: Activer ufw
community.general.ufw:
state: enabled
# roles/base-hardening/handlers/main.yml
# Un handler ne s'exécute qu'à la fin des tâches, et seulement si une tâche l'a notifié.
- name: Redémarrer ssh
ansible.builtin.service:
name: ssh # le service s'appelle "ssh" sous Debian, pas "sshd"
state: restarted
Le rôle docker-engine
Variables
# roles/docker-engine/defaults/main.yml
# Nom du réseau partagé entre Traefik et les applications (voir group_vars/all).
docker_engine_proxy_network: "{{ docker_proxy_network }}"
# Taille maximale d'un fichier de journal de conteneur, et nombre de fichiers conservés.
# Sans limite, les journaux d'un conteneur bavard finissent par remplir le disque.
docker_engine_log_max_size: "10m"
docker_engine_log_max_files: "3"
Configuration du démon
# roles/docker-engine/templates/daemon.json.j2
{
"log-driver": "json-file",
"log-opts": {
"max-size": "{{ docker_engine_log_max_size }}",
"max-file": "{{ docker_engine_log_max_files }}"
},
"live-restore": true
}
Le paramètre live-restore garde les conteneurs en marche pendant un redémarrage ou une mise à jour du démon Docker : une mise à jour du moteur ne coupe donc pas les services des clients.
Tâches
# roles/docker-engine/tasks/main.yml
- name: Créer le dossier des clés de dépôt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Récupérer la clé de signature du dépôt Docker
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Déclarer le dépôt officiel Docker
ansible.builtin.apt_repository:
# "signed-by" lie ce dépôt à sa clé : les paquets sont vérifiés avec elle.
# ansible_distribution_release vaut "bookworm" sur Debian 12.
repo: >-
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable
filename: docker
state: present
- name: Installer Docker Engine et le plugin Compose
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin # fournit la commande "docker compose"
- python3-docker # bibliothèque Python requise par les modules community.docker
state: present
update_cache: true
- name: Déposer la configuration du démon Docker
ansible.builtin.template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
notify: Redémarrer docker
- name: Activer et démarrer Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
# Les handlers sont lancés ici plutôt qu'en fin de rôle : le réseau ci-dessous
# doit être créé sur un démon déjà configuré.
- name: Appliquer les changements de configuration du démon
ansible.builtin.meta: flush_handlers
- name: Créer le réseau partagé entre Traefik et les applications
community.docker.docker_network:
name: "{{ docker_engine_proxy_network }}"
driver: bridge
# roles/docker-engine/handlers/main.yml
- name: Redémarrer docker
ansible.builtin.service:
name: docker
state: restarted
Note — L'utilisateur deploy n'est volontairement pas ajouté au groupe docker. Appartenir à ce groupe revient à avoir les droits root sur la machine. Ansible utilise déjà sudo, ce qui laisse une trace de chaque commande.
Docker et le pare-feu : ce qu'il faut savoir
Docker gère lui-même ses règles de pare-feu. Quand un conteneur publie un port (comme Traefik sur 80 et 443), Docker l'ouvre directement, sans passer par ufw. Ainsi, une règle ufw ne bloque pas un port publié par un conteneur.
Ce comportement est connu, et il explique deux choix du projet : 1. Seul Traefik publie des ports vers l'extérieur. Les autres conteneurs restent sur le réseau proxy, sans port publié. 2. Les blocages dynamiques (Fail2ban) ne passent pas par ufw mais par la chaîne DOCKER-USER. Docker crée cette chaîne et ne la modifie jamais ; les règles qu'on y place sont donc appliquées avant celles de Docker.
Le playbook du socle
Pour tester dès maintenant, un site.yml minimal suffit. Il sera complété à la page de déploiement.
# site.yml
- name: Socle Nébuleuse
hosts: clients
become: true # les tâches s'exécutent en root via sudo
roles:
- { role: base-hardening, tags: [base] }
- { role: docker-engine, tags: [docker] }
# Simulation : montre ce qui changerait, sans rien modifier.
ansible-playbook site.yml --limit client-alpha --check --diff
# Déploiement réel.
ansible-playbook site.yml --limit client-alpha
Vérification
# Depuis le serveur Ansible : un second passage ne doit rien modifier.
# La ligne finale doit afficher "changed=0".
ansible-playbook site.yml --limit client-alpha
# Sur la VM : valeurs SSH effectives.
sudo sshd -T | grep -E 'listenaddress|passwordauthentication|permitrootlogin|allowusers'
# Sur la VM : règles du pare-feu.
sudo ufw status verbose
# Sur la VM : version de Docker, pilote de journaux et live-restore.
docker version --format '{{.Server.Version}}'
docker info --format 'journaux={{.LoggingDriver}} live-restore={{.LiveRestoreEnabled}}'
# Sur la VM : le réseau partagé existe et la chaîne DOCKER-USER est disponible.
docker network ls --filter name=proxy
sudo iptables -L DOCKER-USER -n
Résultat attendu pour sshd -T : listenaddress 10.99.0.21:22, passwordauthentication no, permitrootlogin no, allowusers deploy.
Pièges
- Se couper l'accès en modifiant SSH : une erreur de
ListenAddressou une règle de pare-feu oubliée coupe la connexion. La validationsshd -tet l'ordre « règle avant activation » limitent le risque. Avant de fermer une session de test, ouvrir une seconde connexion pour vérifier que l'accès fonctionne encore. ansible_hostqui n'est pas l'adresse d'administration : SSH n'écouterait pas là où Ansible se connecte. L'inventaire de la première page respecte la règle ; une machine ajoutée avec son adresse publique fausserait le rôle.aptbloqué par un autre processus : juste après le démarrage d'une VM neuve,unattended-upgradespeut tenir le verrouapt. Ansible patiente un moment, puis échoue avec « Could not get lock ». Relancer le playbook suffit.- Pare-feu
ufwqui « ne bloque rien » pour un conteneur : voir la section précédente. Ce n'est pas un défaut de configuration. docker_networken erreur sur la bibliothèque Python : le paquetpython3-dockerdoit être installé avant toute tâchecommunity.docker. Il fait partie de la liste des paquets du rôle.- Journaux de conteneurs qui remplissent le disque : sans
max-size, aucune rotation n'est faite. La configurationdaemon.jsonne s'applique qu'aux conteneurs créés après elle ; les conteneurs existants doivent être recréés.
Page suivante : Bastion Teleport : agent et enrôlement.