|

Building Production PHP Docker Images — The Base Layer (Part 1/3)

Every PHP team eventually faces the same question: how do you ship a PHP runtime that’s consistent across dev, staging, and production — runs on both Intel servers and ARM-based laptops — and doesn’t turn into a 2 GB blob nobody understands?

We solved it with a layered Docker image system. A single base image provides the foundation: OS packages, PHP extensions, FPM tuning, OPcache, and structured log routing. On top of that, variation images add project-specific extensions and modules. The whole thing builds for linux/amd64 and linux/arm64 in one command.

This is Part 1 of a 3-part series. We’ll cover the base image, its flags, and the configuration that makes it production-ready. Part 2 dives into custom modules and variation images. Part 3 wires it all up with GitHub Actions for CI/CD.

The naming convention

Before writing a single Dockerfile, we settled on a naming standard. Every image tag encodes exactly what’s inside:

registry.example.com/php:<flavour>-<php-version>-<release-version>

Base images (no flavour):

registry.example.com/php:8.4-1.0.0
registry.example.com/php:8.1-1.0.0

Variation images:

registry.example.com/php:amber-8.4-1.0.0
registry.example.com/php:amber-8.1-1.0.0-dev

You can look at any tag and immediately know the PHP version, the internal release, and whether it’s a dev build. No guessing. No latest roulette.

The base Dockerfile

The base image starts from serversideup/php, which gives us a solid Debian-based PHP-FPM runtime. From there, we layer on everything a production workload needs:

ARG PHP_VERSION='8.4'
ARG PHP_VARIATION='fpm'

FROM serversideup/php:${PHP_VERSION}-${PHP_VARIATION}

ARG DEPENDENCY_EXTRA_PHP_EXTENSIONS='sockets intl bcmath'
ARG DEPENDENCY_PACKAGES_DEBIAN='vim vim-tiny net-tools nano iputils-ping telnet git'

USER root

RUN apt update && \
    apt upgrade -y &&  \
    apt install -y ${DEPENDENCY_PACKAGES_DEBIAN} && \
    apt clean && \
    apt purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

A few things worth noting:

ARG for PHP extensions. sockets, intl, and bcmath are declared as build arguments, not hardcoded. Any downstream image can override them. This is the “additional flags” approach — the base image is configurable without forking.

Aggressive cleanup. The apt purge and rm -rf in the same RUN layer keeps the image small. Docker layers are immutable — if you install packages in one layer and clean up in another, the bloat stays.

OS utilities are intentional. vim, net-tools, iputils-ping, telnet — these look like dev tools, but they’re invaluable when you’re SSH’d into a production pod debugging a network issue at 2 AM. The weight cost is negligible compared to not having them.

OPcache configuration

OPcache is non-negotiable in production. We set it up via environment variables so you can override per deployment without rebuilding:

ENV PHP_OPCACHE_ENABLE=1 \
  PHP_OPCACHE_INTERNED_STRINGS_BUFFER=64 \
  PHP_OPCACHE_MEMORY_CONSUMPTION=512 \
  PHP_OPCACHE_MAX_ACCELERATED_FILES=50000 \
  PHP_OPCACHE_REVALIDATE_FREQ=0 \
  PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 \
  PHP_ERROR_REPORTING=22527

The key flags:

FlagValueWhy
VALIDATE_TIMESTAMPS0Never check file mtimes. In production, code doesn’t change at runtime
REVALIDATE_FREQ0Combined with above: OPcache is locked-in at boot
MAX_ACCELERATED_FILES50000Large Symfony/Laravel apps easily hit 10k+ files
MEMORY_CONSUMPTION512MB of shared memory. Generous, but OPcache eviction is worse than wasted RAM
INTERNED_STRINGS_BUFFER64MB for interned strings. Classes with lots of string constants benefit

The INI file itself is minimal — just the timestamp override, since the rest is handled via environment variables:

; Other settings can be set up with environment variables
opcache.validate_timestamps = ${PHP_OPCACHE_VALIDATE_TIMESTAMPS}

An entrypoint script handles reset on boot:

if [ "$PHP_OPCACHE_ENABLE" = "1" ] || [ "$PHP_OPCACHE_ENABLE" = "true" ]; then
  if [ "$PHP_OPCACHE_VALIDATE_TIMESTAMPS" = "0" ]; then
    php -r "opcache_reset();"
  fi
