Understanding File Permissions

Working with file permissions is one of the biggest headaches when working with PHP + Docker. This generally is because the PHP server also requires a web server to serve static files. By default, this means multiple users are created in the container, and permissions can get out of hand quickly.

Traditional PHP File Permissions Configuration

Even more frustrating: Development Environments

Even if someone configured a single user in the container to run both the PHP server and the web server, things get even more complicated in development environments. For example, if you have Alice running her Windows Machine with WSL2, she might have a user ID of 1001. Then you have Bob running his Ubuntu workstation with a user ID of 1002. Meanwhile, Charlie is running his Docker on his macOS machine (that runs a tiny VM) that has a totally different file permission experience compared to Windows and Linux because of the file system differences.

If a volume is mounted from the container to the host, the container will write files to the host as 33:33, which will require sudo/root permissions to edit and delete files.

Our industry attempted workarounds

We've seen experiences that allow users to provide an environment variable of PUID and PGID. Although this is a great user experience, it requires the container user to be privileged, which is a major "no-no" in the security world. It also had downstream file permission errors if the container failed on initialization where logs would be created by the root user and no longer writable by the www-data user.

Our solution

We focus on providing the tools to give sysadmins the ability to:

  • Keep their containers unprivileged by default
  • Allow the dynamic reconfiguration of the container user and group ID (at build time only)

It's a bummer that we can only set the user and group ID at build time, but it's a small price to pay for the security benefits of running unprivileged containers.

How it works

  • By default, all our images run www-data as the user (33:33 for Debian and 82:82 for Alpine)
  • We provide a script that can be called at build time to change the UID and GID of www-data (called docker-php-serversideup-set-id)
  • If you need to update permissions of service files (example: NGINX, Apache, Unit, etc), you can run the docker-php-serversideup-set-file-permissions at build
  • We will use a multi-stage build to ensure that the docker-php-serversideup-set-id script is not executed in the construction of the final image

Example

Here's an example of ensuring our UID/GID of www-data will match the development UID/GID of the host machine, while preserving the default UID/GID of 33:33 for the final image:

Dockerfile

############################################
# Base Image
############################################
FROM serversideup/php:8.3-fpm-nginx-bookworm AS base

############################################
# Development Image
############################################
FROM base AS development

# Switch to root so we can do root things
USER root

# Save the build arguments as a variable
ARG USER_ID
ARG GROUP_ID

# Use the build arguments to change the UID 
# and GID of www-data while also changing 
# the file permissions for NGINX
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
    \
    # Update the file permissions for our NGINX service to match the new UID/GID
    docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx

# Drop back to our unprivileged user
USER www-data

############################################
# Production Image
############################################

# Since we're calling "base", production isn't
# calling any of that permission stuff
FROM base AS production

# Copy our app files as www-data (33:33)
COPY --chown=www-data:www-data . /var/www/html

To show a simple Docker Compose file example for development, we could use:

docker-compose.yml

services:
  php:
    build:
      context: .
      target: development
      args:
        # UID and GID must be set as environment variables on the host machine
        USER_ID: $UID
        GROUP_ID: $GID
    ports:
      - 80:8080
    volumes:
      - .:/var/www/html

When we run docker compose up, our compose file directs us to build the development target. This target will run the docker-php-serversideup-set-id script to change the UID and GID of www-data to match the host machine (assuming $UID and $GID are set in a zsh/bash profile or something similar). This will allow us to run the container as an unprivileged user while still having the correct permissions to read and write files.

The best thing is the user can delete files off of their machine without being prompted for sudo permissions. This is because we're aligning the UID/GID of the container with the host machine.

When it comes to building our image for production, we just use the production target, which will copy the files as www-data with the default UID/GID of 33:33.

An optimized experience from development to production

If you like the concepts above and you're looking for an optimized experience for developers (especially when it comes simplifying the setting of UID/GID), we recommend checking out our other open source project Spin. Spin is a lightweight wrapper for Docker Compose that allows you to manage your environment from development to production.

Learn more about Spin →