Laravel Docker Permission Madness

Laravel Docker Permission Madness
Photo by Kaffeebart / Unsplash

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