Reproducible Dev Environments with Docker
A tour of my Docker images project — a small collection of PHP, CLI, and tooling images that keep my local environments reproducible, match CI, and stay in sync with Laravel Sail automatically.
I’ve lost more hours than I’d like to admit to the “works on my machine” problem. A teammate has PHP 8.2, CI runs 8.3, and the production image is built against 8.4 — and somewhere in that gap a bug hides until the worst possible moment. The fix isn’t discipline, it’s removing the variable entirely: pin the toolchain to an image and let everyone — laptops, CI, and prod — run the exact same thing.
That’s what my robmellett/docker repo is. It’s not a single application; it’s a small collection of Docker images I maintain for reproducible development environments. This post walks through what’s in it, why each piece exists, and how I actually use it day to day.
What’s in the box
The repo builds and publishes a handful of images, each with a narrow job:
robmellett/base— a lean base image built on Baseimage-docker. It idles at around 8.3 MB of RAM and brings the Docker-friendly init system, process supervision, and administration tooling everything else is layered on top of.robmellett/php-84,php-83,php-82,php-81— full PHP images derived from the official Laravel Sail images, one per supported PHP version. (php-80andphp-74still exist but are no longer maintained.)robmellett/php-85-cli— a lightweight, CLI-only PHP image bundled with Composer. No web server, no extras — just enough to runphpandcomposercommands.robmellett/hasura-cli— the Hasura CLI in a container, for running migrations and metadata operations without installing it on the host.
Each one exists because I kept reaching for it and didn’t want to reinstall or version-juggle the underlying tool on every machine.
Why derive the PHP images from Laravel Sail?
Laravel ships Sail as its default local dev container, and the Sail PHP images are well-built and battle-tested. Rather than reinvent that, I layer on top of them. The catch with depending on an upstream image is staleness — Sail gets updated frequently, and an image you built six months ago drifts from what laravel new produces today.
So the repo automates the freshness problem with GitHub Actions:
- Weekly Sail sync (Wednesdays) — a workflow checks the upstream Laravel Sail images for changes.
- Rebuild on merge — when a sync brings in changes, the affected images rebuild and republish.
- Weekly safety-net rebuild (Sundays) — even with no upstream changes, everything rebuilds once a week so base-OS security patches flow through.
There’s also a manual escape hatch — src/scripts/update-sail.sh — for when I want to pull the latest Sail definitions on demand rather than waiting for Wednesday.
The net effect: the images track Laravel Sail closely without me babysitting them, and a rebuild is never more than a week stale.
Running PHP and Composer without installing them
The CLI image is the one I use most. The whole point is to run PHP and Composer commands against a project without PHP installed on the host — which means per-project PHP versions, reproducibility across machines, and an environment that matches CI.
The one wrinkle with running build tools in a container is file ownership. By default the container runs as root, so any files it writes — vendor/, lockfiles, generated code — end up owned by root on your host. The fix is to run as your own user and group, and mount the project in:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$(pwd):/var/www/html" \
robmellett/php-85-cli:latest \
composer install
Breaking that down:
--rmthrows the container away when the command finishes — these are one-shot invocations, not long-lived services.-u "$(id -u):$(id -g)"runs the process as your host user, sovendor/comes out owned by you, not root.-v "$(pwd):/var/www/html"mounts the current directory into the image’s working directory.
Typing that every time is miserable, so I wrap the common calls in shell aliases:
# ~/.zshrc (or ~/.bashrc)
alias php='docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/var/www/html" robmellett/php-85-cli:latest php'
alias composer='docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/var/www/html" robmellett/php-85-cli:latest composer'
After that, the containerised tools feel native:
php -v
composer install
composer require some/package
Each one spins up the container, runs in your project directory as you, and tears the container down — leaving correctly-owned files behind and nothing installed on the host.
It’s the same trade I keep making across the whole repo: the tool lives in an image, the image is versioned, and my host stays clean.
The pattern, not the images
The specific images here are mine and tuned to how I work, but the underlying approach is the part worth stealing:
- Pin every tool to an image. Local, CI, and prod should run byte-for-byte the same thing.
- Derive from a trusted upstream rather than rebuilding the world — and automate the sync so you don’t drift.
- Run one-shot CLI tools as your own user with the project mounted, so you get reproducibility without root-owned files or a polluted host.
- Hide the verbosity behind aliases so the containerised tool is as ergonomic as a native one.
Once the toolchain is just a set of images, onboarding a new machine is a docker pull and “works on my machine” stops being a sentence anyone says.
If you want to dig into the Dockerfiles or the sync workflows, the whole thing is on GitHub.
Enjoy!