Le problème

Le service order, sous Symfony 7.4, tourne en développement avec Docker Compose et l'image officielle de FrankenPHP, comme dans le gabarit symfony-docker. Le code est monté depuis l'hôte (bind mount), et le conteneur de développement tourne en root.

Sous Linux, avec Docker en mode root (l'installation par défaut), chaque fichier créé dans le conteneur appartient donc à root sur l'hôte. Après bin/console make:entity Order, l'éditeur ne peut plus modifier src/Entity/Order.php sans sudo. Le gabarit propose un chown -R après la première installation : il faudrait le relancer après chaque génération.

La solution

Nous ajoutons à l'étape frankenphp_dev un utilisateur qui porte l'UID et le GID du développeur. Sur un bind mount, le noyau ne connaît que ces numéros : le nom de l'utilisateur ne compte pas.

# Dockerfile (service order)
FROM frankenphp_base AS frankenphp_dev

# ... contenu de l'étape dev du gabarit (php.ini, Xdebug) ...

# Identifiants de l'utilisateur de l'hôte, 1000 par défaut.
ARG USER_ID=1000
ARG GROUP_ID=1000

RUN <<EOF
set -eux
# Le GID peut déjà exister dans l'image.
if ! getent group "${GROUP_ID}" > /dev/null; then
  groupadd --gid "${GROUP_ID}" app
fi
# L'UID aussi.
if ! getent passwd "${USER_ID}" > /dev/null; then
  useradd --uid "${USER_ID}" --gid "${GROUP_ID}" --create-home --shell /bin/bash app
fi
# Écouter sur 80 et 443 sans être root.
setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp
# Dossiers où Caddy et Symfony écrivent.
mkdir -p /data/caddy /config/caddy /app/var
chown -R "${USER_ID}:${GROUP_ID}" /data /config /app/var
EOF

USER ${USER_ID}:${GROUP_ID}

Les raisons de ces choix :

  • set -eux arrête le build à la première erreur et affiche chaque commande. Le gabarit l'impose déjà avec SHELL, mais le bloc reste sûr s'il est copié ailleurs.
  • getent évite un groupadd ou un useradd qui échouerait sur un identifiant pris. Sur macOS, id -g renvoie en général 20, le GID du groupe Debian dialout. L'étape dev du gabarit crée aussi un utilisateur nonroot, qui reçoit l'UID et le GID 1000.
  • USER reçoit les numéros, pas le nom app : si l'UID existait déjà, app n'a pas été créé.
  • setcap donne au binaire la capacité CAP_NET_BIND_SERVICE, qui autorise l'écoute sur les ports inférieurs à 1024. L'image officielle la pose déjà, avec libcap2-bin qui fournit setcap. Docker Engine ouvre aussi ces ports aux utilisateurs non root, mais pas tous les moteurs : nous la réappliquons, comme la documentation de FrankenPHP.
  • /data et /config reçoivent les certificats et la configuration de Caddy. Le gabarit place /app/var dans un volume anonyme, que Docker remplit à partir du dossier de l'image. Sans mkdir -p, ce dossier n'existe pas dans l'image et le volume appartient à root.

Le compose.override.yaml cible cette étape et lui passe les identifiants :

# compose.override.yaml
services:
  php:
    # ... reste du service du gabarit ...
    build:
      context: .
      target: frankenphp_dev
      args:
        USER_ID: ${USER_ID:-1000}
        GROUP_ID: ${GROUP_ID:-1000}
    volumes:
      - ./:/app
      - /app/var

Sur un projet existant, ce qui a été créé en root le reste : Docker ne remplit qu'un volume vide. sudo chown -R "$(id -u):$(id -g)" . rend les fichiers, et docker compose up -V recrée les volumes anonymes. docker compose down -v supprime aussi caddy_data et caddy_config, donc les certificats locaux de Caddy.

Le piège

Le premier réflexe est d'écrire ${UID} et ${GID} dans le fichier Compose. Dans bash, UID est une variable en lecture seule et non exportée : Compose ne la voit pas. GID n'existe pas dans bash, et zsh définit les deux sans les exporter.

Compose affiche alors un avertissement et remplace la variable par une chaîne vide. L'argument vide écrase la valeur par défaut de ARG, et le build s'arrête sur groupadd: invalid group ID ''. Avec ${UID:-1000}, le repli s'applique sans un mot : un poste dont l'UID vaut 1001 reçoit une image construite pour 1000.

Nous exportons donc nos propres variables depuis le Makefile du projet. Elles ne s'appellent pas UID, pour qu'un développeur puisse aussi les exporter depuis son shell : dans bash, export UID=1000 échoue.

# Makefile
export USER_ID := $(shell id -u)
export GROUP_ID := $(shell id -g)

build:
	docker compose build --pull

make build passe les bons identifiants à Compose. Un docker compose build lancé à la main retombe sur 1000, l'UID habituel du premier compte sous Debian et Ubuntu.

Sources