Writing a Production Dockerfile for a 5-Year-Old PHP Monolith
TL;DR — PHP-FPM + Nginx in one container is fine for most teams. Composer install belongs in a build stage, never runtime. Permissions, opcache, and FPM worker count are the three things that bite you in production.
The starter Dockerfile from the previous post gets you a running container. It’s not what I’d ship to production. Production needs a few things the starter glosses over: a real build/runtime separation, opcache configured properly, FPM worker tuning, and a non-root user. None of those are hard. They’re just a step further than most “Dockerize your PHP app” tutorials go.
This post is the file I’m actually shipping, line by line, with the reasoning. The codebase is a five-year-old Laravel monolith but nothing here is Laravel-specific — it works just as well for Symfony, vanilla PHP, or anything FPM-shaped. PHP 8.0, Composer 2.2, Docker 20.10. All current-stable as of January 2022.
Choose the base image
The first decision is which php:8.0-* tag to start from. The four options that matter:
php:8.0-cli— interpreter only, no FPM. Use this for queue workers and one-off scripts.php:8.0-fpm— FPM on Debian. Larger image but the default Debian package set is friendlier when you need extensions likeimagick.php:8.0-fpm-alpine— FPM on Alpine. ~100 MB smaller. The trade-off is musl libc, which occasionally surprises you (DNS resolver behaviour, locale handling).php:8.0-apache— mod_php + Apache. Don’t. FPM is the modern answer.
For this monolith I’m picking php:8.0-fpm-alpine3.15. The musl quirks have been ironed out for our workload. The size win is real. If you’re using imagick heavily or you’ve hit musl issues before, switch the FROM line and everything else in this post still works.
The Dockerfile, line by line
Here it is, then we’ll walk through it.
# syntax=docker/dockerfile:1.4
# ---- build stage ----
FROM composer:2.2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--ignore-platform-reqs
COPY . .
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
# ---- runtime stage ----
FROM php:8.0-fpm-alpine3.15
RUN apk add --no-cache \
nginx \
supervisor \
tzdata \
icu-libs \
libzip \
oniguruma
RUN apk add --no-cache --virtual .build-deps \
icu-dev \
libzip-dev \
oniguruma-dev \
$PHPIZE_DEPS \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
mbstring \
intl \
zip \
opcache \
bcmath \
&& pecl install redis-5.3.4 \
&& docker-php-ext-enable redis \
&& apk del .build-deps
WORKDIR /app
COPY --from=vendor /app /app
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/fpm-pool.conf /usr/local/etc/php-fpm.d/zzz-pool.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/zzz-app.ini
COPY docker/supervisord.conf /etc/supervisord.conf
RUN addgroup -g 1000 app \
&& adduser -G app -g app -s /bin/sh -D -u 1000 app \
&& chown -R app:app /app/storage /app/bootstrap/cache /var/log/nginx /var/lib/nginx /run
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
A few highlights worth calling out.
Two stages, sharp boundary. The vendor stage is the official composer:2.2 image — it has Composer + PHP + git + everything you need to build dependencies, but you don’t want any of that in your runtime image. The runtime stage is the lean FPM-Alpine image, and we copy only the /app directory across. None of the build tools come along.
Composer cache via BuildKit mount. --mount=type=cache,target=/tmp/cache keeps Composer’s download cache across builds without baking it into a layer. Repeat CI builds drop from ~90s to ~10s for the install step.
Build deps installed in a virtual package, then removed. The apk add --virtual .build-deps / apk del .build-deps pattern is the Alpine equivalent of multi-stage for native extensions. The gcc, autoconf, and PHP source headers stay only long enough to compile the extensions, then they vanish from the layer.
Non-root user. The addgroup + adduser + chown + USER app block is the difference between a “containerized” app and a “production-grade containerized” app. If anything ever breaks out of FPM, it’s not running as root.
Health check. Docker (and Kubernetes, and Compose) all use this. /healthz is a 1-line route that returns 200 — don’t reuse a real route, you don’t want health checks hitting your DB.
Composer + opcache strategy
Two settings that nobody documents but that determine whether your container is fast or slow in production.
Composer: --classmap-authoritative in the dump-autoload call. It makes Composer’s autoloader skip the filesystem fallback after a class isn’t found in the classmap. Production code shouldn’t be doing dynamic class generation; this is a free perf win.
opcache: the FPM image ships opcache built but not configured. Drop this in docker/php.ini:
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload_user=app
opcache.jit=1255
opcache.jit_buffer_size=128M
validate_timestamps=0 is the big one. With it on, opcache stat()s every PHP file on every request to see if it’s changed. With it off, opcache trusts what it has until you restart FPM. In a container, “restart FPM” is “redeploy the container,” which is what you want anyway.
Don’t use these settings in development; you’ll go insane wondering why your code changes aren’t showing up.
Common Pitfalls
Forgetting to copy the FPM pool config. The default pool sets pm.max_children=5. That’s not enough for any real workload. Your container will queue requests under load and you’ll spend an afternoon thinking the database is slow. Copy a real fpm-pool.conf with pm.max_children set based on your container memory limits — rule of thumb, (memory_limit / per-request memory) - safety margin.
Running Nginx as root. The default Nginx Alpine package wants to bind 80, which requires root. Set listen 8080; in your Nginx config and let Kubernetes / your load balancer terminate 443 upstream. The user app directive in nginx.conf needs to match the user you created in the Dockerfile.
Not setting a timezone. The Alpine base has no tzdata by default. Your logs will be in UTC, your business logic that calls date('Y-m-d') will be in UTC, and your users in WIB or PST will be confused. apk add tzdata plus ENV TZ=Asia/Jakarta fixes it.
Putting .env files in the image. Don’t. Pass env vars at runtime via Compose or your orchestrator. Add .env* to .dockerignore.
Wrapping Up
This Dockerfile is the production base I’m shipping for the rest of the month. From here the work shifts to local-dev workflow with Docker Compose for a polyglot stack, and to making the image smaller via multi-stage techniques. The PHP-FPM + Nginx + supervisord pattern is boring on purpose. Boring scales.