Docker et PHP : les bonnes pratiques que j'aurais aimé connaître plus tôt

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
17 min de lecture
977 vues

Écouter l'article

La promesse de Docker pour un projet PHP est séduisante : un environnement identique partout, plus de "ça marche sur ma machine", un déploiement en une commande. La réalité est plus nuancée. Le premier obstacle est presque toujours le même : les permissions de fichiers, qui peuvent faire perdre du temps avant même de voir une page Laravel s'afficher.

Cet article rassemble les bonnes pratiques Docker pour une application Laravel : un docker-compose.yml de développement, un Dockerfile de production en multi-stage, le .dockerignore, Nginx devant PHP-FPM, les permissions et la gestion des variables d'environnement. Il se termine par un avis honnête sur les cas où Docker apporte de la valeur, et ceux où il n'en apporte pas.

Structurer son docker-compose.yml pour le développement

Voici une structure de fichier Docker Compose adaptée au développement avec PHP et Laravel, avec un Dockerfile.dev (PHP-FPM et xdebug) qui n'est pas détaillé ici. PHP-FPM ne parle pas HTTP : il faut donc un service Nginx devant lui, qui est le seul exposé sur l'hôte.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/var/www/html
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      - APP_ENV=local
      - DB_HOST=mysql
      - DB_DATABASE=laravel
      - DB_USERNAME=laravel
      - DB_PASSWORD=secret

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8000:80"
    volumes:
      - .:/var/www/html:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

  mysql:
    image: mysql:8.4
    restart: unless-stopped
    ports:
      - "127.0.0.1:3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_DATABASE: laravel
      MYSQL_USER: laravel
      MYSQL_PASSWORD: secret
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "127.0.0.1:6379:6379"

  phpmyadmin:
    image: phpmyadmin:5
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:80"
    environment:
      PMA_HOST: mysql
      UPLOAD_LIMIT: 100M
    depends_on:
      - mysql

volumes:
  mysql_data:

Détail de cette configuration. Les ports de MySQL, de Redis et de phpMyAdmin sont publiés sur 127.0.0.1 seulement, pour ne pas exposer ces services au réseau local. Le service redis n'est utile que si l'application s'en sert (extension phpredis ou paquet predis, avec REDIS_HOST=redis) ; sans cela, on peut le retirer. Les mots de passe du fichier sont des valeurs de développement.

Il n'y a pas de clé version: en tête de fichier. Avec Docker Compose v2 (la commande docker compose), elle est obsolète : elle est ignorée et provoque un avertissement.

Le service app est un conteneur PHP-FPM qui écoute sur le port 9000 du réseau interne. Il n'a donc pas de ports:. C'est le service nginx qui publie 8000:80 et qui transmet les requêtes PHP à app:9000 (la configuration est plus bas). Mapper 8000:80 directement sur un conteneur PHP-FPM ne fonctionnerait pas, puisque rien n'écoute sur le port 80.

Le depends_on avec condition: service_healthy est différent du simple depends_on. Sans la condition, Docker vérifie seulement que le conteneur MySQL est lancé, pas qu'il accepte des connexions. Un Artisan qui tente de migrer alors que MySQL n'a pas fini son initialisation plante au démarrage. Le healthcheck avec mysqladmin ping résout ce problème : le conteneur app ne démarre que quand MySQL répond réellement.

Le choix de l'image mysql:8.4 (série LTS publiée en 2024, toujours maintenue ; une série 9.7 LTS existe désormais, accessible par le tag lts) a une conséquence sur l'authentification. Depuis MySQL 8.0, le plugin par défaut est caching_sha2_password, que PHP supporte via mysqlnd depuis PHP 7.4.4. L'ancienne option --default-authentication-plugin=mysql_native_password est dépréciée depuis MySQL 8.0.34 et n'existe plus en 8.4, où mysql_native_password est désactivé par défaut. Un client trop ancien qui ne gère pas caching_sha2_password échoue avec une erreur explicite (du type "The server requested authentication method unknown to the client"), pas silencieusement. Si on est obligé de se connecter avec un tel client, il faut soit le mettre à jour, soit réactiver explicitement le plugin côté serveur, en connaissance de cause.

Le volume nommé mysql_data protège les données. L'image MySQL déclare déjà /var/lib/mysql comme volume : sans volume nommé, Docker crée un volume anonyme à chaque nouveau conteneur. docker compose down supprime le conteneur mais pas ce volume, qui devient orphelin ; au up suivant, un nouveau volume vide est créé et la base semble avoir disparu. Avec un volume nommé, les données survivent à down et ne sont effacées que par docker compose down -v. Un bind mount (un dossier de l'hôte) est possible, mais il pose souvent des problèmes de permissions : MySQL doit pouvoir y écrire.

