The problem

The order service, on Symfony 7.4, runs in development with Docker Compose and the official FrankenPHP image, as in the symfony-docker template. The code is mounted from the host (bind mount), and the development container runs as root.

On Linux, with Docker in rootful mode (the default install), every file created in the container is therefore owned by root on the host. After bin/console make:entity Order, the editor can no longer change src/Entity/Order.php without sudo. The template suggests a chown -R after the first install: it would have to be run again after each generation.

The solution

In the frankenphp_dev stage, we add a user with the developer's UID and GID. On a bind mount, the kernel only knows these numbers: the user's name does not matter.

# Dockerfile (order service)
FROM frankenphp_base AS frankenphp_dev

# ... content of the template's dev stage (php.ini, Xdebug) ...

# IDs of the host user, 1000 by default.
ARG USER_ID=1000
ARG GROUP_ID=1000

RUN <<EOF
set -eux
# The GID may already exist in the image.
if ! getent group "${GROUP_ID}" > /dev/null; then
  groupadd --gid "${GROUP_ID}" app
fi
# So may the UID.
if ! getent passwd "${USER_ID}" > /dev/null; then
  useradd --uid "${USER_ID}" --gid "${GROUP_ID}" --create-home --shell /bin/bash app
fi
# Listen on 80 and 443 without being root.
setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp
# Directories Caddy and Symfony write to.
mkdir -p /data/caddy /config/caddy /app/var
chown -R "${USER_ID}:${GROUP_ID}" /data /config /app/var
EOF

USER ${USER_ID}:${GROUP_ID}

The reasons behind these choices:

  • set -eux stops the build at the first error and prints each command. The template already enforces it with SHELL, but the block stays safe if it is copied elsewhere.
  • getent avoids a groupadd or a useradd that would fail on an ID already taken. On macOS, id -g usually returns 20, the GID of the Debian group dialout. The template's dev stage also creates a nonroot user, which gets UID and GID 1000.
  • USER gets the numbers, not the name app: if the UID already existed, app was not created.
  • setcap gives the binary the CAP_NET_BIND_SERVICE capability, which allows listening on ports below 1024. The official image already sets it, along with libcap2-bin, which provides setcap. Docker Engine also opens these ports to non-root users, but not every runtime does: we apply it again, as the FrankenPHP documentation does.
  • /data and /config hold Caddy's certificates and configuration. The template puts /app/var in an anonymous volume, which Docker fills from the image's directory. Without mkdir -p, that directory does not exist in the image and the volume belongs to root.

The compose.override.yaml targets this stage and passes it the IDs:

# compose.override.yaml
services:
  php:
    # ... rest of the template's service ...
    build:
      context: .
      target: frankenphp_dev
      args:
        USER_ID: ${USER_ID:-1000}
        GROUP_ID: ${GROUP_ID:-1000}
    volumes:
      - ./:/app
      - /app/var

On an existing project, whatever was created as root stays that way: Docker only fills an empty volume. sudo chown -R "$(id -u):$(id -g)" . gives the files back, and docker compose up -V recreates the anonymous volumes. docker compose down -v also removes caddy_data and caddy_config, and with them Caddy's local certificates.

The pitfall

The first reflex is to write ${UID} and ${GID} in the Compose file. In bash, UID is a read-only variable that is not exported: Compose does not see it. GID does not exist in bash, and zsh defines both without exporting them.

Compose then prints a warning and replaces the variable with an empty string. The empty argument overrides the ARG default, and the build stops on groupadd: invalid group ID ''. With ${UID:-1000}, the fallback applies silently: a machine whose UID is 1001 gets an image built for 1000.

So we export our own variables from the project's Makefile. They are not called UID, so that a developer can also export them from a shell: in bash, export UID=1000 fails.

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

build:
	docker compose build --pull

make build passes the right IDs to Compose. A docker compose build run by hand falls back to 1000, the usual UID of the first account on Debian and Ubuntu.

Sources