Aller au contenu principal
Retour au blog

Docker PHP 8.4 production : multi-stage, non-root et scan Trivy en CI

Flavien Métivier6 mai 202510 min

Le 10 avril 2025, l'équipe PHP publie la version 8.4.6 — un security release qui corrige plusieurs vulnérabilités dans le moteur. Si ton image de production tournait encore sur 8.4.5 buildée six semaines plus tôt, elle est toujours exposée. Et si ton pipeline CI n'a pas de gate de sécurité, rien ne t'a alerté.

Ce scénario est courant : une image construite une fois, poussée dans le registry, laissée tourner jusqu'au prochain déploiement — parfois des mois. Ce guide attaque trois problèmes de front : réduire la surface d'attaque d'une image PHP 8.4 via le multi-stage build, l'exécuter sous un utilisateur non-root pour limiter l'impact d'une compromission, et intégrer Trivy dans le pipeline CI pour transformer chaque pull request en gate de sécurité automatique. Les exemples s'appuient sur Symfony 7.2, mais la démarche s'applique à n'importe quelle application PHP.

L'image officielle php:8.4-fpm n'est pas prête pour la production

L'image officielle php:8.4-fpm est une excellente base de développement. Elle embarque des outils de build — gcc, make, les headers de compilation — parce qu'elle est conçue pour être étendue. En production, ces outils n'ont aucune utilité fonctionnelle, mais ils augmentent mécaniquement la surface d'attaque : plus de paquets signifie plus de CVE potentielles et plus de code exécutable en cas d'exploitation.

L'image Debian complète pèse autour de 450 MB ; la variante Alpine est plus contenue, mais elle fait tourner le master PHP-FPM en root par défaut. Et surtout, rien dans le workflow habituel de build et push n'analyse le contenu de l'image pour détecter des vulnérabilités connues avant qu'elle atterrisse en production. Trois risques distincts, trois décisions d'architecture à prendre.

  • Surface d'attaque étendue : outils de compilation, headers et bibliothèques inutiles en runtime multiplient les paquets exposés aux CVE
  • Exécution en root : une faille exploitée dans le code PHP ou dans une dépendance Composer donne à l'attaquant les pleins pouvoirs sur le container
  • Aucun gate CVE dans le CI : sans scanner, une image contenant une CVE CRITICAL peut être poussée et déployée sans aucune alerte

Multi-stage build — découpler l'installation des dépendances de l'image finale

