Why I'm Finally Containerizing My Monolith in 2022
TL;DR — The container ecosystem finally caught up to legacy stacks. Containerize first, refactor later: freeze the monolith, wrap it, ship it. The 2022 tooling (Docker 20.10, Buildx, Compose v2) makes the on-ramp painless even for ten-year-old codebases.
I’ve been running the same PHP/MySQL monolith in production since 2017. Five years. It pays the bills. Every six months or so a colleague slides into my DMs with the same pitch: “you should containerize that.” And every six months I’ve found a reason not to. The Dockerfile examples online were always for greenfield apps. The “real” guides assumed you could rewrite the directory layout. Composer in a container felt like wrestling a fire hose.
That changed this year. Not because the monolith got easier to containerize, but because Docker did. Buildx graduated to default. Compose v2 finally feels like a single binary instead of a Python script glued on top. BuildKit cache-from actually works in CI. And the image size question — the one that used to end every “should we containerize PHP” thread — has answers now that aren’t laughable.
So this month I’m doing it. The whole monolith goes in a container. Not because it’s a microservices crusade. Because the operational benefits finally outweigh the friction. This post is the why; the next twelve are the how.
What changed in container land between 2018 and 2022
If you bounced off Docker around 2018, here’s what’s different now. Skip ahead if you’ve been keeping up.
BuildKit is the default builder. Layer caching is sane. Mounts (--mount=type=cache) make Composer and npm installs fast across CI runs. Build secrets actually work without leaking into history. The old docker build is still there if you want it, but you almost never do.
Buildx ships with Docker Desktop and Docker Engine. Cross-platform builds (linux/amd64 and linux/arm64 in one push) are a single flag. If you’ve moved any infrastructure to Graviton or Ampere, you stop caring about manually tagging architectures.
Compose v2 is a Go binary, not a Python wrapper. No more pip install docker-compose. docker compose up (no hyphen) is the command. Performance is night and day. The schema is the same so your old docker-compose.yml files still work.
Distroless and Alpine are mature. A Go binary in a distroless image is 8 MB. A Node app in node:18-alpine is ~150 MB instead of 900 MB. PHP-FPM on Alpine is around 100 MB. None of these were comfortable choices in 2018.
Registry caching is cheap. GitHub Container Registry, AWS ECR, GitLab Container Registry, Harbor — they all support cache mounts now. You can warm a CI build off the previous run’s layers without weird hacks.
The combined effect: a Dockerfile for a “boring” production app went from a 60-line research project to a 25-line working file in 2022.
Reframing the goal: containerize first, refactor later
The mistake I almost made — and that I’ve watched three other teams make — is conflating containerizing with modernizing. They’re not the same job. Treating them as the same is how three-month projects become two-year projects.
Containerizing means: the same code, the same database, the same dependencies, but now packaged so it runs the same way on a developer’s laptop, in CI, and in production. Same behaviour. Same bugs. Same business logic. The only thing that changes is the runtime envelope.
Modernizing means: rewriting the auth layer, splitting the billing module out, moving from MySQL 5.7 to Postgres 14, replacing the homegrown queue with NATS. All worth doing, eventually. None of them belong in the containerization PR.
The reason this distinction matters operationally: once the monolith runs in a container, every other modernization step gets cheaper. Local dev becomes docker compose up. CI gets a deterministic build. Onboarding a new engineer drops from a half-day shell-script tour to a git clone && docker compose up. Staging environments become trivially reproducible. You unlock all of that before touching a single line of business logic.
Freeze the monolith. Wrap it. Then iterate.
A real-world starting Dockerfile
Here’s the Dockerfile I’m using as the starting point for the PHP monolith. It’s not the final version — I’ll write a proper multi-stage one in a later post — but it’s the one that gets us from zero to a running container today.
# syntax=docker/dockerfile:1.4
FROM php:8.0-fpm-alpine3.15
RUN apk add --no-cache \
nginx \
supervisor \
git \
icu-dev \
libzip-dev \
oniguruma-dev \
mysql-client
RUN docker-php-ext-install \
pdo_mysql \
mbstring \
intl \
zip \
opcache
COPY --from=composer:2.2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/root/.composer/cache \
composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/supervisord.conf /etc/supervisord.conf
RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache
EXPOSE 8080
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
A few things to notice. The # syntax=docker/dockerfile:1.4 line opts you in to the modern BuildKit features — including the --mount=type=cache you see on the Composer install. That cache makes repeat builds five-to-ten times faster on CI; without it you’d download the entire Composer dependency tree from packagist every build.
The split between “copy composer files, install, then copy the rest” is intentional. Code changes don’t invalidate the dependency layer. That’s the single most impactful caching trick in the whole file.
The supervisord choice keeps Nginx and PHP-FPM in the same container. There are good arguments for splitting them into a sidecar pattern, and I’ll write about that decision in Writing a Production Dockerfile for a 5-Year-Old PHP Monolith. For “containerize first,” supervisor is the lower-friction choice.
Common Pitfalls
Don’t run Composer with the source code already copied. It’s tempting to write a single COPY . . followed by composer install for simplicity. You’ll pay for it on every CI run. Two layers — composer.json + composer.lock first, install, then the rest of the source — is non-negotiable.
Don’t bake secrets into image layers. ENV DB_PASSWORD=... is a footgun. Use --mount=type=secret for build-time secrets and runtime env vars (or a secrets manager) for runtime ones. Layers are forever; if a secret hits a layer you have to rotate it.
Don’t forget storage permissions. Frameworks like Laravel and Symfony write to storage/ and bootstrap/cache/. The container runs as a different user than your local file owner. chown -R www-data:www-data in the Dockerfile is not optional.
Don’t ignore opcache in production. Without it, PHP recompiles every request. With it, you get a 5–10× throughput improvement for free. Set opcache.validate_timestamps=0 in production so opcache doesn’t waste cycles checking file mtimes.
Don’t commit the .env file. Sounds obvious. People still do it. Add *.env to .dockerignore and .gitignore and use a separate env_file: directive in your compose file pointing at an unchecked-in path.
Wrapping Up
This is the on-ramp post for a month of containerization work. The goal of the month isn’t a microservices migration — it’s getting the existing monolith into a container that runs identically on my laptop, in CI, and in production. From there, every subsequent modernization gets cheaper. Next post: the production-grade PHP Dockerfile, line by line, with the trade-offs that didn’t fit here.
If you’ve been putting off containerizing a legacy app for the same reasons I have, 2022 is the year to stop putting it off. The tooling did the hard work. The rest is just doing the work.