Multi-Stage Docker Builds for PHP Applications

The neatest thing I’ve discovered about Docker in the last few months are multi-stage builds. To put it simply a multi-stage build is a Dockerfile with two or more FROM stanzas. Let’s explore what that means for a PHP project.

First, a goal: the images built for any application should 100% ready to go. That means no composer install on boot or anything like that. Multi-stage builds are a way to get there, but still have the entire build process contained in a Dockerfile.

Steps to Build a PHP Application

Whether building an image or pushing code to a running server at least two things will happen:

  1. Push/add the code
  2. Install dependencies

These two steps could take place outside of a Docker file, then simply add the prepared coded to the image.

# copy PHP code & composer config
cp -r src build/src/
cp composer.* build/
cd build && composer install && cd -

# build container
docker build -f Dockerfile build/

With a multistage docker build, the actual build steps take place inside docker itself. Here’s the actual build part of a Dockerfile for a silly little PHP app. This example is using a simply alpine linux PHP image, we’ll talk more about more complex things below.

FROM php:7.2-alpine AS deps

# install composer
RUN apk update \
    && apk add ca-certificates coreutils \
    && wget -q https://getcomposer.org/installer -O composer-setup.php \
    && wget -q https://composer.github.io/installer.sha384sum -O installer.sha384sum \
    && sha384sum installer.sha384sum \
    && php composer-setup.php --install-dir /usr/local/bin

# Add our application files here
ADD src /app/src
ADD bin /app/bin
ADD composer.* /app/
WORKDIR /app

# Install deps
RUN composer.phar install --no-dev --classmap-authoritative

Here we just install and verify composer, add our application files (step one above), and then install dependencies (step two above).

This could be shipped, but it has some fluff. Our final image doesn’t really need coreutils or composer itself installed. These two little things are not bad, but what about things like compilers and other tooling that may get added to build extensions and such? Should those be included in a final image? Nope.

For now, let’s stick with our simply example. We’ll add another FROM stanza and copy our final /app directory from the deps portion of the build.

FROM php:7.2-alpine AS deps

# see above for what's here.

# this is the REAL application container now
FROM php:7.2-alpine

# we don't need to do anything here by copy the `/app` folder from the
# `deps` stage above. Its /app folder will have all the vendor files etc
COPY --from=deps /app /app

CMD ["/app/bin/hello"]

That’s it! The final image will have our nicely built /app directory but no fluff required to install composer.

The images is smaller, but more importantly, every step of the build process is in one place. This means a complete container is a docker build away.

The dependencies are installed and classmaps generated all in the same environment as production.

What About Applications that Require Certain PHP Extensions?

The Alpine PHP images are extremely barebones compared to something like PHP packages on Ubuntu. So if additional extensions are required, I’d suggest using a common php image on which the other application container can be based.

For example, here’s one that installs the pcntl and sockets extensions.

FROM php:7.2-alpine

RUN docker-php-ext-install pcntl sockets

This can be built with something that tags it with a human understandable name and used as a base for other images.

FROM example-php:latest as deps

# install composer, app code, deps, etc here

FROM example-php:latest

COPY --from=deps /app /app

Building both containers is two steps: one step too many. A script to build both may be in order.

docker build --tag example-php:latest -f path/to/php/Dockerfile
docker build --tag example-app:latest .

What About Private Dependencies?

Composer uses a few environment variables, specifically COMPOSER_AUTH, that make this easy to handle.

COMPOSER_AUTH expects a JSON string exactly like the JSON found in ~/.composer/config.json. For example, a COMPOSER_AUTH value for fetching private dependencies from Github:

COMPOSER_AUTH='{"github-oauth": {"github.com": "YourGHOAuthTokenHere"}}' composer install

Combine that composer feature with docker build arguments and private dependencies are easy to handle.

FROM php:7.2-alpine AS deps

ARG COMPOSER_AUTH

# install composer
# add app code

# Install deps
RUN composer.phar install --no-dev --classmap-authoritative

Build arguments are available in the environment to running commands, so the RUN composer.phar install stanza will see and use that COMPOSER_AUTH value to do its work.

Docker will also, helpfully fail if the build arg is not included. The container can be built like this:

docker build \
  --build-arg 'COMPOSER_AUTH={"github-oauth":{"github.com":"YourGHOAuthTokenHere"}}' \
  --tag example-app:latest .