Safeguard
Container Security

Building a minimal, multi-stage, non-root Dockerfile for PHP

The official php:fpm image still runs its master process as root — a documented, still-open issue. Here's how to build a PHP Dockerfile that doesn't.

Safeguard Research Team
Research
7 min read

Pull php:8.3-fpm from Docker Hub today and start a shell inside it, and ps aux will show the FPM master process running as root — not a misconfiguration, but the documented default behavior tracked in a long-open docker-library/php GitHub issue (#70) that dates back years and still hasn't converged on a fix upstream. The php:apache variant is only marginally better: Apache's own privilege-drop mechanism moves worker processes to www-data, but the parent process still starts as root, and neither image ships a multi-stage build, so your Composer dependencies, dev tools, and build chain all end up baked into the same layers that reach production. None of this is exotic — it's the default state of the two most-pulled official PHP images on Docker Hub. This post walks through building a Dockerfile that fixes all three problems at once: a multi-stage build that separates Composer's build-time footprint from the runtime image, a base image choice that accounts for Alpine's real (and documented) DNS limitations, and dependency pinning that ties your image to an exact, reproducible dependency graph instead of "whatever composer.lock resolves to today." Each section below is something you can copy into a real Dockerfile.

Why isn't the official PHP image secure by default?

Because the official php:fpm image was built for compatibility, not for hardened production defaults, and its maintainers have said as much in the open GitHub thread tracking the root-user question. PHP-FPM's master process needs root to bind privileged ports and manage worker processes that switch UID via pm.user/pm.group directives in www.conf — but that switch happens after the master starts, so the image itself never declares a non-root USER. php:apache mitigates this only for Apache's child workers, not the parent httpd process. Both images also ship as single-stage builds: whatever you RUN docker-php-ext-install or composer install with stays in the final image, including compilers, header files, and Composer's own PHP archive. A production Dockerfile needs to explicitly add a USER directive pointing at an unprivileged UID, and needs a build stage that never ships. Neither is the default — both have to be authored in.

What should the base image actually be — Alpine, Debian, or distroless?

It depends on what your PHP app talks to over the network, because Alpine's musl libc has real, documented DNS resolution gaps that glibc-based Debian doesn't share. The Alpine Linux issue tracker (aports #17262) documents that musl's resolver ignores search/domain directives in /etc/resolv.conf and fires A/AAAA queries in parallel rather than sequentially, using whichever response — including a spurious NXDOMAIN — arrives first; against split-horizon DNS or service-discovery systems like Consul, that race can produce inconsistent lookups that glibc's sequential resolver doesn't hit. (Musl 1.2.4, shipped in Alpine 3.18 in 2023, did add DNS-over-TCP fallback, so that specific failure mode is fixed in current Alpine-based PHP images — the search-domain and parallel-query behavior is what remains open.) If your PHP app resolves hostnames dynamically — service mesh lookups, ext-sockets connections to internally-discovered hosts — php:8.3-fpm-alpine can fail in ways that are hard to reproduce locally. If your app talks to a small, fixed set of hostnames (a database host, a cache host), Alpine's smaller footprint is a reasonable trade. Debian-based php:8.3-fpm avoids the DNS caveat entirely at the cost of a larger base layer. There's no universally correct answer — pick based on your app's actual network behavior, not on image size alone.

How do you structure the multi-stage build itself?

You split the Dockerfile into a build stage that has Composer, compilers, and dev dependencies, and a runtime stage that only receives the finished vendor/ directory and application code. A minimal skeleton:

# --- build stage ---
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader

# --- runtime stage ---
FROM php:8.3-fpm-alpine
RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser
WORKDIR /var/www/html
COPY --from=vendor --chown=appuser:appuser /app/vendor ./vendor
COPY --chown=appuser:appuser . .
USER appuser

The composer:2 stage never ships — Docker discards it after the final image is built, so its compilers and Composer's own binary never reach production. COPY --from=vendor pulls only the resolved vendor/ tree, and COPY --chown sets ownership in the same layer instead of a separate RUN chown -R, which halves the layer's disk cost since Docker doesn't have to write the files twice under different ownership. The explicit USER appuser line is what neither official base image gives you by default.

How do you get layer caching to actually work for PHP apps?

By copying composer.json and composer.lock before you copy the rest of your application code, so Docker's layer cache only invalidates the expensive composer install step when your dependencies actually change. Docker builds layers sequentially and caches each one keyed on the previous layer plus the current instruction; if you COPY . . before running composer install, then editing a single PHP file in your app invalidates every layer after it, forcing a full dependency re-resolution on every build. Ordering instructions from least-frequently-changed to most-frequently-changed — base image, system packages, composer.json/composer.lock, composer install, then application source — means a typical code-only commit reuses every cached layer up through the dependency install, and only the final COPY . . layer rebuilds. This is standard Docker build-cache behavior, not something specific to PHP, but PHP projects benefit disproportionately because composer install on a large vendor/ tree is one of the slower steps in most CI pipelines.

Why pin dependencies instead of trusting composer.lock alone?

Because composer.lock pins your PHP package versions but says nothing about the base image, the OS packages inside it, or the PHP extensions you install with docker-php-ext-install — each of those is a separate supply chain that can drift between builds even when your lockfile hasn't changed. Pinning FROM php:8.3-fpm-alpine by tag alone still lets the underlying digest change every time that tag is rebuilt upstream; pinning by digest (FROM php:8.3-fpm-alpine@sha256:<digest>) locks the exact image bytes, and you update it deliberately via a version bump rather than picking up whatever shipped that week. composer install --no-dev --no-scripts --optimize-autoloader is itself a pinning discipline: --no-scripts prevents composer.json post-install hooks (a documented supply-chain risk if a compromised package ships a malicious script) from executing during the image build, and --optimize-autoloader bakes a static class map instead of resolving autoloading logic at runtime. None of these are exotic flags — they're documented Composer and Docker features — but skipping them is exactly what makes a "reproducible" build actually reproducible only by accident.

What belongs in .dockerignore, and why does it matter for a security build?

Everything that isn't needed to build or run the image — .git, node_modules, local .env files, test fixtures, and CI configuration — because anything not excluded gets sent to the Docker build context and is a candidate for accidental inclusion via a broad COPY . .. A .git directory copied into a build stage can leak commit history and, in worse cases, credentials that were committed and later removed; a stray .env file copied into a runtime layer ships database passwords into every container pulled from the registry. .dockerignore is evaluated before the build context is even sent to the Docker daemon, so excluded files never touch any layer, cached or otherwise — unlike deleting a file in a later RUN rm instruction, which still leaves its bytes in an earlier layer's diff, recoverable by anyone who can pull and inspect that layer.

How Safeguard helps once the image is built

A well-structured Dockerfile is a build-time discipline; it doesn't stop a base-image CVE from landing six weeks after you ship. Safeguard's self-healing containers watch the base image and dependency layers you've just built for new CVEs, generate a patch plan through Griffin — an upstream version bump, a backport, or a Gold-hardened substitution — and rebuild and test the image automatically, with a documented median time-to-heal of 20-45 minutes from CVE publication to production rollout. That closes the gap a Dockerfile alone can't: the image you pin today with a digest is exactly right on day one, and self-healing is what keeps the non-root, minimal image you built staying current without you re-authoring the Dockerfile every time an advisory lands.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.