Aller au contenu

Infrastructure (IaC)

Qu'est-ce que l'Infrastructure as Code ?

L'Infrastructure as Code (IaC) consiste à décrire l'infrastructure — DNS, firewall, configuration serveur, déploiement applicatif — sous forme de fichiers de code versionnés dans Git, exactement comme le code source de l'application.

Sans IaC, configurer un serveur implique une série d'actions manuelles : se connecter en SSH, installer des paquets, éditer des fichiers de config, copier des certificats. Ces actions sont oubliées, difficiles à reproduire et impossibles à auditer. Si le serveur tombe, reconstruire l'infrastructure identique à partir de zéro prend des heures et reste incertain.

Avec IaC, l'infrastructure est lisible, reviewable en PR, et reproductible. Reconstruire un environnement identique se résume à lancer une commande.


Deux outils pour deux périmètres distincts

flowchart LR
    TF["Terraform\n(infrastructure)"]
    AN["Ansible\n(application)"]
    OVH["OVH API\n(DNS + Firewall)"]
    VPS["VPS OVH\n(staging.wonderwork.fr)"]

    TF -->|"DNS A records\nfirewall rules"| OVH
    AN -->|"SSH\n(clé dédiée CI)"| VPS
    VPS -->|"pull images"| GHCR["GHCR\nghcr.io"]
Outil Périmètre Responsabilité
Terraform Ressources cloud OVH DNS, firewall
Ansible Serveur + application OS, Docker, Nginx, TLS, déploiement

Ces deux outils répondent à des questions différentes :

  • Terraform gère les ressources cloud — ce qui existe chez le provider (OVH). Il travaille en mode déclaratif : on décrit l'état final souhaité, Terraform calcule ce qui doit changer et l'applique.
  • Ansible configure le serveur lui-même et déploie l'application. Il se connecte en SSH et exécute des tâches dans l'ordre. Là où Terraform gère "quelles ressources cloud existent", Ansible gère "comment le serveur est configuré".

Terraform

Répertoire : infra/terraform/staging/

Pourquoi Terraform pour le DNS et le firewall ?

