Laravel Docker Permission Madness
I already wrote about the basic setup of my docker images in my other post but it still had some permission issues. So I decided to write a little follow-up:
The Docker Images
In the context of my project "flow" there are a few images involved:
laravel-base
- FROM node 20
- FROM php alpine with extensions and composer
As the name suggests, it is a base image with just node and php (with relevant extensions). It can also be used in CI/CD scripts to run basic tasks like composer install without spinning up the whole laravel-app image
laravel-app
- FROM laravel-base
- supervisor
- nginx
- php-fpm
- laravel queue worker
- cron for laravel scheduler
- nginx
- crontab
- ENTRYPOINT entrypoint.sh
- user setup
- CMD start.sh
- waiting for the database to be available
- setting folder perms
- clear cache, run migrations, ensure storage link, optimize and cache everything for production
This one acts as the base image for a laravel-app, it includes everything you need to run the website and the worker and scheduler in the background. It also has the start script that sets everything up
laravel-app:xdebug
- php xdebug extension
This is just a variation that adds xdebug ontop which can be used locally instead of the default image, this way we do not have xdebug in production.
flow
- FROM laravel-app
- just copies the sourcecode into var/www/html
The image for the laravel app itself is relatively boring, it just copies the source code to the web folder.
Container start
The docker container runs as root, this is necessary to dynamically decide as which user we run supervisor, php, nginx and so on.
The entrypoint script checks if two environment variables are set, UID and GID. If they are NOT set, the user is "www-data", otherwise a user and group lrv-<ID>
are created if they do not exist yet:
#!/bin/sh
# setup the user
USER_PREFIX="lrv"
USERNAME="www-data"
GROUPNAME="www-data"
if [[ -n "$UID" && -n "$GID" ]]; then
# if we got UID and GID use them
USERNAME="${USER_PREFIX}-${UID}"
GROUPNAME="${USER_PREFIX}-${GID}"
# Create group if it doesn't already exist
if ! getent group $GROUPNAME > /dev/null; then
addgroup -g $GID "$GROUPNAME"
fi
# Create user if it doesn't already exist
if ! id -u lrv-$UID > /dev/null 2>&1; then
adduser -D -H -u $UID -G "$GROUPNAME" "$USERNAME"
fi
fi
exec "$@"
Services
- php-fpm spawns its workers as
www-data
by default - nginx runs as
www-data
because its configured in the nginx.conf - laravel-worker runs as
www-data
because its configured in the supervisor conf - the laravel setup (cache clear, migrations, optimize, ...) is run as
www-data
- the project folder is chowned by
www-data
start.sh
...
USER_PREFIX="lrv"
USERNAME="www-data"
GROUPNAME="www-data"
...
# wait for db
echo "Waiting for Database ${DB_HOST}:${DB_PORT:-3306}"
/wait-for.sh ${DB_HOST}:${DB_PORT:-3306} -- echo "Database is up"
# laravel setup
chown -Rf "$USERNAME":"$GROUPNAME" /var/www/html
su-exec "$USERNAME" /laravel-setup.sh
exec /usr/bin/supervisord -n -c /etc/supervisord.conf
Running as different user
If UID and GID are set:
- the php-fpm config is modified via sed to run as
lrv-<ID>
- the nginx config is modified via sed to run as
lrv-<ID>
- the supervisor config is modified to run the laravel-queue worker as
lrv-<ID>
- the crontab /etc/crontabs/www-data is moved to /etc/crontabs/
lrv-<ID>
- the laravel setup is run as
lrv-<ID>
- the project folder is chowned by
lrv-<ID>
start.sh
...
USER_PREFIX="lrv"
USERNAME="www-data"
GROUPNAME="www-data"
if [[ -n "$UID" && -n "$GID" ]]; then
USERNAME="${USER_PREFIX}-${UID}"
GROUPNAME="${USER_PREFIX}-${GID}"
sed -i \
-e "s/user = www-data/user = $USERNAME/g" \
-e "s/group = www-data/group = $GROUPNAME/g" \
-e "s/;listen.owner = www-data/listen.owner = $USERNAME/g" \
-e "s/;listen.group = www-data/listen.group = $GROUPNAME/g" \
/usr/local/etc/php-fpm.d/www.conf
sed -i \
-e "s/user=www-data/user=$USERNAME/g" \
/etc/supervisord.conf
sed -i \
-e "s/user www-data www-data/user $USERNAME $GROUPNAME/g" \
/etc/nginx/nginx.conf
mv /etc/crontabs/www-data "/etc/crontabs/$USERNAME"
fi
...
So in the local world I just pass my current user to the docker container to have vscode and the app work as the same user
services:
flow:
image: codingkiwi/laravel-app:8.3-xdebug
ports:
- '80:80'
volumes:
- '.:/var/www/html'
environment:
....
UID: ${UID:-1000}
GID: ${GID:-1000}
networks:
- flow
depends_on:
- mariadb
extra_hosts:
- "host.docker.internal:host-gateway"
Laravel artisan
While this all works, one thing is a bit annoying.. Running a command in the container locally requires passing the user every time:
docker compose exec -u ${UID:-1000}:${GID:-1000} flow php artisan
A shorter variant of this is just spawning an ephemeral container:
docker compose run --rm flow php artisan
This way the UID and GID envs from the docker-compose.yml work, but the command php artisan
is now run as root because the container runs as root by default. So I added a little workaround in the entrypoint.sh
...
# Determine the command to run
if [ "$1" = "/start.sh" ]; then
# Run the default command as root
exec "$@"
else
exec su-exec "$USERNAME" "$@"
fi
This means if the cmd is /start.sh
which is the default defined in the dockerfile, we run as root. Otherwise we switch to the www-data or $UID based user.
You might asks why not use docker compose exec flow php artisan
? The reason is that docker compose exec spawns a new command in the container, it does not use the entrypoint.sh.
So here are the options:
docker compose exec flow su-exec "lrv-$UID" php artisan
docker compose exec -u ${UID:-1000}:${GID:-1000} flow php artisan
docker compose run --rm flow php artisan