720 MB. That was our production PHP image, and most of it was composer, git, unzip, build headers and a pile of apt cache. None of it runs in production. It was there because composer install needs it, and a Dockerfile was one linear script. People worked around this with two Dockerfiles and a shell script gluing them. Ugly, and everyone’s glue was different.

Docker 17.05 brought multi-stage builds. The first Docker feature in a while that fixes a problem I actually had. Now it is one file:

FROM composer:1.4 AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts

FROM php:7.1-fpm-alpine
COPY --from=vendor /app/vendor /var/www/app/vendor
COPY src/ /var/www/app/src/

The first stage has composer and does the heavy lifting. The final image never sees it. Only vendor/ crosses the border, through COPY --from.

The new image is 84 MB. Nice, but size is the least interesting part. There is no composer and no git inside the production container now. Whatever an attacker manages to do in there, he does it without a package manager and without a toolchain. Smaller image is mostly a faster pull. Smaller toolset is a smaller playground.

One more effect. The image is a complete artifact: code plus dependencies, built once, immutable. The same image goes to staging and production. No composer install on the server, no “it resolved different versions on prod” mystery. The build happens in the build. That sounds like a tautology and took us years.

Copy composer.json and the lock file before the sources, like above. Then editing PHP code does not invalidate the dependency layer, and rebuilds take seconds. I checked the 720 MB number twice before writing it down. I had stopped noticing.