|

Building Production PHP Docker Images — Custom Modules & Variations (Part 2/3)

In Part 1 we built the base image — PHP-FPM with OPcache tuning, structured log routing, socket configuration, and all the compile flags exposed as build arguments. Now it’s time to stack things on top.

Not every application needs the same PHP extensions. One project needs a proprietary document processing library for generating business documents. Another just needs Xdebug for development. A third needs SOAP because legacy APIs refuse to die. The solution: variation images that inherit from the base and add exactly what each project requires.

The layer architecture

┌─────────────────────────────────────────┐
│         Variation: Amber                │
│   + customlib, SOAP, custom ext          │
│   + optional: Xdebug (dev builds)├─────────────────────────────────────────┤
│         Variation: Dev                  │
│   + Xdebug                              │
(standalone dev image, no customs)├─────────────────────────────────────────┤
│         Base Image                      │
│   + sockets, intl, bcmath               │
│   + OPcache, FPM, log routing           │
│   + Debian packages                     │
├─────────────────────────────────────────┤
│   serversideup/php:8.x-fpm             │
└─────────────────────────────────────────┘

Each layer only adds what it needs. Rebuilding the base doesn’t require rebuilding every variation — Docker layer caching handles that. Rebuilding a variation only pulls in the already-cached base.

The Amber variation

Amber is the project variation for our main application. It adds SOAP (for legacy integrations) and a proprietary extension (for document processing). Here’s the Dockerfile:

ARG PHP_VERSION='8.4'
ARG BASE_VERSION='1.0.0'

FROM registry.example.com/php:${PHP_VERSION}-${BASE_VERSION}

ARG DEPENDENCY_EXTRA_PHP_EXTENSIONS='soap'
ARG DEPENDENCY_DEVELOPMENT_EXTENSIONS

USER root

COPY src/variations/common/scripts/install-custom-ext /scripts/install-custom-ext
COPY src/variations/amber/custom-ext /scripts/custom-ext

RUN /scripts/install-custom-ext customlib

RUN install-php-extensions "${DEPENDENCY_EXTRA_PHP_EXTENSIONS}"

RUN if [ -z "$DEPENDENCY_DEVELOPMENT_EXTENSIONS" ] ; then \
    echo Argument 'DEPENDENCY_DEVELOPMENT_EXTENSIONS' not provided; \
  else \
    echo Argument is $DEPENDENCY_DEVELOPMENT_EXTENSIONS; \
    install-php-extensions "${DEPENDENCY_DEVELOPMENT_EXTENSIONS}"; \
  fi

USER www-data

The interesting parts:

BASE_VERSION as a build arg. The variation doesn’t hardcode which base image it extends. You can pin a specific base version while testing, then bump it independently. The build script handles this mapping.

DEPENDENCY_DEVELOPMENT_EXTENSIONS is optional. When empty, the conditional RUN skips installation entirely. When set to xdebug, you get a dev build from the same Dockerfile. One Dockerfile, two image variants — production and development.

Custom extensions get their own installer. install-php-extensions (from the serversideup base) handles standard PECL/distro extensions. But some libraries are proprietary and ship pre-compiled binaries. They need a custom installation script.

The custom extension installer

Some extensions can’t be installed through install-php-extensions or pecl. They’re proprietary binaries that need to be downloaded, architecture-matched, and placed in the right directory. We built a generic installer for this:

#!/bin/sh

SCRIPT_DIR=$(dirname "$0")

