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:
- checkout — clones the repo with LFS
- build — runs
build.shwith--hash <commit-sha>for all PHP versions and architectures - push — runs
push.shwith 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.
