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 -euxstops the build at the first error and prints each command. The template already enforces it withSHELL, but the block stays safe if it is copied elsewhere. -
getentavoids agroupaddor auseraddthat would fail on an ID already taken. On macOS,id -gusually returns 20, the GID of the Debian groupdialout. The template's dev stage also creates anonrootuser, which gets UID and GID 1000. -
USERgets the numbers, not the nameapp: if the UID already existed,appwas not created. -
setcapgives the binary theCAP_NET_BIND_SERVICEcapability, which allows listening on ports below 1024. The official image already sets it, along withlibcap2-bin, which providessetcap. Docker Engine also opens these ports to non-root users, but not every runtime does: we apply it again, as the FrankenPHP documentation does. -
/dataand/confighold Caddy's certificates and configuration. The template puts/app/varin an anonymous volume, which Docker fills from the image's directory. Withoutmkdir -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
- FrankenPHP, Running as a non-root user and Dockerfile of the official image.
-
symfony-docker,
Dockerfile,
compose.override.yaml
and
Troubleshooting:
nonrootuser,/app/varvolume,chownon Linux. - Docker, Dockerfile reference, Compose, build and interpolation: unset variables.
-
Docker,
volumes,
docker compose up
and
docker compose down: empty volume,
-Vand-voptions. - Docker Engine, privileged ports without capabilities.
- Linux, capabilities(7) and getent(1); Debian, libcap2-bin.
- Shells and make, Bash Variables, zsh parameters and export in GNU make.