Le multi-stage build résout le premier problème en séparant deux phases : celle où l'on a besoin d'outils (installation des dépendances Composer, compilation d'extensions PHP), et celle où l'image doit simplement exécuter PHP-FPM en production. Le stage builder peut être aussi lourd que nécessaire — il ne sera jamais poussé vers le registry. L'image finale n'embarque que ce dont PHP-FPM a réellement besoin à l'exécution.

Pour Symfony 7.2, le découpage naturel est le suivant : un stage composer basé sur l'image officielle Composer qui installe les dépendances de production avec --no-dev, et un stage app basé sur php:8.4-fpm-alpine qui copie le vendor/ du stage précédent. L'image finale tourne typiquement entre 100 et 130 MB selon les extensions installées, contre 500+ MB pour une image all-in-one Debian. Adapte les extensions (pdo_pgsql, pdo_mysql...) à ton driver de base de données.

# ── Stage 1 : Installation des dépendances Composer ──────────────────────────
FROM composer:2.8 AS composer

WORKDIR /app

COPY composer.json composer.lock symfony.lock* ./

RUN composer install \
    --no-dev \
    --no-scripts \
    --no-plugins \
    --prefer-dist \
    --optimize-autoloader \
    --no-interaction

# ── Stage 2 : Image finale PHP 8.4-FPM production ────────────────────────────
FROM php:8.4-fpm-alpine AS app

WORKDIR /var/www/html

# Extensions nécessaires pour Symfony 7.2
# Installation et suppression des deps de build dans un seul layer
RUN apk add --no-cache \
        icu-libs \
        libpq \
    && apk add --no-cache --virtual .build-deps \
        $PHPIZE_DEPS \
        icu-dev \
        libpq-dev \
    && docker-php-ext-install \
        intl \
        pdo_pgsql \
        opcache \
    && pecl install apcu \
    && docker-php-ext-enable apcu \
    && apk del .build-deps \
    && rm -rf /tmp/pear

# Configuration PHP et PHP-FPM
COPY docker/php/php.ini   /usr/local/etc/php/conf.d/app.ini
COPY docker/php/www.conf  /usr/local/etc/php-fpm.d/www.conf

# Dépendances Composer depuis le stage builder
COPY --from=composer /app/vendor ./vendor

# Code applicatif (après vendor pour maximiser le cache Docker)
COPY . .

# Utilisateur non-root dédié
RUN addgroup -S symfony && adduser -S symfony -G symfony \
    && mkdir -p var \
    && chown -R symfony:symfony var/

USER symfony

EXPOSE 9000
CMD ["php-fpm"]

Utilisateur non-root — appliquer la règle des moindres privilèges

Passer en utilisateur non-root est l'une des mesures les plus simples et les plus efficaces pour sécuriser un container. Si une vulnérabilité dans ton code Symfony, dans une dépendance Composer ou dans PHP lui-même permet une exécution arbitraire de commandes, l'attaquant atterrit dans le contexte de l'utilisateur symfony — sans sudo, sans accès aux sockets système, sans possibilité d'écrire en dehors des répertoires explicitement autorisés. L'image Alpine n'inclut pas sudo par défaut, ce qui élimine ce vecteur d'élévation de privilèges par construction.

La subtilité avec PHP-FPM est que le master process a historiquement besoin de root pour gérer certaines ressources système. En pratique, sur le port 9000 — supérieur à 1024 — aucun privilège réseau n'est requis. La configuration www.conf doit refléter l'utilisateur applicatif, et le répertoire var/ de Symfony (cache, logs, sessions) doit appartenir à cet utilisateur dès le docker build — c'est la raison du chown placé avant l'instruction USER symfony.

; docker/php/www.conf
[www]
user  = symfony
group = symfony

; Port TCP pas de socket Unix pour simplifier l'orchestration container
listen = 0.0.0.0:9000

pm                   = dynamic
pm.max_children      = 10
pm.start_servers     = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests      = 500

; Logs vers stdout/stderr — intégration native Docker/journald
access.log                    = /proc/self/fd/2
php_flag[display_errors]      = off
php_admin_value[error_log]    = /proc/self/fd/2
php_admin_flag[log_errors]    = on
; docker/php/php.ini

; ── OPcache ──────────────────────────────────────────────────────────────────
opcache.enable                  = 1
opcache.memory_consumption      = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files   = 20000
opcache.validate_timestamps     = 0
opcache.preload                 = /var/www/html/config/preload.php
opcache.preload_user            = symfony

; ── APCu (Symfony Cache) ─────────────────────────────────────────────────────
apc.enable_cli = 1

; ── Sécurité ─────────────────────────────────────────────────────────────────
expose_php      = Off
display_errors  = Off
log_errors      = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

; ── Performance ──────────────────────────────────────────────────────────────
realpath_cache_size = 4096K
realpath_cache_ttl  = 600

Trivy dans le CI — bloquer les CVE HIGH/CRITICAL avant le push

Besoin d'un expert Symfony ?

Réserver un appel

Trivy (Aqua Security) est un scanner open source qui analyse l'image Docker couche par couche : paquets OS Alpine ou Debian, dépendances Composer détectées via le composer.lock, et bibliothèques système liées dynamiquement. Il produit un rapport au format SARIF compatible avec l'onglet Security de GitHub, ce qui donne une visibilité directe sur les vulnérabilités sans quitter l'interface GitHub.

L'intégration CI suit un principe simple : on construit l'image localement sans push, on la scanne, et si Trivy détecte une CVE HIGH ou CRITICAL pour laquelle un correctif est disponible, le job échoue avec un exit code non-nul — le push n'a pas lieu. C'est le gate qui manque à la plupart des pipelines. L'option ignore-unfixed: true est clé : elle filtre les CVE pour lesquelles aucun patch n'existe encore dans les dépôts Alpine, pour éviter de bloquer un déploiement sur une vulnérabilité non actionnable.

name: Docker Build & Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE: ghcr.io/${{ github.repository }}/app

jobs:
  build-and-scan:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
      security-events: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build sans push
        uses: docker/build-push-action@v6
        with:
          context: .
          push:  false
          load:  true
          tags:  ${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to:   type=gha,mode=max

      - name: Scan Trivy — bloquer sur HIGH/CRITICAL
        uses: aquasecurity/trivy-action@0.20.0
        with:
          image-ref:      ${{ env.IMAGE }}:${{ github.sha }}
          format:         sarif
          output:         trivy-results.sarif
          severity:       HIGH,CRITICAL
          exit-code:      '1'
          ignore-unfixed: true

      - name: Upload SARIF vers GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

      - name: Login GHCR
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Le push n'a lieu que si le scan précédent a réussi (exit-code 0)
      - name: Push vers GHCR
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMAGE }}:${{ github.sha }}
            ${{ env.IMAGE }}:latest
          cache-from: type=gha

