Breeze – Simple Deployments

Learn how to use and scale Docker in local, test, and production

Docker Build Stages: Making your Dockerfile production-ready

2019-03-22 6 min read Leo Sjöberg

Now that our docker image contains everything we need for local development, how do we get it ready for production? Production is quite different from local – you won’t be binding your local volume into prod, instead the code has to ship in the container.

You also won’t use Xdebug in production, and you won’t want your composer devDependencies included. Despite all that, we don’t want to have to maintain multiple Dockerfiles. It would be great if we could have a single Dockerfile that allowed us to build for both production and local, without including local dependencies in production and vice versa. This is exactly what “staged builds” allows us to do.

Our first stage

As you may recall, our original Dockerfile started with

FROM php:7.3-fpm

This was to indicate that we wanted to base our image off of the official PHP 7.3 FPM image. Now we’re about to change that, to this:

FROM php:7.3-fpm AS base

The AS instruction allows us to give a name to this stage of the build. Why we do this will become clear in a brief moment.

Your Dockerfile, if based on my previous posts, should now look something like this

FROM php:7.3-fpm AS base

RUN apt-get update && apt-get install -y \
    curl \
    libssl-dev \
    zlib1g-dev \
    libicu-dev \
    libmcrypt-dev
RUN docker-php-ext-configure intl
RUN docker-php-ext-install pdo_mysql mbstring intl opcache mcrypt

RUN usermod -u 1000 www-data

WORKDIR /var/www/html

RUN pecl install xdebug-2.5.5 && \
    docker-php-ext-enable xdebug && \
    echo "xdebug.remote_enable=on\nxdebug.remote_autostart=off\nxdebug.remote_host=169.254.10.100\nxdebug.remote_port=9001" > /usr/local/etc/php/conf.d/xdebug.ini

Making xdebug dev-only

Now, let’s change it up. Let’s start with making Xdebug optional – we don’t want to include that in our production build. We’ll do this by adding a new stage, easily done by adding the following before the call to install xdebug.

FROM base AS dev

This tells Docker that we’re starting a new stage, that we want to base off the one named base (which contains all our dependencies installed in the first step). Now, if you want to build the dev image, you just run docker build. If you want to only build the base stage, you can instead run docker build --target=base. The target argument to docker build tells Docker which stage it should build until.

Installing our dependencies for production

Another thing needed to run most production applications is installing our dependencies. To do that, we’ll be adding composer install to another Docker build stage. First, let’s install Composer into our base stage by simply adding the following:

ENV COMPOSER_HOME ./.composer
COPY --from=composer:1.7.2 /usr/bin/composer /usr/bin/composer

This just copies the composer executable from the official image into our own. Then, to install our dependencies, we add the following at the end of our Dockerfile:

FROM base as deps

RUN apt-get install -y git zip

ADD composer.json /var/www/html/composer.json
ADD composer.lock /var/www/html/composer.lock

RUN composer install --no-dev --no-autoloader --no-scripts

First, note how we’re calling FROM base – this ensures that we don’t include xdebug, which was added in the dev stage.

Next, you might notice that we’re only adding the composer files, not our entire app directory. As a result of this, we call --no-autoloader --no-scripts, as some of the autoloader files (and indeed the post-install script too) rely on the whole app being present, so we’ll run that separately later on.

But why don’t we include the whole app? Why isn’t this our final prod stage? Just ADD ., run composer install, and call it a day. It’s time to meet the docker build cache.

The Docker Build Cache

When Docker builds an image, it’s able to cache the result of every command that runs, so that we don’t have to rerun all steps when we build a new image, but rather only build for changes. By only including the composer files, and nothing else, the results of composer install will remain cached until the composer files change.

If we had done ADD . to add the entire directory at this point, Docker would bust the cache for every command run after that ADD, meaning we’d have to spend time waiting for composer install every time we just changed some code. Doing it this way means we only need to re-run composer install when our dependencies change or are updated, which can drastically improve the build time.

Fetching Private Repositories

If you have private repositories as part of your dependencies, you might have noticed a glaring problem in the above snippet: we have no SSH key or token to fetch private repos. And since Docker runs in its own context, it can’t use the machine’s default SSH key. However, this is easily rectified by simply adding the SSH key to the stage:

FROM base as deps

RUN apt-get install -y git zip

COPY composer.json /app/composer.json
COPY composer.lock /app/composer.lock

ARG SSH_COMPOSER_KEY
RUN mkdir /root/.ssh/
RUN echo "${SSH_COMPOSER_KEY}" > /root/.ssh/id_rsa
RUN chmod 0400 /root/.ssh/id_rsa

RUN composer install --no-dev --no-autoloader --no-scripts

With this, you can now set an environment variable on your machine, SSH_COMPOSER_KEY, and when building, run docker build --build-arg SSH_COMPOSER_KEY. It would look something like this:

export SSH_COMPOSER_KEY=$(cat ~/.ssh/id_rsa)

docker build --build-arg SSH_COMPOSER_KEY

The Final Stage

Our final stage is the production stage, where we put it all together, and include our app code. It looks like this:

FROM base as prod

COPY --from=deps /app/vendor /app/vendor
ADD . /var/www/html

RUN composer dump-autoload

Now you might be curious: why do we use FROM base rather than FROM deps? Or even include it into the same stage? If you’re not using an SSH key to pull dependencies, you totally can. Doing it this way ensures that the SSH key is not part of the final docker image, but rather only present in our deps stage.

This final stage is quite simple. We simply copy over the vendor folder resulting from our composer install, and then run composer dump-autoload once our entire application has been added, so that our autoloader is correctly created, and Laravel’s automatic service discovery works as intended.

Targeting a stage in docker-compose

In order to run our dev stage through docker-compose, we need to specify that as the target. Otherwise, we’ll be running the production image in our local dev environment, meaning we won’t have access to xdebug. To do this, we simply change the following:

fpm:
  ...
  build: .

Into

fpm:
  ...
  build:
    context: .
    target: dev

And that’s all you need to do to use a staged build through Docker Compose!

Conclusion

Hopefully, you now have a good idea of how to make your docker build production ready. We will be using this staged build as we get into deploying our application on Kubernetes.

For completeness, this is the entire Dockerfile:

FROM php:7.3-fpm AS base

RUN apt-get update && apt-get install -y \
    curl \
    libssl-dev \
    zlib1g-dev \
    libicu-dev \
    libmcrypt-dev
RUN docker-php-ext-configure intl
RUN docker-php-ext-install pdo_mysql mbstring intl opcache mcrypt

RUN usermod -u 1000 www-data

WORKDIR /var/www/html

ENV COMPOSER_HOME ./.composer
COPY --from=composer:1.7.2 /usr/bin/composer /usr/bin/composer

FROM base as dev

RUN pecl install xdebug-2.5.5 && \
    docker-php-ext-enable xdebug && \
    echo "xdebug.remote_enable=on\nxdebug.remote_autostart=off\nxdebug.remote_host=169.254.10.100\nxdebug.remote_port=9001" > /usr/local/etc/php/conf.d/xdebug.ini

FROM base as deps

RUN apt-get install -y git zip

COPY composer.json /app/composer.json
COPY composer.lock /app/composer.lock

ARG SSH_COMPOSER_KEY
RUN mkdir /root/.ssh/
RUN echo "${SSH_COMPOSER_KEY}" > /root/.ssh/id_rsa
RUN chmod 0400 /root/.ssh/id_rsa


RUN composer install --no-dev --no-autoloader --no-scripts

FROM base as prod

COPY --from=deps /app/vendor /app/vendor
ADD . /var/www/html

RUN composer dump-autoload