A first serious attempt at Docker for local development

A new developer joined and spent the better part of two days getting the project to run: the wrong PHP minor version, a missing mcrypt, a MySQL that shipped with a different default collation, and an nginx config nobody had written down because the person who set it up had left. This is the attempt to replace that with a checked-in description of the stack, and an honest account of what it cost.

The symptom

$ php -v
PHP 5.5.9-1ubuntu4.14   # production runs 5.6.7

$ php -m | grep -c mcrypt
0

$ mysql -e "SELECT @@collation_database;"
latin1_swedish_ci       # production is utf8_general_ci

None of these is hard to fix once identified. The cost is in the identifying, and it is paid again by every new person, on every machine, whenever anything upstream changes.

Why it happens

The local stack was installed by hand and never written down, so it exists only as the current state of somebody’s laptop. There is no artefact to review, no way to tell whether two machines match, and no mechanism that notices when production moves ahead of development.

A README listing the steps helps and decays: it is not executed, so nothing enforces that it is still accurate.

The fix

A Dockerfile per service, readable by a human

One container per service, each with a Dockerfile that is a literal list of what that service needs. The PHP one carries the whole answer to the two-day problem above.

FROM php:5.6-fpm

RUN apt-get update && apt-get install -y 
        libmcrypt-dev libpng12-dev libicu-dev git unzip 
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-install pdo_mysql mcrypt gd intl opcache

COPY --from=composer:1.0 /usr/bin/composer /usr/bin/composer

COPY php.ini /usr/local/etc/php/conf.d/app.ini

WORKDIR /var/www

