Le déploiement « à la main » — un transfert de fichiers, une commande lancée en SSH, une correction rapide dans un fichier de configuration du serveur — fonctionne jusqu'au jour où plus personne ne sait ce qui tourne réellement en production. Nous avons adopté une règle stricte : aucune modification manuelle sur le serveur. Voici le flux qui la rend tenable, pour des sites WordPress et des applications Laravel hébergés sur un même serveur.

Le principe : le dépôt est la seule source de vérité

Le flux tient en une ligne : modification en local → commit → push → pipeline → serveur. Le serveur n'est jamais modifié directement, ni par transfert de fichiers, ni par édition en SSH, ni par une commande lancée dans un conteneur.

Ce qu'on y gagne est concret. Chaque état de la production correspond à un commit identifiable. Un correctif appliqué en urgence ne disparaît pas au déploiement suivant, puisqu'il est dans le dépôt. Et reconstruire le serveur après une perte devient une question de relancer les pipelines, pas de se souvenir de ce qui avait été bricolé.

Le prix à payer est une discipline : même pour une correction d'une ligne, on passe par le pipeline. C'est quelques minutes de plus, et c'est précisément ce qui garantit que la correction survivra.

L'organisation sur le serveur

Chaque application a son répertoire, qui est un clone de son dépôt, et son fichier docker-compose.yml. Un proxy Nginx installé sur l'hôte reçoit tout le trafic et le route vers les conteneurs, qui n'écoutent que sur l'interface locale :

ports:
  - "127.0.0.1:8090:80"

La mention 127.0.0.1 est importante : sans elle, Docker publie le port sur toutes les interfaces et contourne le pare-feu de l'hôte, rendant le conteneur joignable directement depuis Internet, sans passer par le proxy, ses certificats et ses limitations.

Les conteneurs qui doivent se parler partagent un réseau Docker commun et s'appellent par leur nom de conteneur. C'est ce qui permet à WordPress d'interroger l'API Laravel sans sortir sur Internet (voir découpler WordPress d'une API externe).

Le pipeline

Le job de déploiement s'exécute sur un runner GitLab dédié, uniquement sur la branche principale. Il se connecte au serveur en SSH avec une clé stockée dans une variable protégée, et y exécute une séquence courte :

cd ~/greenlog-portal
git fetch --all
git reset --hard origin/main

# Fichier d'environnement écrit depuis la variable CI (voir plus bas)

GIT_SHA=$(git rev-parse --short HEAD)
git tag "deploy-$(date +%Y-%m-%d-%H-%M)-${GIT_SHA}"

docker compose up -d --build

git reset --hard plutôt que git pull : si quelqu'un a malgré tout modifié un fichier sur le serveur, le pull échouerait sur un conflit ; le reset rétablit l'état du dépôt. C'est la traduction technique de la règle « le serveur n'a pas d'état propre ». L'étiquette datée permet de savoir, depuis le serveur, quel commit est en production et quand il a été déployé.

Les secrets : ni dans le dépôt, ni sur un poste

Le fichier .env de production contient mots de passe et clés d'API. Il n'est pas versionné. Son contenu complet est stocké dans une variable GitLab protégée et masquée ; le pipeline l'encode, le transmet au serveur et l'y écrit à chaque déploiement :

if [ -n "${_ENV_B64}" ]; then
  printf '%s' "${_ENV_B64}" | base64 -d > .env
elif [ ! -f .env ] || ! grep -q "APP_KEY" .env; then
  echo "ERREUR : .env manquant ou incomplet"
  exit 1
fi

L'encodage en base64 évite que les caractères spéciaux des mots de passe soient interprétés par le shell pendant le transfert. Et le garde-fou final refuse de démarrer une application sans sa clé de chiffrement : mieux vaut un déploiement en échec qu'une application qui tourne avec une configuration par défaut.

Conséquence pratique : modifier un secret, c'est modifier la variable dans GitLab puis relancer le pipeline. Jamais éditer le fichier sur le serveur, où la modification serait écrasée au déploiement suivant.

La vérification de santé fait partie du déploiement

Un déploiement n'est pas terminé quand les conteneurs sont démarrés, mais quand l'application répond. Le pipeline interroge donc le service juste après son démarrage, avec l'en-tête Host du domaine réel, et échoue si la réponse n'est pas un 200 — en affichant au passage les dernières lignes de journaux pour accélérer le diagnostic :

HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Host: greenlog.fr" http://localhost:8090)
if [ "$HTTP_CODE" != "200" ]; then
  docker compose logs --tail=50
  exit 1
fi

Un pipeline rouge est visible et notifié ; un site cassé mais « déployé avec succès » ne l'est pas. Pour les sites derrière un cache de proxy, rappelons qu'une vérification par l'URL publique peut interroger le cache plutôt que la nouvelle version : c'est pourquoi celle-ci s'adresse directement au conteneur. Le sujet est développé dans notre guide sur le cache Nginx.

Le piège de docker compose : ce qui n'est pas recréé

docker compose up -d --build reconstruit les images dont le Dockerfile ou le contexte a changé, et recrée les conteneurs dont la définition a changé. Mais notre pipeline de l'API ne visait que les services applicatifs — Laravel et ses workers de file d'attente — et jamais Redis ni MySQL.

Nous l'avons appris lors d'un incident : un Redis sous-dimensionné était tué en boucle par manque de mémoire. La correction — une mémoire maximale et une politique d'éviction — a été commitée, le pipeline est passé au vert… et le conteneur Redis a continué de tourner avec son ancienne commande, puisqu'il n'était pas concerné par le déploiement. Il a fallu ajouter au pipeline une recréation explicite du service :

docker compose up -d --no-deps greenlog-api-redis

La règle qui en découle : toute modification de configuration d'un service d'infrastructure doit s'accompagner d'une ligne de pipeline qui le recrée, sinon elle n'est pas déployée, quel que soit l'état du pipeline. Le récit complet est dans notre guide sur la panne de Redis par manque de mémoire.

Limiter chaque conteneur

Sur un serveur partagé entre plusieurs applications, un conteneur qui consomme toute la mémoire fait tomber les autres. Chaque service a donc une limite explicite, dimensionnée d'après son usage réel : 1 Go pour le conteneur WordPress, 512 Mo pour son Redis, 256 Mo pour le conteneur de tâches planifiées. Pour Redis, la limite du conteneur doit laisser de la marge au-dessus de sa propre limite mémoire (maxmemory) : certaines opérations internes peuvent temporairement dépasser la consommation nominale.

Même logique pour les journaux, plafonnés par conteneur, et pour les contrôles de santé : un healthcheck dans la définition du service permet à Docker de savoir si un conteneur démarré est réellement opérationnel.

Ce que ce flux ne couvre pas

Ce montage est volontairement simple, et il a des limites qu'il faut connaître :

Pour un serveur unique et quelques sites, ces compromis sont raisonnables. L'essentiel — un état de production connu, reproductible et versionné — est acquis.