if [ $# -eq 0 ]; then
  echo "Extension names are not provided"
  exit 1
fi

if [ ! -d "$SCRIPT_DIR/custom-ext" ]; then
  echo "Custom extension directory is not found $SCRIPT_DIR"
  exit 1
fi

for EXTENSION_NAME in "$@"
do
  if [ ! -f "$SCRIPT_DIR/custom-ext/$EXTENSION_NAME" ]; then
    echo "Script for extension $EXTENSION_NAME is not found"
    exit 1
  fi

  echo "Running $SCRIPT_DIR/custom-ext/$EXTENSION_NAME"
  if ! sh "$SCRIPT_DIR/custom-ext/$EXTENSION_NAME"; then
    echo "Error running $SCRIPT_DIR/custom-ext/$EXTENSION_NAME"
    exit 1
  fi
done

The pattern is simple: each custom extension has a script named after it in a custom-ext/ directory. The installer iterates over arguments, finds the matching script, and runs it. Adding a new custom extension means adding a single script file — no Dockerfile changes needed.

Proprietary extensions: multi-architecture installation

Some proprietary extensions are delivered as pre-compiled .so files for specific PHP versions and CPU architectures. The installer has to detect both and download the right binary:

#!/bin/sh
set -e
set -x

PHP_VERSION=$(php -r "echo sprintf('%s%s0', PHP_MAJOR_VERSION, PHP_MINOR_VERSION);")
PHP_NTS=$(php -r "echo ((int)PHP_ZTS === 0) ? '-nts' : '';")

LIB_VERSION="10.0.3"
LIB_VPATH="1003"

ARCHITECTURE=$(uname -m)

# Some vendors use 'x64' not 'x86_64'
if [ "$ARCHITECTURE" = "x86_64" ]; then
  ARCHITECTURE="x64"
fi

# Cache for local dev — don't re-download on every build
if [ -f "$SCRIPT_DIR/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz" ]; then
  cp "$SCRIPT_DIR/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz" customlib.tar.gz
else
  LIB_URL="https://vendor.example.com/binaries/customlib/$LIB_VPATH/customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php.tar.gz"
  curl -L "$LIB_URL" -o customlib.tar.gz
fi

tar -xzf customlib.tar.gz

EXTENSION_DIR="$(php -d display_errors=stderr -r 'echo realpath(ini_get("extension_dir"));')"
FROM="customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php/bind/php/php-$PHP_VERSION$PHP_NTS/php_customlib.so"
TO="$EXTENSION_DIR/customlib.so"
mv "$FROM" "$TO"

rm -rf customlib.tar.gz "customlib-$LIB_VERSION-Linux-$ARCHITECTURE-php"
docker-php-ext-enable customlib

Several details make this multi-platform aware:

uname -m for architecture detection. When docker buildx builds for linux/arm64, the build container reports aarch64. When building for AMD64, it reports x86_64. The script maps x86_64 to the vendor’s x64 naming.

PHP version formatting. The vendor organizes its binaries by PHP version in a specific format (8.40 for PHP 8.4). The sprintf('%s%s0', ...) trick produces exactly that format.

Thread-safety detection. PHP_ZTS tells us whether PHP was compiled with Zend Thread Safety. The -nts suffix selects the non-thread-safe binary, which is what FPM uses.

Local caching. If the tarball already exists in the build context (from a previous download or manual placement), it skips the download. This speeds up iterative development without affecting CI builds.

The dev variation

For development, we have a standalone dev image that adds Xdebug on top of the base — no project-specific extensions:

ARG PHP_VERSION='8.4'
ARG BASE_VERSION='1.0.0'

FROM registry.example.com/php:${PHP_VERSION}-${BASE_VERSION}

ARG DEPENDENCY_DEVELOPMENT_EXTENSIONS

USER root

RUN install-php-extensions "${DEPENDENCY_EXTRA_PHP_EXTENSIONS}"

RUN if [ -z "$DEPENDENCY_DEVELOPMENT_EXTENSIONS" ] ; then \
    echo Argument 'DEPENDENCY_DEVELOPMENT_EXTENSIONS' not provided; \
  else \
    echo Argument is $DEPENDENCY_DEVELOPMENT_EXTENSIONS; \
    install-php-extensions "${DEPENDENCY_DEVELOPMENT_EXTENSIONS}"; \
  fi

USER www-data

This gives teams a clean development image with Xdebug that matches the production base exactly — same PHP version, same OPcache settings, same FPM configuration. The only difference is the debugger.

The build script: flags for everything

The variation build script is where the flexibility comes together. It supports multiple PHP versions, dev builds, CI hashes, and multi-arch — all through CLI flags:

bash build.sh
bash build.sh --base-version 1.0.0 --release-version 1.0.2
bash build.sh --base-version 1.0.0 --release-version 1.0.2 --php-versions "8.4,8.1" --with-dev
bash build.sh --base-version 1.0.0 --release-version 1.0.2 --php-versions "8.4,8.1" --hash abcdef123456

The flags:

FlagDefaultPurpose
--base-version1.0.0Which base image to build on
--release-versionSame as baseTag for the output images
--php-versions8.4,8.1Comma-separated PHP versions to build
--with-devdisabledAlso build dev images with Xdebug
--hashnoneCI commit hash appended to release tag

The --hash flag is particularly useful for CI. On pull requests, images are tagged with the commit SHA so reviewers can test the exact build. On main branch pushes, clean release tags are used.

Here’s how the build function handles all the combinations:

function build_php_version {
  local php_version="$1"
  local with_dev="$2"

  # Always build the production amber image
  build_image \
    "${AMBER_DOCKERFILE}" \
    "registry.example.com/php:amber-${php_version}-${RELEASE_TAG_VERSION}" \
    "${php_version}" \
    "false"

  if [[ ${with_dev} != "true" ]]; then
    return
  fi

  # Dev amber: production extensions + Xdebug
  build_image \
    "${AMBER_DOCKERFILE}" \
    "registry.example.com/php:amber-${php_version}-${DEV_TAG_VERSION}" \
    "${php_version}" \
    "true"

  # Dev base: just Xdebug, no project extensions
  build_image \
    "${DEV_DOCKERFILE}" \
    "registry.example.com/php:${php_version}-${DEV_TAG_VERSION}" \
    "${php_version}" \
    "true"
}

One function, three image variants. The --with-dev flag controls whether dev images are built at all, keeping CI pipelines fast when you only need production images.

The push scripts mirror the build scripts

Every build flag has a corresponding push flag. The push scripts reuse the same argument parsing and version logic:

bash push.sh --base-version 1.0.0 --release-version 1.0.2 --with-dev
bash push.sh --base-version 1.0.0 --release-version 1.0.2 --hash abcdef123456

This symmetry is deliberate. Whatever you built, you push with the same flags. No mental mapping between build tags and push tags.

Adding a new variation

Adding a new variation follows a predictable pattern:

  1. Create a directory under src/variations/<name>/
  2. Write a Dockerfile that extends the base image
  3. If you need custom extensions, add scripts to custom-ext/
  4. Add the variation to the build script

The custom extension installer is reusable — any variation can use it by copying the common install-custom-ext script and providing its own extension scripts.

Code formatting

Even shell scripts and YAML files deserve formatting. We enforce consistency with shfmt and yamlfmt:

shfmt -l -w -s ./*.sh
yamlfmt -dstar **/*.{yaml,yml} -formatter indent=2,eof_newline=true

This runs in CI alongside the builds. If your shell script doesn’t pass shfmt, the pipeline fails before it even tries to build an image.

What’s next

With the base and variation images in place, we have a complete build-locally story. But nobody wants to SSH into a server and run bash build.sh manually. In Part 3, we’ll automate everything with GitHub Actions: multi-arch builds on self-hosted runners, PR preview images, Slack notifications, and a workflow_dispatch release flow for base images.

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