|

Building Production PHP Docker Images — GitHub Actions CI/CD (Part 3/3)

In Part 1 we built the base image with all its compile flags and runtime tuning. In Part 2 we layered on custom modules and variations. Now it’s time to ship.

Manual builds break in predictable ways: someone forgets a flag, builds from the wrong branch, or pushes an image tagged latest over a known-good version. CI/CD eliminates all of that. Push code, get images. Open a PR, get preview images. Release a base version, click a button.

We use two GitHub Actions workflows: one for variations (automatic on push/PR) and one for base images (manual via workflow_dispatch). Both build multi-architecture images and push to a private registry.

Workflow 1: variation builds

This workflow runs on every push to main and on every pull request targeting main:

name: Build Workflow
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  BASE_VERSION: "1.0.0"
  RELEASE_VERSION: "1.0.0"
  PHP_VERSIONS: "8.4,8.1"

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - name: Build images (pull_request)
        if: github.event_name == 'pull_request'
        run: >
          bash ./build.sh
          --base-version "$BASE_VERSION"
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"
          --hash "${{ github.event.pull_request.head.sha }}"          

      - name: Build images (push)
        if: github.event_name != 'pull_request'
        run: >
          bash ./build.sh
          --base-version "$BASE_VERSION"
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"
          --with-dev          

      - name: Push images (pull_request)
        if: github.event_name == 'pull_request'
        run: >
          bash ./push.sh
          --base-version "$BASE_VERSION"
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"
          --hash "${{ github.event.pull_request.head.sha }}"          

      - name: Push images (push)
        if: github.event_name != 'pull_request'
        run: >
          bash ./push.sh
          --base-version "$BASE_VERSION"
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"
          --with-dev          

The workflow is straightforward — one job, sequential steps. A few things worth noting:

Version pinning in env. BASE_VERSION and RELEASE_VERSION are defined at the workflow level. To bump a version, you change one line — not three shell scripts and a Dockerfile.

Containerd image store for multi-arch. The runner’s Docker daemon is configured to use the containerd image store ("features": { "containerd-snapshotter": true }). This enables native multi-platform builds with docker buildx build --platform linux/amd64,linux/arm64 — no QEMU emulation setup, no extra builder instances. The build scripts handle platform flags internally.

PR builds use --hash. Images are tagged with the commit SHA: amber-8.4-1.0.0-abc123. Reviewers can pull exactly the build from that PR, test it, and know it matches the code they reviewed.

Main branch builds use --with-dev. When code lands on main, we build both production and dev images. Dev images include Xdebug for development environments that track the latest stable release.

cancel-in-progress: false. Docker builds are expensive. Cancelling a running build to start a new one wastes compute and can leave half-pushed layers in your registry. Let it finish, then run the next.

Workflow 2: base image releases

Base images change less frequently. New PHP extensions, FPM tuning updates, or OS package upgrades warrant a new base version. This workflow uses workflow_dispatch — it only runs when someone clicks “Run workflow” in the GitHub Actions UI:

name: Base Release Workflow
on:
  workflow_dispatch:
    inputs:
      release_version:
        description: "Release version to publish, for example 1.0.2"
        required: true
        type: string
      php_versions:
        description: "Comma-separated PHP versions, for example 8.4,8.1"
        required: true
        default: "8.4,8.1"
        type: string

jobs:
  release:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    env:
      RELEASE_VERSION: ${{ github.event.inputs.release_version }}
      PHP_VERSIONS: ${{ github.event.inputs.php_versions }}
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - name: Build base images
        run: >
          bash ./build-base.sh
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"          

      - name: Push base images
        run: >
          bash ./push-base.sh
          --release-version "$RELEASE_VERSION"
          --php-versions "$PHP_VERSIONS"          

The operator types in the version number. No tags to create, no branches to merge, no release scripts to chase down on someone’s laptop.

if: github.ref == 'refs/heads/main' ensures base releases only come from the main branch. If someone triggers the workflow from a feature branch — intentionally or by accident — the job is skipped immediately.

The complete pipeline flow

Here’s what happens when a developer opens a PR:

  1. checkout — clones the repo with LFS
  2. build — runs build.sh with --hash <commit-sha> for all PHP versions and architectures
  3. push — runs push.sh with the same flags, pushing preview images

And when the PR is merged to main:

1 is the same. 2. build — runs build.sh with --with-dev for production and dev images 3. push — pushes clean release-tagged images

For base image releases, the operator clicks “Run workflow”, enters a version, and the pipeline handles build and push.

Putting it all together

Across three posts, we’ve built:

  • A configurable base image with PHP extensions as build args, OPcache tuning via environment variables, FPM on Unix sockets, and structured log routing through named pipes
  • A variation layer system with custom extension installers, multi-architecture module installation, conditional dev builds, and a unified build script with CLI flags
  • Two CI/CD pipelines — automatic variation builds on push/PR with commit-SHA tagging, and manual base releases via workflow_dispatch, both with multi-arch support and concurrency control

The key insight: the shell scripts do all the real work. The GitHub Actions workflows are just triggers and orchestration. This means you can run bash build.sh --release-version 1.0.2 --with-dev on your laptop and get exactly the same result as CI. No vendor lock-in. No “it works in the pipeline but not locally.”

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