The rm -rf /var/lib/apt/lists/* in the same RUN as the install matters: a separate instruction would leave the package lists in the earlier layer and the image keeps them regardless. Layers are additive, so deleting a file in a later step does not make the image smaller.

Tip

Order instructions from least to most frequently changed. Putting COPY . /var/www before composer install means every source edit invalidates the dependency layer and reinstalls everything. Copy the manifests, install, then copy the source.

Composing them

# docker-compose.yml
php:
  build: ./docker/php
  volumes:
    - .:/var/www
  links:
    - mysql
    - redis

nginx:
  build: ./docker/nginx
  ports:
    - "8080:80"
  volumes:
    - .:/var/www:ro
  links:
    - php

mysql:
  image: mysql:5.6
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: shop
  volumes:
    - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf

redis:
  image: redis:3.0

Pinning mysql:5.6 and redis:3.0 rather than latest is the point of the exercise — latest means the stack silently changes underneath the team on whichever day someone rebuilds.

$ docker-compose up -d
Creating shop_mysql_1...
Creating shop_redis_1...
Creating shop_php_1...
Creating shop_nginx_1...

$ docker-compose run --rm php composer install
$ open http://localhost:8080

Where the database data actually lives

The compose file above has no volume for MySQL data, which means the schema and every row live inside the container. docker-compose rm — the command people reach for when something is stuck — deletes the lot, and it will happen in the first fortnight.

The fix is a named volume, and it has a second consequence worth knowing: the volume is created on first run and the official MySQL image only runs its initialisation when the data directory is empty. Change MYSQL_DATABASE afterwards and nothing happens, which reads exactly like a broken environment variable.

mysql:
  image: mysql:5.6
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: shop
  volumes:
    - shopdata:/var/lib/mysql
    - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf

volumes:
  shopdata:
# stop and remove containers, keep the data
$ docker-compose stop && docker-compose rm -f

# start over from an empty database, deliberately
$ docker-compose down
$ docker volume rm shop_shopdata

Making the destructive version explicit is the point. “Reset my database” becomes one command that clearly says so, rather than a side effect of a command that appeared to be about containers.

The part nobody warns you about

On Linux this is fast. On the Macs half the team uses, Docker runs inside a VirtualBox VM managed by Docker Machine — there is no native Docker for OS X — and the project directory reaches the container through a VirtualBox shared folder. That path is extremely slow for the access pattern PHP has, which is thousands of small stat calls per request.

# same page, same machine

native PHP-FPM                    120 ms
docker, Linux                     140 ms
docker, Toolbox + vboxsf         2900 ms

Caveat

A twenty-fold slowdown is not a rough edge, it is a reason the team will stop using it. Any honest evaluation of Docker for local development in 2015 has to start here, because it is the thing that decides the outcome on a mixed-OS team.

Two changes made it usable. First, NFS instead of the VirtualBox shared folder, which is fiddly to set up and roughly ten times faster. Second — and this helped more — keeping vendor/ out of the shared mount entirely, since it is by far the largest number of files and it does not need to be edited from the host.

php:
  build: ./docker/php
  volumes:
    - .:/var/www
    - /var/www/vendor      # container-only: not shared with the host

With both, the same page came down to about 380ms. Slower than native, fast enough to work in.

Matching the server, not just each other

Consistency between developers is the easy half. The valuable half is consistency with the production server, which means the same PHP minor version, the same extensions, and the same MySQL configuration file rather than a similar one.

$ docker-compose run --rm php php -m > /tmp/local-modules
$ ssh deploy@shop 'php -m' > /tmp/prod-modules
$ diff /tmp/local-modules /tmp/prod-modules
> 3c3
> < opcache
> ---
> > Zend OPcache

That diff being nearly empty is the deliverable. It is also a check that can run in CI, which turns “we think they match” into something that fails loudly when they stop matching.

Xdebug, which is where most people give up

Xdebug connects outward: the debugger runs in the IDE and PHP dials it. From inside a container, localhost is the container, so the connection goes nowhere and the breakpoint never fires. There is no error — the request simply runs to completion.

The container has to be told the host’s address on the Docker network, and on the Toolbox VM that is the gateway rather than anything resembling a machine name.

; docker/php/php.ini
xdebug.remote_enable=1
xdebug.remote_connect_back=0
xdebug.remote_host=192.168.99.1     ; the VM's gateway, not localhost
xdebug.remote_port=9000
xdebug.idekey=PHPSTORM

The second half is path mapping. The IDE has the project at /Users/ada/shop and PHP reports breakpoints at /var/www, so without a mapping the debugger receives a file it cannot find and silently ignores it. Every “Xdebug does not work in Docker” thread is one of those two problems.

Warning

Leave Xdebug out of the image built for CI. Loading it costs roughly a 2–3× slowdown on every request even with no debugger attached, and it will quietly make the test suite the longest step in the pipeline. Two Dockerfiles, or an install step gated on a build argument.

Wrapping the commands people actually run

Every routine command grew a nine-word prefix. That is not a rounding error in ergonomics — it is what decides whether the team keeps using this after the second week.

#!/usr/bin/env bash
# bin/dev — thin wrapper, checked in with the project
set -euo pipefail

case "${1:-}" in
  sh)      shift; exec docker-compose run --rm php bash ;;
  php)     shift; exec docker-compose run --rm php php "$@" ;;
  comp)    shift; exec docker-compose run --rm php composer "$@" ;;
  mysql)   shift; exec docker-compose run --rm mysql mysql -hmysql -uroot -proot shop ;;
  test)    shift; exec docker-compose run --rm php vendor/bin/phpunit "$@" ;;
  *)       echo "usage: bin/dev {sh|php|comp|mysql|test}" >&2; exit 1 ;;
esac

bin/dev test is short enough to type without resenting it, and it is checked in, so the way to run the suite is the same for everyone and stays correct when the compose file changes.

One habit does have to change regardless: docker-compose run starts a new container each time, and without --rm they accumulate until the disk is full. It is worth checking docker ps -a after the first week — the number is usually surprising.

Verifying it worked

The measurement is the one from the opening: a clean machine to a running application, timed, by someone who has not seen the project.

# before: roughly two days, and a list of undocumented steps

# after
$ git clone git@git:shop.git && cd shop
$ docker-compose up -d
$ docker-compose run --rm php composer install
$ docker-compose run --rm php php artisan migrate --seed

real    14m22s   (most of it pulling base images once)

What this costs

It is genuinely slower than running natively, and on Mac it is slower by enough that some people will keep a native stack alongside — which quietly reintroduces the drift this was meant to remove. That is a real risk, not a hypothetical one.

The team also has to learn it. Every debugging habit that involved running a command now involves running it inside a container, Xdebug needs the host address configured explicitly rather than localhost, and a MySQL client on the host has to connect through a mapped port. None of it is difficult and all of it is unfamiliar at once.

There is a maintenance cost that only appears later, too. The images pin php:5.6-fpm and mysql:5.6, which is what makes the environment reproducible — and it also means nothing updates unless somebody decides to update it. A pinned base image with a security fix outstanding is a decision that has quietly become a default, and the only defence is a calendar reminder to rebuild and re-pin, because there is no package manager on the host that will nag about it.

It is also worth being clear about what this is not: a deployment story. Running these images in production would mean orchestration, secrets management, log shipping and a registry, none of which is solved here. This is a development environment that happens to be built out of containers, and treating it as a first step toward deploying them would be reading more into it than it earns.

Would I do it again on this project? Yes — but the honest reason is narrower than the usual argument for containers. It was not about parity with production, which a well-written provisioning script would also have given. It was that the description of the stack is now a file in the repository that gets reviewed when it changes, instead of knowledge held by whoever set up their laptop most recently. That is the part worth the friction, and on a team where everyone runs Linux the friction would have been close to zero.