phpMyAdmin avec UPLOAD_LIMIT: 100M sert à importer un dump SQL pour reproduire un bug, une opération fréquente. C'est un outil de développement : il ne doit pas être exposé en production. Son image est pinnée (phpmyadmin:5) plutôt que latest, pour la même raison que les autres.

Construire un Dockerfile de production avec les multi-stage builds

Le Dockerfile de développement et celui de production n'ont rien à voir. En développement, on monte le code source en volume et on installe xdebug. En production, l'image doit être autonome, légère, et reproductible.

Voici un Dockerfile de production pour un projet Laravel avec Vite :

# Étape 1 : dépendances PHP
FROM composer:2 AS deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader

# Étape 2 : compilation des assets
FROM node:24-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ ./resources/
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build

# Étape 3 : image finale
FROM php:8.3-fpm-alpine

RUN docker-php-ext-install pdo_mysql opcache bcmath

# Configuration OPcache pour la production
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.memory_consumption=128" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

WORKDIR /var/www/html

COPY --chown=www-data:www-data . .
COPY --from=deps --chown=www-data:www-data /app/vendor ./vendor
COPY --from=frontend --chown=www-data:www-data /app/public/build ./public/build

# Les dossiers d'exécution doivent exister et appartenir à www-data
RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \
    && chown -R www-data:www-data storage bootstrap/cache

USER www-data

# Composer a été lancé avec --no-scripts : on relance la découverte des packages
RUN php artisan package:discover --ansi \
    && php artisan route:cache \
    && php artisan view:cache

EXPOSE 9000
# La config est mise en cache au démarrage, avec les variables d'environnement réelles du conteneur
CMD ["sh", "-c", "php artisan config:cache && exec php-fpm"]

Chaque stage a son rôle. Le premier installe les dépendances Composer. Le second compile les assets avec Vite. Le troisième assemble l'image finale sans Composer, sans Node.js, sans npm : juste PHP-FPM et le code applicatif.

La force du multi-stage, c'est le cache par couches. Tant que composer.json et composer.lock ne changent pas, Docker réutilise le layer des dépendances PHP. Modifier un contrôleur ne retélécharge pas vendor/. Même logique pour les assets : tant que package.json reste stable, npm ne réinstalle rien.