Sans Terraform, chaque modification DNS (nouvel sous-domaine, changement d'IP) nécessiterait de se connecter au portail OVH, naviguer dans l'interface et cliquer. C'est une opération manuelle, non traçable, non reviewable.

Avec Terraform : - Toute modification passe par une PR — elle est discutée, approuvée et loguée dans l'historique Git - Le terraform plan montre exactement ce qui va changer avant d'appliquer - L'état courant de l'infrastructure est stocké dans un fichier terraform.tfstate distant (S3 OVH) — partagé entre tous les membres de l'équipe

Backend distant (S3 OVH)

Le state Terraform est stocké dans un bucket S3 OVH et non localement. Sans backend distant, chaque développeur aurait son propre state local — il serait impossible de savoir qui a appliqué quoi et l'équipe travaillerait en aveugle sur l'état réel de l'infrastructure.

Le backend est configuré dans backend.hcl (non versionné — contient des credentials) ; le fichier backend.hcl.example documente sa structure.

Ressources gérées

5 enregistrements A pointant vers le VPS staging — chaque sous-domaine correspond à un service exposé :

Sous-domaine Service
staging.wonderwork.fr App candidat (3000)
api.staging.wonderwork.fr API Symfony (8000)
candidate.staging.wonderwork.fr App candidat (3000)
recruiter.staging.wonderwork.fr App recruteur (3001)
docs.staging.wonderwork.fr Documentation MkDocs

TTL : 3600 secondes (1 heure). Les changements DNS se propagent en moins d'une heure.

Firewall OVH NSM (stateful) appliqué au niveau réseau, en amont du VPS. Filtre tout le trafic entrant avant qu'il n'atteigne le serveur.

Séquence Port Action Raison
0 22 (SSH) permit TCP Accès Ansible et administration
1 80 (HTTP) permit TCP Certbot ACME challenge (renouvellement TLS)
2 443 (HTTPS) permit TCP Trafic applicatif
3 9000 permit TCP SonarQube (analyse qualité)
10 tout IPv4 deny Catch-all — tout ce qui n'est pas explicitement autorisé est rejeté

Stateful : seul le trafic entrant est déclaré. Les réponses aux connexions établies passent automatiquement.

Pourquoi un firewall au niveau OVH et pas uniquement ufw sur le serveur ? Le firewall OVH bloque le trafic avant qu'il n'atteigne le VPS — les paquets non autorisés ne consomment pas de ressources serveur. C'est une défense en profondeur : même si ufw est mal configuré, le firewall OVH reste actif.

terraform_data.vps : référence au VPS existant (IP + hostname). Le VPS lui-même n'est pas provisionné par Terraform — il est créé manuellement sur OVH et Terraform le référence uniquement.

Variables

variable "ovh_application_key"    {}  # OVH API
variable "ovh_application_secret" {}  # OVH API
variable "ovh_consumer_key"       {}  # OVH API
variable "ovh_zone"               {}  # ex: wonderwork.fr
variable "vps_ip"                 {}  # IP du VPS staging

Toutes passées via les secrets GitHub de l'environnement staging dans le workflow terraform-staging.yml.

Workflow GitHub Actions

Trigger : push sur main avec changements dans infra/terraform/staging/** + workflow_dispatch.

workflow_dispatch:
  inputs:
    action: plan | apply
Étape Ce qu'elle fait Pourquoi
terraform init Initialise le backend S3 OVH Télécharge les providers, connecte au state distant
terraform validate Valide la syntaxe HCL Détecte les erreurs avant de contacter l'API
terraform plan Calcule et affiche les changements Permet de voir exactement ce qui va changer avant d'appliquer
terraform apply Applique les changements Uniquement si action == 'apply' — jamais automatique

Apply manuel uniquement

Le apply n'est jamais déclenché automatiquement — il faut lancer manuellement le workflow avec action: apply depuis GitHub Actions. Cette friction intentionnelle évite qu'une modification de DNS soit appliquée sans relecture humaine.


Ansible

Répertoire : infra/ansible/

Pourquoi Ansible pour le déploiement ?

Ansible orchestre la configuration du serveur et le déploiement de l'application via SSH. Il remplace ce qui serait sinon un ensemble de scripts bash fragiles et non maintenables.

Ses avantages clés dans ce contexte :

  • Idempotent — chaque tâche vérifie l'état actuel avant d'agir. Relancer le playbook plusieurs fois produit le même résultat. Un script bash réexécuté pourrait dupliquer des configs ou écraser des données.
  • Déclaratif par rôles — le code est organisé par responsabilité (system, docker, nginx, certbot, app). Chaque rôle est lisible indépendamment.
  • Vault intégré — Ansible chiffre les secrets directement dans le dépôt Git. Pas de variables d'environnement exposées dans les logs CI.

Structure

infra/ansible/
├── ansible.cfg                     ← Configuration (vault, SSH, pipelining)
├── deploy.yml                      ← Playbook principal — orchestre les rôles
├── requirements.yml                ← community.docker >= 3.0.0
├── inventory/
│   └── staging.yml                 ← Host staging_server (IP via secret CI)
├── group_vars/
│   ├── all/vars.yml                ← Variables communes (images, ports, stack)
│   └── staging/
│       ├── vars.yml                ← Variables staging (domaines, CORS, env)
│       └── vault.yml               ← Secrets chiffrés AES-256
└── roles/
    ├── system/                     ← Updates OS, paquets système
    ├── docker/                     ← Installation Docker + Swarm init
    ├── nginx/                      ← Config Nginx reverse proxy
    ├── certbot/                    ← Certificats TLS Let's Encrypt
    └── app/                        ← Déploiement stack applicative

Playbook deploy.yml — Séquence d'exécution

Les rôles s'exécutent dans un ordre précis car chaque rôle dépend du précédent :

flowchart LR
    S[system] --> D[docker]
    D --> N[nginx]
    N --> C[certbot]
    C --> A[app]

Rôle system — Fondations OS

Ce qu'il fait : met à jour les paquets système Ubuntu, installe les utilitaires nécessaires (curl, git, etc.), configure le fuseau horaire.

Pourquoi : un serveur fraîchement créé n'a pas forcément les derniers patches de sécurité. Ce rôle garantit une base saine avant d'installer quoi que ce soit.

Rôle docker — Moteur de conteneurs

Ce qu'il fait : installe Docker Engine (dernière version stable) et initialise Docker Swarm en mode single-node.

Pourquoi Docker Swarm et pas Compose directement ? Docker Swarm permet d'utiliser docker stack deploy avec l'option --with-registry-auth, qui transmet les credentials GHCR à tous les nœuds lors du pull. C'est plus robuste que docker compose pour les déploiements automatisés avec authentification de registre privé. Il reste simple à opérer (pas de Kubernetes) et suffit pour le staging.

Rôle nginx — Reverse proxy

Ce qu'il fait : installe Nginx, déploie les virtual hosts pour chaque service (api, candidate, recruiter, docs), crée le répertoire /var/www/docs/ pour la documentation statique.

Pourquoi un reverse proxy ? Les applications Next.js et l'API Symfony tournent chacune sur leur port (3000, 3001, 8000). Nginx fait le lien entre les noms de domaine publics (api.staging.wonderwork.fr → port 8000) et gère : - La terminaison TLS (HTTPS) — les apps ne voient que du HTTP en interne - Les headers de proxy (X-Forwarded-For, X-Real-IP) - La limite de taille des uploads (client_max_body_size)

Les configs Nginx sont générées depuis des templates Jinja2 — les domaines, ports et paramètres sont injectés depuis les variables Ansible, pas écrits en dur.

Rôle certbot — Certificats TLS

Ce qu'il fait : installe Certbot avec le plugin Nginx, génère un certificat Let's Encrypt couvrant tous les sous-domaines, active le renouvellement automatique via certbot.timer.

Pourquoi Let's Encrypt ? Certificats TLS gratuits, automatisés et reconnus par tous les navigateurs. Le plugin Nginx configure automatiquement les redirections HTTP→HTTPS et met à jour les configs Nginx pour pointer vers les certificats générés.

Note : la tâche de génération de certificat utilise creates: "/etc/letsencrypt/live/{{ api_domain }}/fullchain.pem" — elle ne s'exécute que si le certificat n'existe pas encore. Sur un serveur déjà provisionné, Certbot ne sera pas relancé inutilement.

Rôle app — Déploiement applicatif

C'est le rôle central. Il déploie la stack applicative complète, dans un ordre précis avec des vérifications à chaque étape.

flowchart TD
    T1["Créer /opt/wonderwork"] --> T2
    T2["Login GHCR\n(ghcr_token)"] --> T3
    T3["Déployer docker-stack.yml.j2"] --> T4
    T4["docker stack deploy\n(with_registry_auth)"] --> T5
    T5["Attendre API :8000\ntimeout 120s"] --> T6
    T6["Attendre candidate :3000\ntimeout 120s"] --> T7
    T7["Attendre recruiter :3001\ntimeout 120s"] --> T8
    T8["JWT keypair\n--skip-if-exists\n(préserve les sessions)"] --> T9
    T9["Attendre PostgreSQL\npg_isready"] --> T10
    T10["doctrine:migrations:migrate\n--allow-no-migration"] --> T11
    T11{cities table\nvide?}
    T11 -->|oui| T12["app:import:geonames\n--from-bundle\n~140k villes"]
    T11 -->|non| T13
    T12 --> T13
    T13["Health check /v1/docs\n(200 OK)"] --> T14
    T14["Health check candidate\n(200 OK)"] --> T15
    T15["Health check recruiter\n(200 OK)"]

Détail des décisions importantes :

Login GHCR avant deploy : le registre GHCR est privé. Sans docker login préalable, docker stack deploy échoue lors du pull des images.

--with-registry-auth : transmet les credentials GHCR au daemon Docker Swarm pour qu'il puisse puller les images depuis le registre privé.

Attentes de démarrage (timeout 120s) : Docker Swarm lance les conteneurs en arrière-plan. Sans attente active, les étapes suivantes (migrations, health checks) pourraient s'exécuter avant que les services soient prêts.

JWT keypair --skip-if-exists : les clés JWT sont générées une seule fois et conservées entre les déploiements. Utiliser --overwrite invaliderait tous les tokens JWT actifs à chaque déploiement — tous les utilisateurs connectés seraient déconnectés de force.

doctrine:migrations:migrate --allow-no-migration : applique les migrations de base de données sans erreur si aucune migration n'est en attente. Garantit que le schéma DB est toujours à jour après un déploiement.

Import GeoNames conditionnel : les ~140 000 villes du dataset GeoNames ne sont importées que si la table cities est vide. Sur un premier déploiement, l'import s'exécute. Sur les suivants, il est ignoré — évite de réimporter 140k lignes à chaque déploiement.

Health checks finaux : vérifient que chaque service répond bien en HTTP après le déploiement. Si un service ne répond pas, le playbook échoue et l'erreur est visible dans les logs CI.


Variables et secrets

group_vars/all/vars.yml — Variables communes

ghcr_registry: ghcr.io
ghcr_owner:    wonderwork-io

image_tag:        latest                    # surchargé par -e "image_tag=vX.Y.Z" en CI
api_image:        "{{ ghcr_registry }}/{{ ghcr_owner }}/wonderwork-api:{{ image_tag }}"
candidate_image:  "{{ ghcr_registry }}/{{ ghcr_owner }}/wonderwork-candidate:{{ image_tag }}"
recruiter_image:  "{{ ghcr_registry }}/{{ ghcr_owner }}/wonderwork-recruiter:{{ image_tag }}"

api_port:       8000
candidate_port: 3000
recruiter_port: 3001

stack_name:     wonderwork
postgres_db:    wonderwork
postgres_user:  wonderwork

Vault Ansible — Secrets chiffrés

Les secrets (mots de passe, tokens, clés) sont chiffrés en AES-256 directement dans le dépôt Git via ansible-vault. Le fichier vault.yml est versionné — son contenu est illisible sans la clé de déchiffrement.

Pourquoi stocker les secrets dans Git (chiffrés) plutôt que dans des variables d'environnement CI ? Les secrets dans le vault font partie du code d'infrastructure — ils sont versionnés, auditables et cohérents avec les variables Ansible. Un secret CI serait opaque : on saurait qu'il existe mais pas quelle valeur est déployée sur quel environnement. Le vault permet de relire exactement ce qui est déployé.

La clé de déchiffrement (.vault_pass) est un fichier local ignoré par Git. En CI, son contenu est injecté depuis le secret GitHub ANSIBLE_VAULT_PASSWORD.

# Éditer les secrets
ansible-vault edit group_vars/staging/vault.yml

# Chiffrer une nouvelle valeur
ansible-vault encrypt_string 'valeur' --name 'vault_db_password'
Variable vault Variable Ansible Usage
vault_db_password db_password Mot de passe PostgreSQL
vault_app_secret app_secret Secret Symfony (CSRF, sessions)
vault_jwt_passphrase jwt_passphrase Passphrase des clés JWT RSA
vault_ghcr_token ghcr_token Token GitHub pour pull GHCR
vault_mailer_dsn mailer_dsn DSN Brevo (emails transactionnels)
vault_france_travail_client_id france_travail_client_id API France Travail (ROME)
vault_france_travail_client_secret france_travail_client_secret API France Travail