No description
  • Shell 53.9%
  • Dockerfile 32.4%
  • HCL 8.3%
  • PHP 4.7%
  • HTML 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Greyscale Eusebio d70f9feb86
All checks were successful
Build / Run container tests (push) Successful in 6m33s
Build / Build (push) Successful in 15m1s
Merge pull request 'Build php:8.5 from Alpine 3.24 (opcache/composer OOM fix)' (#48) from fix/php85-bump-alpine-324 into main
2026-08-22 21:00:16 +00:00
.github/workflows ci: give bake a registry-backed layer cache 2026-08-08 16:30:49 +02:00
fs Merge origin/main into feat/frankenphp-xorriso 2026-08-08 03:51:24 +02:00
.common.sh Should still be quiet 2025-12-12 15:49:54 +01:00
.gitignore Fixup workflow 2025-06-03 22:43:58 +02:00
.gitmodules remove trunk 2026-04-30 15:44:27 +02:00
Alpine.builder-fragment.Dockerfile Alpine and Ubuntu+PHP bits. 2026-04-27 17:05:17 +02:00
Alpine.Dockerfile Build php:8.5 (and php-node/magento 8.5) from Alpine 3.24 2026-08-22 21:58:47 +02:00
Alpine.fragment.Dockerfile Fix regressed names. 2026-04-02 16:39:34 +02:00
bake FrankenPHP 2026-05-28 17:41:37 +02:00
changed-targets ci: give bake a registry-backed layer cache 2026-08-08 16:30:49 +02:00
CLAUDE.md ci: run the whole repo in one concurrency lane 2026-08-08 16:23:50 +02:00
docker-bake.hcl ci: give bake a registry-backed layer cache 2026-08-08 16:30:49 +02:00
Dotnet.Dockerfile Dotnet container 2025-12-12 14:45:44 +01:00
Dotnet.fragment.Dockerfile Dotnet container 2025-12-12 14:45:44 +01:00
FrankenPHP.Dockerfile Merge origin/main into feat/frankenphp-xorriso 2026-08-08 03:51:24 +02:00
Java.Dockerfile ci: bump Tomcat 9 pin to 9.0.121, dlcdn dropped 9.0.117 2026-08-22 22:13:15 +02:00
Java.fragment.Dockerfile Fix regressed names. 2026-04-02 16:39:34 +02:00
Java.Tomcat.fragment.Dockerfile Tomcat-related schenanigans. 2026-04-02 17:07:08 +02:00
Java.War.fragment.Dockerfile Apply DRY to docker-bake. Add java:war and java:tomcat images. 2025-06-11 23:29:25 +02:00
Magento.Dockerfile Add php 8.2 & 8.3 builds. 2025-08-03 17:01:08 +02:00
Magento.fragment.Dockerfile Fix regressed names. 2026-04-02 16:39:34 +02:00
Node.Dockerfile Node container 2025-12-12 14:46:03 +01:00
Node.fragment.Dockerfile Node container 2025-12-12 14:46:03 +01:00
PHP-Node.fragment.Dockerfile Fix regressed names. 2026-04-02 16:39:34 +02:00
PHP.Dockerfile Build php:8.5 (and php-node/magento 8.5) from Alpine 3.24 2026-08-22 21:58:47 +02:00
PHP.fragment.Dockerfile Ship pcov alongside Xdebug, disabled until a caller asks for it 2026-08-08 02:08:56 +02:00
PHP.Ubuntu-fragment.Dockerfile FrankenPHP 2026-05-28 17:41:37 +02:00
PHP.Ubuntu.Dockerfile Ship pcov alongside Xdebug, disabled until a caller asks for it 2026-08-08 02:08:56 +02:00
README.md Build php:8.5 (and php-node/magento 8.5) from Alpine 3.24 2026-08-22 21:58:47 +02:00
reap-buildkit-orphans Give each CI run its own compose project so runs stop deleting each other 2026-08-08 03:20:37 +02:00
test Give each CI run its own compose project so runs stop deleting each other 2026-08-08 03:20:37 +02:00
test-healthchecks.yml Merge remote-tracking branch 'pulls/Grey.ooo/main' into fix/frankenphp-zts-stack-size 2026-08-04 14:35:32 +02:00
test-versions-assertions.yml Build php:8.5 (and php-node/magento 8.5) from Alpine 3.24 2026-08-22 21:58:47 +02:00
test.yml Build php:8.5 (and php-node/magento 8.5) from Alpine 3.24 2026-08-22 21:58:47 +02:00
TODO.md various bits of magento fiddling 2025-06-17 15:41:50 +02:00
Ubuntu.builder-fragment.Dockerfile Alpine and Ubuntu+PHP bits. 2026-04-27 17:05:17 +02:00
Ubuntu.Dockerfile Alpine and Ubuntu+PHP bits. 2026-04-27 17:05:17 +02:00
Ubuntu.fragment.Dockerfile Alpine and Ubuntu+PHP bits. 2026-04-27 17:05:17 +02:00

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 marked latest in 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
Java Tomcat OpenJDK 8 Alpine 3.22 java-tomcat:8 (stage still exists in the Dockerfile, but is deprecated and unpublished)
Java Tomcat OpenJDK 7 Alpine 3.18 java-tomcat:7 (stage still exists in the Dockerfile, but is deprecated and unpublished)
.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 → basephpphp-nodemagento.

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

  1. The container starts as root via /usr/local/bin/entrypoint (it refuses to start otherwise, and de-privileges its tasks itself).
  2. The entrypoint runs sv-fix-perms, which sources every executable script in /etc/sv-fixer.d/*.sh — by default this makes the service run/finish scripts and helper binaries executable.
  3. The entrypoint then execs runsvdir -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 *.pem certificates found in /usr/local/share/ca-certificates/ via update-ca-certificates.
    • cron: Runs crond in the foreground.
    • logs: Collects logs from files and relays them to stdout. Set LOG_PATHS to a space-separated list of file paths to tail; existing content is rotated to <file>.old on 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-ubuntu images have it on every version, 7.4 included). pcov ships disabled — see 51_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 be opcache.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 -d does 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_MODE is 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 throw inside an if never ran, pcov counted it as an executable statement and Xdebug did not, which moves the denominator. A gate can therefore tighten when you switch.
  • php-node and magento (fs/node):
    • npm-babysitter: Monitors permissions on the node cache directories and fixes them if they change.
    • npm-watcher: Like the logs service, set NPM_WATCH_PATHS to a space-separated list of paths and it will run npm run watch in 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/public directly — there is no separate nginx service. PHP and its extensions come from the pkg.henderkes.com static-PHP apk repo rather than Alpine's own php* packages.
      • The Caddyfile imports /etc/frankenphp/conf.d/*.caddy, which is where the service writes optional handlers — currently the Mercure hub, when FRANKENPHP_MERCURE=On.
      • FrankenPHP and the php binary share /etc/php-zts/php.ini, so PHPRC points the CLI at its own copy in /etc/php-zts/cli/php.ini. That is what makes PHP_CLI_MEMORY_LIMIT a separate knob from PHP_MEMORY_LIMIT.
      • The server drops to the app user with su-exec, which calls initgroups() — so supplementary groups survive. Adding app to a group before the service starts is therefore enough to reach, say, a bind-mounted /var/run/docker.sock from PHP; under the chpst this replaced, the group was discarded and the same trick silently did nothing. HOME and XDG_DATA_HOME follow the drop, so Caddy's storage and anything else resolving ~ land somewhere app can write rather than in root's home.
      • opcache is configured by conf.d/zz_opcache.ini, named to sort after the upstream opcache.ini. PHP_DISABLE_OPCACHE_* rewrite it at boot.
      • redis and igbinary are intentionally not installed: the upstream php-zts-igbinary package (and php-zts-redis, which depends on it) crashes with Illegal instruction on native linux/arm64 — installing it is enough to make plain php --version core-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_JIT overrides either way.
      • The build rewrites the frankenphp binary's PT_GNU_STACK header to 8MB with elf-stack-size. FrankenPHP creates its request threads with default attributes, so musl would otherwise give them a 128KB stack — PHP sizes zend.max_allowed_stack_size from 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.ini sets no directives — its filename is the point. These php-zts builds have no Configure Command row in phpinfo() at all (static-php-cli compiles it out whenever -release is set, which is what gives libphp.so its soname), so libraries that detect libc by grepping phpinfo(INFO_GENERAL) for --build=<triple>-linux-musl conclude 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 the Configure Command row 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.
  • 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/done on completion.
    • consumers: Waits for the magento bootstrap to finish, then periodically runs magento cron:run --group consumers (every 5 minutes by default).
    • cron: Magento-flavoured cron runner.
    • indexes: Waits for the magento bootstrap to finish, then periodically runs magento cron:run --group index (daily by default).
  • node (fs/node): npm-babysitter and npm-watcher as above.
  • java (fs/java): no services — a plain JDK base.
    • java:war-machine-* (fs/java-war-machine): war-machine runs any .war file placed in /app (see WAR_FILE below).
    • java:tomcat-* (fs/java-tomcat): tomcat runs the Apache Tomcat servlet container.
  • dotnet (fs/dotnet): dotnet runs the .NET application.

Extension points

  • Add your own service: drop a directory containing a run script into /etc/services.d/<name>/.
  • Validation (/etc/validate.d/*.sh): /usr/local/bin/validate runs 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 by sv-fix-perms before the services start. See 10_app.sh.disabled for an example that chowns /app.
  • Custom CA certificates: mount or copy *.pem files into /usr/local/share/ca-certificates/ and the ca-certs service installs them at boot.
  • Patches: /usr/local/bin/apply-patches applies every /app/patches/*.patch with patch -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 different ARGs.
  • 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/*.sh assertions (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 entire default group in a single job so BuildKit can share cache across every image family (base images, PHP, Magento, etc.); pushes only happen from main.
  • mirror-baseimages.yml: daily job that mirrors the upstream alpine:* and ubuntu:* images into git.grey.ooo/mirrors/* so builds don't depend on Docker Hub availability.