fi

This ensures a clean OPcache state every time the container starts — no stale bytecode from a previous run.

FPM socket configuration

We run FPM on a Unix socket instead of a TCP port. Sockets skip the TCP/IP stack entirely, eliminating connection overhead when Nginx and PHP-FPM live in the same pod:

[global]
daemonize = no
error_log = /tmp/php-log.pipe

[www]
listen = /var/run/fpm/fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm.status_path = /3d633e69231403b286884d73fe3d3cf1e2dddbe2
clear_env = no

catch_workers_output = yes
decorate_workers_output = no

php_admin_value[error_log] = /tmp/php-log.pipe
php_admin_flag[log_errors] = on

The pm.status_path is a hashed endpoint — accessible when enabled in the load balancer, but not guessable by bots. clear_env = no makes host environment variables available to PHP scripts, which is critical for 12-factor app configuration.

The Dockerfile creates the socket directory with correct ownership:

RUN mkdir -p /var/run/fpm \
  && chown -R www-data:www-data /var/run/fpm

VOLUME /var/run/fpm

The VOLUME declaration lets Nginx containers mount the same socket directory in a shared pod.

Structured log routing

PHP-FPM has a well-known problem: everything goes to stderr. Application logs, debug output, warnings, fatal errors — all lumped together in one stream. We fix this with a named pipe and a log router:

LOG_PIPE="/tmp/php-log.pipe"

setup_log_pipe() {
  rm -f "$LOG_PIPE"
  mkfifo "$LOG_PIPE"
  chmod 666 "$LOG_PIPE"
}

start_log_router() {
  (
    while true; do
      if [ -p "$LOG_PIPE" ]; then
        while IFS= read -r line; do
          if [ -n "$line" ]; then
            if is_error_level "$line"; then
              printf '%s\n' "$line" >&2
            else
              printf '%s\n' "$line"
            fi
          fi
        done < "$LOG_PIPE"
      fi
      sleep 0.1
    done
  ) &
  sleep 0.2
}

The is_error_level function pattern-matches against Monolog JSON levels ("level":300+), plain text markers ([WARNING], .ERROR:), PHP native errors (PHP Fatal, Parse error), and stack traces. Anything WARNING and above goes to stderr. Everything else goes to stdout.

This means your container runtime (Docker, Kubernetes) sees clean, separated streams. Log aggregators can filter without parsing. Alerting on stderr actually means something.

The FPM tuning defaults

ENV FCGI_CONNECT="/var/run/fpm/fpm.sock" \
  PHP_FPM_PM_MAX_CHILDREN=20 \
  PHP_FPM_PM_MAX_SPARE_SERVERS=5 \
  PHP_FPM_PM_MIN_SPARE_SERVERS=1 \
  PHP_FPM_PM_START_SERVERS=5 \
  PHP_MAX_EXECUTION_TIME=90 \
  PHP_MEMORY_LIMIT=512M

These are starting points, not gospel. MAX_CHILDREN=20 works for a pod with 2 GB RAM and 512 MB per process. The formula is straightforward: available_ram / memory_limit_per_process. Override via environment variables per deployment.

Building with the script

The build script supports multiple PHP versions, multi-arch, and all flags via CLI:

bash build-base.sh --release-version 1.0.2
bash build-base.sh --release-version 1.0.2 --php-versions "8.4,8.1"

Under the hood, it uses docker buildx build with --platform linux/amd64,linux/arm64:

docker buildx build \
  --platform "${PLATFORMS}" \
  -t "${image_tag}" \
  --build-arg "PHP_VERSION=${php_version}" \
  --build-arg PHP_VARIATION=fpm \
  -f "${DOCKERFILE}" \
  "${CONTEXT}"

One command, two architectures. Your developers on M-series Macs get ARM images. Your production servers get AMD64. Same Dockerfile, same flags, same behaviour.

Security: dropping privileges

The Dockerfile ends where it should:

USER www-data

Everything after the build runs as an unprivileged user. Root is used only during image construction — installing packages, creating directories, setting permissions. The running container never has root access.

What’s next

The base image is the boring part — and that’s the point. It’s stable, configurable, and production-ready. In Part 2, we’ll build variation images on top of it: custom PHP modules, a reusable extension installer, dev images with Xdebug, and how the whole layer system fits together.

Keep pushing forward and savor every step of your coding journey.