A CI build broken by a 504 from pecl.php.net, buried in 40 lines of &&
disponible aussi en Français.
On 20 October 2026, the PHP security team announced two critical vulnerabilities. The announcement was of an unusual scale and got wide coverage (official websites taken offline as a precaution around the world, Cloudflareâs reaction, OpenAIâŠ). For us it had an unexpected effect. During the day, our applicationâs builds started failing.
The failing build
I looked at my companyâs application Dockerfile. Its base stage starts from the official PHP image and installs the dependencies. About forty lines are grouped in a single RUN, chained with &&: apt packages, locale, PHP extensions, Composer installation, php.ini, PECL extensions.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libfreetype-dev \
libjpeg62-turbo-dev \
libpng-dev \
libxslt-dev \
libz-dev \
...
&& pecl install redis \
&& pecl install ssh2-1.4 \
&& rm -rf /tmp/pear \
&& docker-php-ext-enable redis \
&& docker-php-ext-enable ssh2Docker only showed this:
Fail
process â/bin/sh -c apt-get update && âŠâ did not complete successfully: exit code: 1
No indication of which command in the chain had failed. I first suspected a new Composer version. With a single RUN, there was no way to tell. I went to check: Composer had not released a new version, so the cause was elsewhere. We decided to split the RUN.
Splitting into several RUNs
Each RUN becomes a numbered step in docker build --progress=plain, for example [base 7/8] RUN pecl install .... It is cached independently of the others. The error points straight at the right block, with the Dockerfile line number.
I made six RUNs: packages, locale, PHP extensions, Composer, php.ini, PECL. A giant && blinds Docker because the unit of error is the instruction, not the command. (set -eux would have traced every command, but the build would have stayed a single block, with no intermediate cache.)
On the next build, the error was on the PECL installation:
Fail
buildx failed with: ERROR: failed to build: failed to solve: process â/bin/sh -c pecl install redis && pecl install ssh2-1.4 && rm -rf /tmp/pear && docker-php-ext-enable redis && docker-php-ext-enable ssh2â did not complete successfully: exit code: 1
That let me find this in the log, a bit higher up:
Fail
Could not download from âhttps://pecl.php.net/get/redis-6.3.0.tgzâ, cannot download âpecl/redisâ (File https://pecl.php.net:443/get/redis-6.3.0.tgz not valid (received: HTTP/1.1 504 Gateway Timeout))
The problem wasnât in the code: pecl.php.net was overloaded. I assume the wave of updates that followed PHPâs announcement saturated the server.
So our build wasnât broken by the vulnerability, which hadnât even been fixed yet. It was broken by everyoneâs reaction to the announcement of the fix.
Looking for a PECL mirror
Next I looked for a pecl.php.net mirror to use as a fallback. There is no official mirror you can use that way. The real alternatives are:
- compile the extensions from the GitHub tarballs with
phpize - use mlocati/docker-php-extension-installer
- publish the base image to a registry so nothing gets downloaded at every deployment.
The last one is the cleanest, but it is a project of its own. Adding a tool while the build was broken seemed risky to me, so I went for the simplest option: repeated attempts.
The retry, in three versions
To keep it simple and change as little as possible, I first put both extensions in the same loop:
RUN for i in 1 2 3 4 5; do pecl install redis ssh2 && break; sleep 10; doneTesting the failure case, I saw this wasnât viable. If redis installs and ssh2 fails, the next attempt fails on redis with âalready installedâ, which returns a non-zero exit code. The loop can never converge. It needs one loop per extension.
Second version, one loop per extension. If all five attempts fail, the for ends without a break and returns 0. The build then only fails at docker-php-ext-enable, with a message that has nothing to do with the problem. It needs a flag and an explicit exit 1.
Final version:
# pecl.php.net can answer 504 when overloaded: retry each extension separately
# (a combined install would fail on retry with "already installed"), then fail loudly.
RUN set -eu; \
for ext in redis ssh2-1.4; do \
ok=0; \
for attempt in 1 2 3 4 5; do \
if pecl install "$ext"; then ok=1; break; fi; \
echo "pecl install $ext failed (attempt $attempt/5), retrying..." >&2; \
sleep $((attempt * 10)); \
done; \
if [ "$ok" != 1 ]; then \
echo "ERROR: pecl install $ext failed after 5 attempts" >&2; \
exit 1; \
fi; \
done; \
rm -rf /tmp/pear; \
docker-php-ext-enable redis ssh2; \
php --ri redis > /dev/null; \
php --ri ssh2 > /dev/nullThe wait grows with each attempt (10 s, 20 s, 30 s, 40 s, 50 s), 150 s in total for an extension that fails five times. The last sleep is useless, since it comes right before giving up. I left it in to avoid complicating the Dockerfile.
php --ri redis at the end of the block checks that the extension is really loaded. docker-php-ext-enable can succeed even though the extension doesnât load (a missing dependency, for example). In that case php --ri fails, and so does the build.
Testing the failure
The Dockerfile is multi-stage. The base stage installs the packages and the PHP extensions, and the following ones (Composer dependencies, front-end assets) build on it. So I only build base:
docker build --progress=plain --target base -f .docker/Dockerfile -t test .To check the failure case, I temporarily replaced ssh2-1.4 with ssh2-9.9 (a version that doesnât exist) in a copy of the Dockerfile. The output:
pecl install ssh2-9.9 failed (attempt 5/5), retrying...
ERROR: pecl install ssh2-9.9 failed after 5 attemptsI havenât tested the retry against a real 504, only with a nonexistent version. A nonexistent version is a permanent error, whereas a 504 is temporary. This test shows that the failure does surface, not that the retry eventually succeeds.
What this changes
Iâm keeping this solution. The separate RUNs tell me which step failed and benefit from the cache. The retry saves me from relaunching a build over a passing 504.
It doesnât solve the rest. pecl install redis without a version installs the latest one, so the build isnât reproducible. It still depends on a third-party server at every deployment, and it wonât survive an outage of several hours. Itâs a modest improvement, suited to the incident I had.