Gérer le bruit — faux positifs et CVE sans correctif disponible

Même avec ignore-unfixed: true, certaines CVE peuvent apparaître dans le rapport Trivy alors que leur exploitabilité dans ton contexte applicatif est nulle — par exemple, une vulnérabilité dans libexpat quand l'application ne parse aucun XML utilisateur non contrôlé. Bloquer le pipeline pour ces cas génère de la friction sans apporter de valeur de sécurité réelle.

Trivy supporte un fichier .trivyignore à la racine du dépôt pour exclure ces CVE au cas par cas. La discipline importante : chaque entrée doit être commentée avec une date d'évaluation, une justification et une date de revue planifiée. Sans traçabilité, le fichier .trivyignore devient un cimetière où les CVE disparaissent discrètement — ce qui annule l'intérêt du scanner. Le rapport SARIF uploadé dans GitHub Security reste la référence auditée pour toutes les CVE exclues.

# .trivyignore
# Format : un CVE-ID par ligne, commentaires obligatoires pour la traçabilité

# Évaluée le 2025-04-15 — libexpat / CVE-2024-50602
# Contexte : aucun parsing XML de données utilisateur non contrôlées dans l'app
# Mitigation alternative : validation stricte des entrées en amont
# Revue planifiée : 2025-07-01
CVE-2024-50602

Maintenir le niveau dans le temps — rebuild automatique et veille

Une image sécurisée au moment du build devient vulnérable si elle n'est pas reconstruite régulièrement. Alpine publie des mises à jour de sécurité indépendamment de ton cycle de déploiement applicatif. Sans rebuild, ton container embarque des paquets non patchés même si ton code n'a pas bougé depuis des semaines.

C'est exactement ce qu'illustre la sortie de PHP 8.4.6 le 10 avril 2025 : sans mécanisme de rebuild automatique, l'image en production reste exposée jusqu'au prochain commit applicatif — qui peut survenir longtemps après le security release. L'automatisation est la seule réponse scalable à ce problème de maintenance.

  • Activer Dependabot pour les Dockerfiles dans .github/dependabot.yml : il ouvre des PR automatiques quand l'image de base dispose d'une nouvelle version dans le registry
  • Programmer un rebuild hebdomadaire via un workflow schedulé : même sans commit applicatif, les paquets Alpine sont re-téléchargés à leur dernière version patchée et le scan Trivy est relancé
  • Suivre le flux RSS des security releases PHP sur php.net et le feed CVE Alpine pour anticiper les publications et prioriser les rebuilds urgents
# .github/workflows/weekly-rebuild.yml
name: Weekly Image Rebuild

on:
  schedule:
    - cron: '0 3 * * 1'  # Chaque lundi à 03h00 UTC
  workflow_dispatch:       # Déclenchable manuellement en cas de CVE urgente

env:
  IMAGE: ghcr.io/${{ github.repository }}/app

jobs:
  rebuild:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
      security-events: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build sans cache — forcer le re-téléchargement des paquets Alpine
        uses: docker/build-push-action@v6
        with:
          context: .
          push:     false
          load:     true
          tags:     ${{ env.IMAGE }}:weekly
          no-cache: true

      - name: Scan Trivy — bloquer sur HIGH/CRITICAL
        uses: aquasecurity/trivy-action@0.20.0
        with:
          image-ref:      ${{ env.IMAGE }}:weekly
          format:         sarif
          output:         trivy-results.sarif
          severity:       HIGH,CRITICAL
          exit-code:      '1'
          ignore-unfixed: true

      - name: Upload SARIF vers GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

      - name: Push si scan OK
        uses: docker/build-push-action@v6
        with:
          context: .
          push:     true
          tags:     ${{ env.IMAGE }}:latest
          no-cache: true

Multi-stage build, utilisateur non-root, gate Trivy en CI et rebuild hebdomadaire : ces quatre pratiques forment le socle minimal d'une image PHP 8.4 véritablement prête pour la production. Elles ne requièrent aucun outil propriétaire, s'intègrent dans n'importe quel pipeline GitHub Actions existant, et réduisent significativement la fenêtre d'exposition entre un security release PHP et son déploiement effectif. Le coût d'implémentation se mesure en heures ; le coût de ne pas les avoir peut être considérablement plus élevé.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.