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.
| É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 |