- Shell 53.9%
- Dockerfile 32.4%
- HCL 8.3%
- PHP 4.7%
- HTML 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| fs | ||
| .common.sh | ||
| .gitignore | ||
| .gitmodules | ||
| Alpine.builder-fragment.Dockerfile | ||
| Alpine.Dockerfile | ||
| Alpine.fragment.Dockerfile | ||
| bake | ||
| changed-targets | ||
| CLAUDE.md | ||
| docker-bake.hcl | ||
| Dotnet.Dockerfile | ||
| Dotnet.fragment.Dockerfile | ||
| FrankenPHP.Dockerfile | ||
| Java.Dockerfile | ||
| Java.fragment.Dockerfile | ||
| Java.Tomcat.fragment.Dockerfile | ||
| Java.War.fragment.Dockerfile | ||
| Magento.Dockerfile | ||
| Magento.fragment.Dockerfile | ||
| Node.Dockerfile | ||
| Node.fragment.Dockerfile | ||
| PHP-Node.fragment.Dockerfile | ||
| PHP.Dockerfile | ||
| PHP.fragment.Dockerfile | ||
| PHP.Ubuntu-fragment.Dockerfile | ||
| PHP.Ubuntu.Dockerfile | ||
| README.md | ||
| reap-buildkit-orphans | ||
| test | ||
| test-healthchecks.yml | ||
| test-versions-assertions.yml | ||
| test.yml | ||
| TODO.md | ||
| Ubuntu.builder-fragment.Dockerfile | ||
| Ubuntu.Dockerfile | ||
| Ubuntu.fragment.Dockerfile | ||
Grey.ooo Base Images
A family of multi-arch (linux/amd64 + linux/arm64) Docker base images built on Alpine Linux (with an Ubuntu variant), supervised by runit. They provide ready-to-run environments for PHP, PHP+Node, Magento, FrankenPHP, Node.js, Java (plain / Tomcat / WAR-runner), and .NET applications.
Images are published to Docker Hub as matthewbaggett/*.
Quick start
Use an image directly:
docker run --rm -p 8080:80 matthewbaggett/php:8.5
Or as a base for your own application:
FROM matthewbaggett/php:8.5
COPY . /app
Build and push everything yourself:
# Fundamentally, this is all it takes:
docker buildx bake --provenance=false --push --progress=plain
# But we have wrappers that test first, then bake and push:
./test && ./bake
Tagging scheme
Every image gets these tags (see docker-bake.hcl):
matthewbaggett/<name>:<version>— the canonical tag.matthewbaggett/<name>:<version>-build-YYYY-MM-DD— timestamped build, useful for pinning.matthewbaggett/<name>:latest— only on the version markedlatestin the bake matrix.
Versions available
| Container | Is :latest |
Version | Base OS | Tag |
|---|---|---|---|---|
| Magento | ✔️ | PHP 8.5, Node 24 | Alpine 3.24 | matthewbaggett/magento:8.5 |
| Magento | PHP 8.4, Node 22 | Alpine 3.22 | matthewbaggett/magento:8.4 | |
| Magento | PHP 8.3, Node 22 | Alpine 3.22 | matthewbaggett/magento:8.3 | |
| Magento | PHP 8.2, Node 22 | Alpine 3.22 | matthewbaggett/magento:8.2 | |
| Magento | PHP 8.1, Node 18 | Alpine 3.18 | matthewbaggett/magento:8.1 | |
| PHP+Node | ✔️ | PHP 8.5, Node 24 | Alpine 3.24 | matthewbaggett/php-node:8.5 |
| PHP+Node | PHP 8.4, Node 22 | Alpine 3.22 | matthewbaggett/php-node:8.4 | |
| PHP+Node | PHP 8.3, Node 22 | Alpine 3.22 | matthewbaggett/php-node:8.3 | |
| PHP+Node | PHP 8.2, Node 22 | Alpine 3.22 | matthewbaggett/php-node:8.2 | |
| PHP+Node | PHP 8.1, Node 18 | Alpine 3.18 | matthewbaggett/php-node:8.1 | |
| PHP+Node | PHP 7.4, Node 16 | Alpine 3.15 | matthewbaggett/php-node:7.4 | |
| PHP | ✔️ | PHP 8.5 | Alpine 3.24 | matthewbaggett/php:8.5 |
| PHP | PHP 8.4 | Alpine 3.22 | matthewbaggett/php:8.4 | |
| PHP | PHP 8.3 | Alpine 3.22 | matthewbaggett/php:8.3 | |
| PHP | PHP 8.2 | Alpine 3.22 | matthewbaggett/php:8.2 | |
| PHP | PHP 8.1 | Alpine 3.18 | matthewbaggett/php:8.1 | |
| PHP | PHP 7.4 | Alpine 3.15 | matthewbaggett/php:7.4 | |
| PHP (Ubuntu) | ✔️ | PHP 8.5 | Ubuntu 24.04 | matthewbaggett/php-ubuntu:8.5 |
| PHP (Ubuntu) | PHP 8.4 | Ubuntu 24.04 | matthewbaggett/php-ubuntu:8.4 | |
| PHP (Ubuntu) | PHP 8.3 | Ubuntu 24.04 | matthewbaggett/php-ubuntu:8.3 | |
| PHP (Ubuntu) | PHP 8.2 | Ubuntu 24.04 | matthewbaggett/php-ubuntu:8.2 | |
| PHP (Ubuntu) | PHP 7.4 | Ubuntu 24.04 | matthewbaggett/php-ubuntu:7.4 | |
| FrankenPHP | ✔️ | PHP 8.5 | Alpine 3.23 | matthewbaggett/frankenphp:8.5 |
| FrankenPHP | PHP 8.4 | Alpine 3.23 | matthewbaggett/frankenphp:8.4 | |
| FrankenPHP | PHP 8.3 | Alpine 3.23 | matthewbaggett/frankenphp:8.3 | |
| FrankenPHP | PHP 8.2 | Alpine 3.23 | matthewbaggett/frankenphp:8.2 | |
| Node | ✔️ | Node 24 | Alpine 3.23 | matthewbaggett/node:24 |
| Node | Node 22 | Alpine 3.21 | matthewbaggett/node:22 | |
| Java | ✔️ | OpenJDK 21 | Alpine 3.22 | matthewbaggett/java:21 |
| Java | OpenJDK 17 | Alpine 3.22 | matthewbaggett/java:17 | |
| Java | OpenJDK 11 | Alpine 3.22 | matthewbaggett/java:11 | |
| Java | OpenJDK 8 | Alpine 3.22 | matthewbaggett/java:8 | |
| Java War Machine | ✔️ | OpenJDK 21 | Alpine 3.22 | matthewbaggett/java-war-machine:21 |
| Java War Machine | OpenJDK 17 | Alpine 3.22 | matthewbaggett/java-war-machine:17 | |
| Java War Machine | OpenJDK 11 | Alpine 3.22 | matthewbaggett/java-war-machine:11 | |
| Java Tomcat | ✔️ | OpenJDK 21 | Alpine 3.22 | matthewbaggett/java-tomcat:21 |
| Java Tomcat | OpenJDK 17 | Alpine 3.22 | matthewbaggett/java-tomcat:17 | |
| Java Tomcat | OpenJDK 11 | Alpine 3.22 | matthewbaggett/java-tomcat:11 | |
| .Net | ✔️ | .Net 10 | Alpine 3.23 | matthewbaggett/dotnet:10 |
| .Net | .Net 9 | Alpine 3.23 | matthewbaggett/dotnet:9 | |
| Base | ✔️ | Alpine 3.23 | Alpine 3.23 | matthewbaggett/base:23 |
| Base | Alpine 3.22 | Alpine 3.22 | matthewbaggett/base:22 | |
| Base | Alpine 3.21 | Alpine 3.21 | matthewbaggett/base:21 | |
| Base | Alpine 3.18 | Alpine 3.18 | matthewbaggett/base:18 | |
| Base | Alpine 3.15 | Alpine 3.15 | matthewbaggett/base:15 | |
| Builder | ✔️ | Alpine 3.23 | Alpine 3.23 | matthewbaggett/builder:23 (base + build-base, linux-headers, openssl-dev) |
| Base (Ubuntu) | 26.04 | Ubuntu 26.04 | matthewbaggett/ubuntu:26 | |
| Base (Ubuntu) | ✔️ | 24.04 | Ubuntu 24.04 | matthewbaggett/ubuntu:24 |
| Base (Ubuntu) | 22.04 | Ubuntu 22.04 | matthewbaggett/ubuntu:22 |
Each base version also has a matching builder image with compiler toolchains installed, and each ubuntu version a matching ubuntu-builder.
Image family tree
How the published images chain together, as declared by the FROM ./x#stage lines in the Dockerfiles and the publish matrix in docker-bake.hcl. A change to a base ripples right through everything to its right — the deepest chain is Alpine → base → php → php-node → magento.
flowchart LR
base24["Alpine 3.24<br/>(internal only — not<br/>published as base:24)"]
base23["base:23<br/>Alpine 3.23"]
base22["base:22<br/>Alpine 3.22"]
base21["base:21<br/>Alpine 3.21"]
base18["base:18<br/>Alpine 3.18"]
base15["base:15<br/>Alpine 3.15"]
base24 --> php85["php:8.5"]
base23 --> frankenphp["frankenphp:8.2 – 8.5"]
base23 --> node24["node:24"]
base23 --> dotnet["dotnet:9, 10"]
base22 --> java["java:8, 11, 17, 21"]
base22 --> php8x["php:8.2, 8.3, 8.4"]
base21 --> node22["node:22"]
base18 --> php81["php:8.1"]
base15 --> php74["php:7.4"]
php85 --> phpnode85["php-node:8.5<br/>PHP 8.5 + Node 24"]
php8x --> phpnode8x["php-node:8.2 – 8.4<br/>PHP 8.2 – 8.4 + Node 22"]
php81 --> phpnode81["php-node:8.1<br/>PHP 8.1 + Node 18"]
php74 --> phpnode74["php-node:7.4<br/>PHP 7.4 + Node 16"]
java --> tomcat["java-tomcat:11, 17, 21"]
java --> war["java-war-machine:11, 17, 21"]
phpnode85 --> magento85["magento:8.5<br/>PHP 8.5 + Node 24"]
phpnode8x --> magento8x["magento:8.2 – 8.4<br/>PHP 8.2 – 8.4 + Node 22"]
phpnode81 --> magento81["magento:8.1<br/>PHP 8.1 + Node 18"]
Every base:XX also has a builder:XX built directly on top of it (omitted above for legibility). All frankenphp tags build from the same Alpine 3.23 stage, varying only the PHP_VERSION build arg. The Ubuntu family is a separate bake group and much smaller — everything published hangs off the 24.04 base:
flowchart LR
ubuntu26["Ubuntu base 26.04<br/>resolute"]
ubuntu24["Ubuntu base 24.04<br/>noble"]
ubuntu22["Ubuntu base 22.04<br/>jammy"]
ubuntu24 --> phpubuntu["php-ubuntu:7.4, 8.2 – 8.5"]
Architecture of the containers
The containers are based on runit. Each container runs multiple processes internally and supervises them.
Boot sequence
- The container starts as root via
/usr/local/bin/entrypoint(it refuses to start otherwise, and de-privileges its tasks itself). - The entrypoint runs
sv-fix-perms, which sources every executable script in/etc/sv-fixer.d/*.sh— by default this makes the servicerun/finishscripts and helper binaries executable. - The entrypoint then
execsrunsvdir -P /etc/services.d, which starts and supervises every service directory found there.
Each directory in /etc/services.d/ contains a run script that starts the service. If run exits, runit restarts it — unless a finish script exists. finish receives the exit code of run as $1 and can decide to sleep forever (one-shot services do this) or exit to trigger a restart. More in the runit documentation, particularly runsv and runsvdir.
Services by image
Service directories are layered in from the fs/ overlay directories at build time. At the time of writing:
- All images (from fs/alpine, also used by the Ubuntu base):
ca-certs: One-shot service that installs any*.pemcertificates found in/usr/local/share/ca-certificates/viaupdate-ca-certificates.cron: Runscrondin the foreground.logs: Collects logs from files and relays them to stdout. SetLOG_PATHSto a space-separated list of file paths to tail; existing content is rotated to<file>.oldon start.splash: Prints a startup banner.
php/php-node/php-ubuntu(fs/php-nginx):nginx: The Nginx web server, listening on port 80 and serving/app/public.php: PHP-FPM, which runs the PHP application itself.- Two coverage drivers are installed: Xdebug everywhere, and pcov on 8.2 and above (Alpine 3.18 and 3.15 package no pcov for 8.1 and 7.4; the
php-ubuntuimages have it on every version, 7.4 included). pcov ships disabled — see51_pcov.ini— because loaded-but-disabled costs nothing measurable, while loaded-and-enabled taxes every run that is not collecting coverage by around 3.7x. phpunit/php-code-coverage prefers pcov over Xdebug on its own once it is enabled, and collects line coverage roughly 5x faster. pcov does line coverage only, so anything reading branch or path coverage still needs Xdebug.- Disable opcache when collecting with pcov, or the numbers are quietly wrong. pcov builds its line table at compile time, and a file served from opcache is never compiled — so pcov does not see it and reports fewer covered lines, with no warning of any kind. On a real 1877-test suite in this image that was 6847 covered lines with opcache off against 6814 with it on: 33 lines lost to nothing, easily enough to fail a 100% gate for reasons unconnected to the code.
-d opcache.file_cache=alone does not fix it; it has to beopcache.enable_cli=0, and it costs nothing measurable (35s against 34s). - So:
php -d pcov.enabled=1 -d opcache.enable_cli=0 vendor/bin/phpunit --coverage-clover clover.xml. Under ParaTest both the parent and its workers need those flags — the parent will not start without a driver of its own, and-ddoes not reach the workers that do the collecting:XDEBUG_MODE=off php -d pcov.enabled=1 -d opcache.enable_cli=0 vendor/bin/paratest --passthru-php="-d pcov.enabled=1 -d opcache.enable_cli=0" --coverage-clover clover.xml.XDEBUG_MODEis an environment variable, so that one does inherit. - Diff the totals against an Xdebug run before trusting a gate on them. On a suite sitting at 100% the two agreed exactly — 6475 of 6475 statements under both, gate passing under both — but that is a result to reproduce, not to assume. The two drivers do not always classify the same lines as executable: on a branch of the same codebase where a
throwinside anifnever ran, pcov counted it as an executable statement and Xdebug did not, which moves the denominator. A gate can therefore tighten when you switch.
- Disable opcache when collecting with pcov, or the numbers are quietly wrong. pcov builds its line table at compile time, and a file served from opcache is never compiled — so pcov does not see it and reports fewer covered lines, with no warning of any kind. On a real 1877-test suite in this image that was 6847 covered lines with opcache off against 6814 with it on: 33 lines lost to nothing, easily enough to fail a 100% gate for reasons unconnected to the code.
php-nodeandmagento(fs/node):npm-babysitter: Monitors permissions on the node cache directories and fixes them if they change.npm-watcher: Like the logs service, setNPM_WATCH_PATHSto a space-separated list of paths and it will runnpm run watchin each. [WARNING]: This is currently busted. (see TODO.md)
frankenphp(fs/php-frankenphp):frankenphp: Runs the FrankenPHP application server against/etc/frankenphp/Caddyfile, serving/app/publicdirectly — there is no separate nginx service. PHP and its extensions come from the pkg.henderkes.com static-PHPapkrepo rather than Alpine's ownphp*packages.- The Caddyfile imports
/etc/frankenphp/conf.d/*.caddy, which is where the service writes optional handlers — currently the Mercure hub, whenFRANKENPHP_MERCURE=On. - FrankenPHP and the
phpbinary share/etc/php-zts/php.ini, soPHPRCpoints the CLI at its own copy in/etc/php-zts/cli/php.ini. That is what makesPHP_CLI_MEMORY_LIMITa separate knob fromPHP_MEMORY_LIMIT. - The server drops to the
appuser withsu-exec, which callsinitgroups()— so supplementary groups survive. Addingappto a group before the service starts is therefore enough to reach, say, a bind-mounted/var/run/docker.sockfrom PHP; under thechpstthis replaced, the group was discarded and the same trick silently did nothing.HOMEandXDG_DATA_HOMEfollow the drop, so Caddy's storage and anything else resolving~land somewhereappcan write rather than in root's home. - opcache is configured by
conf.d/zz_opcache.ini, named to sort after the upstreamopcache.ini.PHP_DISABLE_OPCACHE_*rewrite it at boot. redisandigbinaryare intentionally not installed: the upstreamphp-zts-igbinarypackage (andphp-zts-redis, which depends on it) crashes withIllegal instructionon nativelinux/arm64— installing it is enough to make plainphp --versioncore-dump. Re-checked 2026-08-04, still broken. See the microarchitecture trap for the wider pattern.- opcache's JIT defaults to off on 8.2 and 8.3, whose ZTS builds segfault on the first request with it enabled. 8.4 and 8.5 run it.
PHP_DISABLE_OPCACHE_JIToverrides either way. - The build rewrites the
frankenphpbinary'sPT_GNU_STACKheader to 8MB withelf-stack-size. FrankenPHP creates its request threads with default attributes, so musl would otherwise give them a 128KB stack — PHP sizeszend.max_allowed_stack_sizefrom that, and refuses to compile anything as deeply nested as a Twig template. This is the same thing upstream's-Wl,-z,stack-size=…build flag does, applied to a binary we install prebuilt, and it leaves the stack guard armed rather than disabling it. conf.d/zz-libc-marker--build=<arch>-alpine-linux-musl.inisets no directives — its filename is the point. Thesephp-ztsbuilds have noConfigure Commandrow inphpinfo()at all (static-php-cli compiles it out whenever-releaseis set, which is what giveslibphp.soits soname), so libraries that detect libc by greppingphpinfo(INFO_GENERAL)for--build=<triple>-linux-muslconclude glibc and fetch binaries that cannot exec here — symfonycasts/tailwind-bundle is the known case. PHP lists parsed ini paths in that same section, which is the only text there we control. The triple is accurate for this image, but theConfigure Commandrow stays absent: this is a marker for a missing upstream field, not build metadata. Delete it if static-php-cli stops hiding the configure command.
- The Caddyfile imports
magento(fs/magento):magento: Bootstraps the Magento application: fixes cache permissions, runs any scripts placed in/etc/services.d/magento/tasks.d/, and touches/etc/services.d/magento/doneon completion.consumers: Waits for themagentobootstrap to finish, then periodically runsmagento cron:run --group consumers(every 5 minutes by default).cron: Magento-flavoured cron runner.indexes: Waits for themagentobootstrap to finish, then periodically runsmagento cron:run --group index(daily by default).
node(fs/node):npm-babysitterandnpm-watcheras above.java(fs/java): no services — a plain JDK base.java:war-machine-*(fs/java-war-machine):war-machineruns any.warfile placed in/app(seeWAR_FILEbelow).java:tomcat-*(fs/java-tomcat):tomcatruns the Apache Tomcat servlet container.
dotnet(fs/dotnet):dotnetruns the .NET application.
Extension points
- Add your own service: drop a directory containing a
runscript into/etc/services.d/<name>/. - Validation (
/etc/validate.d/*.sh):/usr/local/bin/validateruns every script here and fails if any fail. The CI/test harness uses this to assert images are healthy; add your own checks in downstream images. - Startup permission fixes (
/etc/sv-fixer.d/*.sh): executable scripts here are sourced bysv-fix-permsbefore the services start. See 10_app.sh.disabled for an example that chowns/app. - Custom CA certificates: mount or copy
*.pemfiles into/usr/local/share/ca-certificates/and theca-certsservice installs them at boot. - Patches:
/usr/local/bin/apply-patchesapplies every/app/patches/*.patchwithpatch -p1. - Magento startup tasks: drop scripts into
/etc/services.d/magento/tasks.d/to run during the Magento bootstrap.
Configurable options
| Environment Variable | Affected Images | Default | Options | Description |
|---|---|---|---|---|
MAGENTO_MODE |
magento |
developer |
developer or production |
The mode in which Magento will run. |
NPM_WATCH_PATHS |
php-node+ |
"" |
Space separated paths. | Paths in which to run npm run watch, restarted if it exits. |
PHP_MEMORY_LIMIT |
php+, frankenphp |
256M |
256M, 2G etc |
The memory limit for PHP scripts. |
PHP_CLI_MEMORY_LIMIT |
php+, frankenphp |
4G |
256M, 2G etc |
The memory limit for PHP CLI scripts. |
PHP_DEBUG_MODE |
php+, frankenphp |
null |
on |
Enable or disable debug mode. |
PHP_ERROR_REPORTING |
php+, frankenphp |
E_ALL if PHP_DEBUG_MODE is on. E_ERROR otherwise. |
Expressions from error_reporting | Override error_reporting level. |
PHP_DISPLAY_ERRORS |
php+, frankenphp |
on when PHP_DEBUG_MODE is on. off otherwise |
on or off |
Enable or disable display errors. |
PHP_ENABLE_XDEBUG |
php+, frankenphp |
null |
on or off |
Enable or disable Xdebug. Is influenced by PHP_ERROR_REPORTING. |
PHP_DISABLE_OPCACHE_FILE_CACHE |
php+, frankenphp |
on when PHP_DEBUG_MODE is on. off otherwise |
on or off |
Disable opcache's file cache. |
PHP_DISABLE_OPCACHE_JIT |
php+, frankenphp |
on when Xdebug is enabled, and on frankenphp 8.2/8.3. off otherwise |
on or off |
Disable opcache's JIT. |
PHP_DISABLE_OPCACHE_VALIDATE_TIMESTAMPS |
php+, frankenphp |
on when PHP_DEBUG_MODE is on. off otherwise |
on or off |
Make opcache revalidate bytecode against file mtimes on every request (sets opcache.validate_timestamps=1 and opcache.revalidate_freq=0), so bind-mounted source edits take effect without a manual cache clear or restart. |
FRANKENPHP_MERCURE |
frankenphp |
off |
on or off |
Serve a Mercure hub at /.well-known/mercure from the Caddy module bundled with FrankenPHP. |
FRANKENPHP_MERCURE_ANONYMOUS |
frankenphp |
off |
on or off |
Let clients subscribe to the Mercure hub without a JWT. Off means every subscriber must present one. |
MERCURE_PUBLISHER_JWT_KEY |
frankenphp |
A throwaway key, regenerated on every restart | The key your publisher JWTs are signed with. | Required in anything but a throwaway container: without it, tokens you signed elsewhere will not validate. |
MERCURE_SUBSCRIBER_JWT_KEY |
frankenphp |
MERCURE_PUBLISHER_JWT_KEY |
The key your subscriber JWTs are signed with. | Only needed when subscribers use a different key from publishers. |
WAR_FILE |
java-war-machine |
null |
Path to a .war file. |
An explicit path to the .war file to run. Will be autodetected if not present. Only necessary if the executable name is ambiguous. |
NTP_SERVER |
base+ |
0.pool.ntp.org |
Any valid NTP server address | The NTP server to use for time synchronization. |
LOG_PATHS |
base+ |
``, overridden by php base image etc. |
Space separated paths. | Paths of files to collect logs from and relay to stdout. |
HEALTHCHECK_URI |
base+ |
http://localhost:80 |
Any valid URI | The URI to use for health checks. |
base+ means the variable applies to the base image and everything built on it; php+ to all PHP-flavoured images, and so on.
Building
Repository layout
| Path | Purpose |
|---|---|
*.Dockerfile |
One multi-stage Dockerfile per image family (Alpine, Ubuntu, PHP, Magento, Java, Node, Dotnet, FrankenPHP…). |
*.fragment.Dockerfile |
Shared build steps pulled into the Dockerfiles with INCLUDE — see below. |
| docker-bake.hcl | Buildx bake matrix: defines every target, version, tag and platform. |
| fs/ | Filesystem overlays COPY'd into the images (services, config, helper scripts), one directory per image family. |
| bake | Build-and-push wrapper around docker buildx bake. |
| test, test.yml | Test harness (see Testing below). |
| .common.sh | Shared shell functions for the bake/test scripts. |
| .github/workflows | CI: image builds and daily upstream mirroring. |
The Dockerfiles use the dockerfile-x frontend (# syntax = devthefuture/dockerfile-x), which adds two things plain Dockerfiles can't do:
INCLUDE ./PHP.fragment.Dockerfile— inlines a shared fragment, so each PHP version stage reuses identical build steps with differentARGs.FROM ./Alpine.Dockerfile#alpine-23-base— builds directly from a stage of another Dockerfile, which is how the image families chain together (Magento → PHP+Node → Alpine base).
Local builds
./bake # build and push the default group (alpine base, java, php, magento, frankenphp, node, dotnet)
./bake magento # build and push a single bake target
./bake ubuntu # the ubuntu group (base-ubuntu, ubuntu-php) is not in the default group
The bake script creates (and tears down) a dedicated docker-container buildx builder for multi-arch builds, pre-pulls the upstream base images, and pushes with provenance attestations. If a docker-bake.override.hcl file exists it is layered on top of docker-bake.hcl — useful for local tag or platform overrides.
Upstream Alpine/Ubuntu images are consumed via the git.grey.ooo/mirrors/* registry (see contexts in the bake file), which is kept fresh by a scheduled CI job.
Testing
./test # build and validate everything
./test php-85-assertions # build and run a single test service, with full output
test.yml is a docker compose file that includes:
- test-versions-assertions.yml: builds each image and runs
/usr/local/bin/validate, which executes the image's/etc/validate.d/*.shassertions (correct versions installed, services present, etc.). Each service must exit 0. - test-healthchecks.yml: boots long-running containers (PHP in debug and production mode, etc.) and waits for their Docker healthchecks to pass.
The top-level test service depends on all of the above, so ./test passes only when every image builds, validates, and reports healthy.
The FrankenPHP microarchitecture trap
The php-zts packages come from pkg.henderkes.com, which is static-php-cli output: PHP and every library it bundles are compiled together, and several of those libraries carry AVX2/AVX-512 (and NEON) code. readelf -n /usr/bin/php-zts on amd64 duly reports x86 ISA used: x86-64-baseline, x86-64-v2, x86-64-v3, x86-64-v4.
That note records what the compiler emitted somewhere in the link, not what runs unconditionally — those paths are supposed to sit behind CPUID dispatch. When a rebuild gets the dispatch wrong the binary dies with Illegal instruction on any CPU lacking the instruction. That is what happened to amd64 between June and July 2026, when the FrankenPHP tests had to be pulled out of the suite because every php invocation core-dumped on the CI runner (an x86-64-v2 part); the 2026-07-23 rebuild upstream fixed it and the tests are back in. It is still happening on native aarch64 to php-zts-igbinary and php-zts-redis, which is why they are omitted.
Because an assertion parsed out of readelf -n would fail on binaries that demonstrably work, the guard is behavioural instead: isa.sh runs isa-probe.php, which exercises the routines the vector code actually lives in (zlib-ng, OpenSSL, libsodium, PCRE2's JIT, ICU, libxml2, libjpeg-turbo). Every build runs it, so a regression fails on the CPU that built the image rather than on whoever pulls it.
CI
- build.yml: on push to
main(or manual dispatch), bakes the entiredefaultgroup in a single job so BuildKit can share cache across every image family (base images, PHP, Magento, etc.); pushes only happen frommain. - mirror-baseimages.yml: daily job that mirrors the upstream
alpine:*andubuntu:*images intogit.grey.ooo/mirrors/*so builds don't depend on Docker Hub availability.