Le --no-scripts est nécessaire dans le stage deps, car artisan n'existe pas encore dans ce stage : seuls composer.json et composer.lock y sont copiés. Conséquence : les scripts post-install de Composer n'ont pas tourné, et en particulier package:discover, qui génère le cache des packages et des service providers. Il faut donc le relancer dans l'image finale, une fois le code copié, ce que fait la commande php artisan package:discover. L'autoloader "optimisé" construit au stage deps ne connaît pas non plus les classes de app/ (le code n'était pas encore là), mais l'autoload PSR-4 les résout quand même.

L'option opcache.validate_timestamps=0 est spécifique à la production. Elle dit à OPcache de ne jamais vérifier si les fichiers PHP ont changé sur le disque. En production, le code ne change pas : un nouveau déploiement est nécessaire pour ça. On évite ainsi des appels système stat() à chaque requête. C'est aussi la raison pour laquelle ce Dockerfile n'est pas adapté au développement avec bind mount, où les modifications de fichiers ne seraient pas vues.

Côté build, route:cache et view:cache fonctionnent sans base de données et peuvent être lancés pendant le build. php artisan migrate, lui, a besoin d'une connexion MySQL. Lancer les migrations dans le Dockerfile est une erreur courante, que beaucoup de tutoriels montrent. Les migrations doivent être exécutées au runtime, quand le conteneur a accès à la base de données, via un entrypoint script ou un job d'initialisation.

Le config:cache est lui placé dans la commande de démarrage plutôt que dans le build. Exécuté au build, il figerait dans l'image les valeurs d'environnement présentes à ce moment-là (sans .env, donc des valeurs vides ou par défaut). Au démarrage, il lit les vraies variables du conteneur.

Et sur le portfolio ? Le Dockerfile du dépôt est bien multi-stage, mais l'image finale regroupe php-fpm, nginx, supervisord et un worker de queue dans un seul conteneur, exécuté en root, sans USER www-data. La version ci-dessus, avec un utilisateur non privilégié, est celle que je recommanderais pour un déploiement conteneurisé. Le site lui-même n'est pas déployé avec Docker : la CI GitHub Actions envoie les fichiers par FTP sur un hébergement mutualisé (voir la section sur les cas où Docker n'est pas la bonne réponse).

Le .dockerignore

Le .dockerignore est souvent oublié ou copié sans relecture. Il a pourtant un impact direct sur la taille de l'image et la vitesse de build. À chaque docker build, Docker envoie l'intégralité du contexte (le dossier du projet) au daemon. Sans .dockerignore, ça inclut node_modules/, vendor/, .git/, les logs, et tout le reste.

Voici un .dockerignore adapté à un projet Laravel :

node_modules/
vendor/
.git/
.env
.env.*
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
tests/
docker-compose*.yml
*.md
.editorconfig
.gitignore
phpunit.xml

Le dossier vendor/ est exclu parce que le Dockerfile le reconstruit via Composer dans le stage deps. Même logique pour node_modules/ et le stage frontend. Inclure ces dossiers dans le contexte ne ferait que ralentir le build pour du code qui sera remplacé de toute façon.

Exclure .env est une mesure de sécurité. Le fichier .env ne doit jamais se retrouver dans une image Docker. Les secrets doivent venir de l'environnement runtime : variables d'environnement Docker, secrets Kubernetes, ou un gestionnaire de secrets comme Vault.

Remarque : les dossiers storage/framework/* sont vidés par le .dockerignore, d'où le mkdir -p dans le Dockerfile, qui les recrée.

Nginx comme reverse proxy devant PHP-FPM

PHP-FPM ne sert pas les fichiers statiques. Il ne gère pas non plus le HTTPS, la compression gzip, ni le cache navigateur. C'est le rôle de Nginx, et la configuration n'est pas compliquée, mais quelques détails font une vraie différence. Ce fichier est celui monté dans le service nginx du compose (docker/nginx/default.conf).

server {
    listen 80;
    server_name localhost;
    root /var/www/html/public;
    index index.php;

    # Sécurité : bloquer l'accès aux fichiers cachés
    location ~ /\.(?!well-known).* {
        deny all;
    }

    # Assets compilés par Vite (noms hashés) : cache long
    location ^~ /build/ {
        add_header Cache-Control "public, max-age=2592000, immutable";
        try_files $uri =404;
    }

    # Toutes les requêtes passent par Laravel
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;   # refuse un chemin .php inexistant
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
    }
}

La ligne location ~ /\.(?!well-known).* bloque l'accès à tous les fichiers commençant par un point (.git, .htaccess, un .env égaré), sauf .well-known, nécessaire à Let's Encrypt. Comme root pointe sur public/, le .env à la racine du projet n'est normalement pas servi : cette règle est une protection supplémentaire, utile si un fichier sensible se retrouve par erreur dans public/.

La directive Cache-Control: public, immutable ne doit s'appliquer qu'aux fichiers dont le nom change quand le contenu change. C'est le cas des assets dans public/build/, que Vite génère avec un hash dans le nom : un fichier app-3f8a2c.js ne change jamais de contenu, et si le code change, Vite génère un nouveau hash. Appliquer immutable à tous les .png ou .jpg de public/ serait risqué : ces fichiers, eux, ne sont pas hashés.

Les paramètres fastcgi_buffer_size et fastcgi_buffers évitent des avertissements dans les logs Nginx quand les réponses PHP sont volumineuses, typiquement les pages avec beaucoup de contenu HTML ou les réponses JSON d'API.

Dernière précision : le conteneur Nginx doit avoir accès au dossier public/ (assets et index.php). En développement, le bind mount du compose s'en charge. En production avec des images séparées, il faut copier public/ dans l'image Nginx ou partager un volume entre les deux conteneurs.

La gestion des permissions : le piège classique

C'est probablement le problème le plus fréquent quand on conteneurise une application Laravel. Le framework a besoin d'écrire dans storage/ et bootstrap/cache/. Mais dans un conteneur, les fichiers copiés appartiennent à root par défaut.

La solution que l'on trouve le plus souvent sur Stack Overflow est chmod -R 777 storage. C'est à éviter : elle donne tous les droits à tous les utilisateurs du système, et masque le vrai problème, qui est la propriété des fichiers.

La bonne pratique est d'utiliser --chown dans les instructions COPY du Dockerfile :

COPY --chown=www-data:www-data . .

Puis de passer à l'utilisateur www-data avant le CMD :

USER www-data

PHP-FPM tourne alors en tant que www-data, il possède les fichiers, il peut écrire dans storage/. Pas besoin de chmod 777.

L'ordre compte : si php artisan ... est exécuté en root pendant le build, les fichiers qu'il crée (caches de vues, de routes) appartiennent à root et www-data ne pourra pas les réécrire. C'est pourquoi le USER www-data du Dockerfile ci-dessus est placé avant les commandes artisan.

Si certains fichiers doivent être modifiés au runtime (logs, cache framework, sessions fichier), il faut s'assurer que les dossiers existent et que www-data y a accès. Le Dockerfile s'en charge avec une commande explicite :

RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \
    && chown -R www-data:www-data storage bootstrap/cache

On évite volontairement la syntaxe storage/framework/{cache,sessions,views} : l'expansion des accolades est une fonctionnalité de bash, absente du sh d'Alpine, où elle créerait un dossier littéralement nommé {cache,sessions,views}. Le Dockerfile du dépôt suit la même règle : les chemins y sont listés explicitement.

Variables d'environnement : la discipline env() vs config()

Laravel charge le fichier .env au démarrage et rend ses valeurs accessibles via env(). Après un php artisan config:cache, le fichier .env n'est plus chargé : toutes les valeurs des fichiers config/*.php sont compilées dans le cache de configuration, et les appels env() en dehors de ces fichiers ne voient plus que les variables d'environnement réelles du processus (système ou Docker), ou retournent null si elles n'y sont pas.

C'est un problème insidieux. En développement, sans cache de configuration, tout fonctionne. En production avec un config:cache (ce qui est recommandé), les env() dans les contrôleurs ou les modèles ne lisent plus le .env et peuvent retourner null.

La bonne pratique :

// Dans config/services.php
'api_key' => env('EXTERNAL_API_KEY'),

// Dans un contrôleur : toujours config(), jamais env()
$apiKey = config('services.api_key');

Cette discipline est d'autant plus importante avec Docker. Les variables passées au conteneur via docker-compose.yml ou docker run -e sont de vraies variables d'environnement, que env() lit dans les fichiers de configuration. Mais si le config:cache est exécuté au build de l'image, il fige les valeurs présentes à ce moment-là, qui ne correspondent pas à l'environnement de production. Il faut donc soit cacher la config au démarrage du conteneur (comme dans le CMD ci-dessus), soit ne pas la cacher dans l'image. Dans tous les cas, tous les env() doivent rester dans les fichiers de configuration.

Automatiser avec un Makefile

Les commandes Docker sont verbeuses. Un Makefile simplifie les opérations quotidiennes :

.PHONY: up down build shell test migrate fresh logs

up:
	docker compose up -d

down:
	docker compose down

build:
	docker compose build --no-cache

shell:
	docker compose exec app sh

test:
	docker compose exec app php artisan test --parallel

migrate:
	docker compose exec app php artisan migrate

fresh:
	docker compose exec app php artisan migrate:fresh --seed

logs:
	docker compose logs -f app

make up, make test, make shell : trois commandes à retenir au lieu de six. L'option --parallel sur les tests exploite les multiples cœurs de la machine, à condition d'avoir installé brianium/paratest en dépendance de développement. Le shell est sh car les images Alpine n'ont pas bash par défaut.

Le fresh est réservé au développement. Il efface toute la base et relance les migrations avec les seeders. En production, c'est migrate --force, jamais fresh.

Quand Docker n'est pas la bonne réponse

Docker n'est pas toujours applicable. Si l'hébergement cible est un mutualisé sans support Docker, sans accès root, avec un dossier public_html et un accès SSH limité, maintenir un Dockerfile et un docker-compose.yml qui n'atteindront jamais la production est du temps perdu.

Dans ce cas, la stratégie est différente. On garde Docker pour la base de données en local si on veut (un simple docker compose up pour MySQL), et l'on concentre l'effort sur un pipeline de déploiement adapté. Avec un accès SSH, les étapes ressemblent à ceci :

# En local : compiler les assets
npm run build

# Sur le serveur
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
chmod -R 775 storage bootstrap/cache

C'est le cas du portfolio, avec une variante : sans passer par SSH, c'est la CI GitHub Actions (Pint, PHPStan, tests, build) qui installe les dépendances sans les paquets de développement, compile les assets et génère les caches de routes, d'événements et de vues, puis envoie le résultat par FTP (action SamKirkland/FTP-Deploy-Action).

Ce n'est pas moins professionnel qu'un déploiement Docker : c'est un choix adapté à une contrainte. Le piège serait de forcer Docker dans un environnement qui ne le supporte pas, juste parce que "tout le monde fait du Docker".

La décision d'utiliser ou non Docker en production dépend de l'infrastructure cible. Un VPS avec accès root ? Docker a du sens. Un hébergement mutualisé ? Un script de déploiement bien écrit fait le même travail.

Ce qu'il faut retenir

Docker est un outil de standardisation d'environnement et de déploiement. Il apporte de la valeur quand il résout des problèmes concrets : la parité dev/prod, la reproductibilité des builds, l'isolation des services.

Les multi-stage builds réduisent la taille des images et accélèrent le CI. Les healthchecks évitent les race conditions entre services. Le .dockerignore accélère les builds et garde le .env hors de l'image, comme les migrations restent hors du build. Les volumes nommés protègent les données, et des tags d'image pinnés (mysql:8.4 plutôt que latest) évitent les surprises. Et la discipline config() au lieu de env() rend l'application compatible avec n'importe quel système d'injection de variables, Docker ou pas.

Conteneuriser par réflexe sans se demander si l'infrastructure cible le justifie ne sert à rien : un Dockerfile bien écrit pour un serveur qui ne supporte pas Docker reste du code mort. Un script de déploiement adapté à la réalité est plus utile.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.