This repository provides a Docker image based on Debian Trixie that includes:
The image is designed to serve PHP applications efficiently using Caddy as the web server and PHP-FPM for processing.
An Alpine-based image is built in parallel via Dockerfile.alpine; see Alpine variant.
linux/amd64 and linux/arm64.Europe/Berlin by default.Clone the repository:
git clone <repository-url>
cd <repository-directory>
Build with default settings (PHP 8.4):
docker build -t my-php-caddy-image .
docker build \
--build-arg ENV_PHP_VERSION=8.3 \
--build-arg S6_OVERLAY_VERSION=3.2.3.2 \
--build-arg CADDY_VERSION=2.11.7 \
-t my-php-caddy-image .
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg ENV_PHP_VERSION=8.4 \
-t my-php-caddy-image .
An Alpine-based image is built in parallel from Dockerfile.alpine. It is a
drop-in replacement for the Debian image: same PHP extensions, same build
arguments, same paths, same www-data uid/gid (33:33 on both, so a volume
populated by one variant stays writable under the other). Switching an
application from trixie-v1.1.0-php8.4 to alpine-v1.1.0-php8.4 requires no
other change, except for the C-library differences listed under
Known differences below.
docker build -f Dockerfile.alpine \
--build-arg ENV_PHP_VERSION=8.4 \
-t my-php-caddy-alpine .
With full extension parity (both variants pull in ImageMagick and the rest of the list below), the size saving is modest but real: a PHP 8.4 build measures roughly 492 MB for the Debian image against roughly 271 MB for the Alpine image, about 45% smaller.
Extension parity is verified on every change by tests/smoke-test.sh, which diffs
the extensions loaded by CLI and FPM against baselines captured from the Debian
image. Two differences are allowed and recorded in
tests/expected-extensions/allowed-extra.txt:
msgpack, which Alpine pulls in as a dependency of the redis
extension (Debian's sury build of php-redis does not depend on it). This
one is cosmetic.pcntl loaded under FPM. Debian's sury build compiles pcntl into the
php-cli package only, so it is never present under PHP-FPM there — code
that calls pcntl_fork() or pcntl_signal() from an FPM worker fails
cleanly with an undefined-function error. Alpine has a single conf.d
shared by CLI and FPM, so its pcntl package, needed for CLI parity, also
loads under FPM. This is not cosmetic: on Alpine, the same pcntl_fork()
or pcntl_signal() call made from inside an FPM worker can corrupt
PHP-FPM's own process management instead of failing loudly — silently
working is arguably worse than failing outright. Do not call pcntl_fork()
or pcntl_signal() from request-serving (FPM) code on either variant; treat
their presence under Alpine FPM as an accident of packaging, not a supported
feature.Everything else is identical between the variants — the PHP version, the
extension set, the paths, the build arguments, the php-vars.ini settings and
the www-data uid/gid (33:33 on both). These are the differences we know of,
all of them consequences of Alpine using musl rather than glibc, plus the
pcntl packaging difference above:
pcntl is loaded under FPM on Alpine but not on Debian. See the bullet
above; it is enforced as an allowed exception by the smoke test.iconv() does not transliterate on musl. iconv('UTF-8', 'ASCII//TRANSLIT', 'Grüße') returns "Grusse" on Debian, but on Alpine it
returns false and raises Warning: iconv(): Wrong encoding, conversion from "UTF-8" to "ASCII//TRANSLIT" is not allowed — musl's iconv implements no
transliteration at all. This is the most common way a PHP application breaks
when it is moved to Alpine: slug generation, filename sanitising and
ASCII-folding search keys all go through //TRANSLIT. Code that relies on it
should use Transliterator from intl (loaded on both variants) or a
userland transliteration table instead. A gnu-libiconv plus LD_PRELOAD
workaround exists and is widely used, but it is deliberately not applied
here: swapping the C library's iconv under a running PHP is a semantic
change to the image and belongs in its own release with its own testing,
for the same reason the chroot finding below is reported rather than fixed.setlocale() reports success for locales musl does not implement. On
Alpine, setlocale(LC_ALL, 'de_DE.UTF-8') returns "de_DE.UTF-8" and even
setlocale(LC_ALL, 'xx_YY.bogus') returns "xx_YY.bogus"; musl accepts any
locale name while implementing only C/POSIX semantics. The Debian slim image
generates no locales, so the same calls return false there. Either way the
locale is not actually applied, but on Alpine the return value will not tell
you that — do not branch on it.user, group and chroot in www.conf are inertphp-fpm/www.conf sets user = www-data, group = www-data and
chroot = /var/www/html, but the image runs as USER www-data and all three
directives require a root master process. They are ignored, not silently
applied: php-fpm logs one NOTICE per directive at startup on both variants,
visible in docker logs:
NOTICE: [pool www] 'user' directive is ignored when FPM is not running as root
NOTICE: [pool www] 'group' directive is ignored when FPM is not running as root
NOTICE: [pool www] 'chroot' directive is ignored when FPM is not running as root
So there is no chroot in effect and no surprise filesystem view — the worker
sees the same paths as every other process in the container. The directives
predate the Alpine work and are left in place deliberately: removing them, or
making chroot actually take effect, changes the running image's behaviour and
belongs in its own release.
tests/smoke-test.sh builds one variant, boots it against a minimal fixture
application, and verifies that it serves PHP, runs the newest PHP patch release
published on php.net, and loads exactly the expected set of extensions:
tests/smoke-test.sh Dockerfile 8.4
tests/smoke-test.sh Dockerfile.alpine 8.4
The expected sets live in tests/expected-extensions/, captured from the Debian
image and kept per SAPI — CLI and FPM genuinely differ (FPM loads cgi-fcgi, CLI
loads pcntl). An extension that appears or disappears fails the test; a
legitimate extra must be recorded in allowed-extra.txt with a reason.
The php.net check fails when the image's PHP is older than the latest release of
its branch and that release is older than PHP_RELEASE_GRACE_DAYS (default 3):
Sury and Alpine usually ship a new PHP a day or two after php.net. Locally an
unreachable php.net only prints a warning; with REQUIRE_PHPNET_CHECK=1, as in
CI, it fails the test.
CI runs this smoke test, for both variants and all three PHP versions, as a
test stage that must pass before the build stage runs. The script defaults to
reaching the container at localhost; set SMOKE_HOST to override this for
environments where the container's published port is not reachable there — CI
runs Docker as a separate docker:dind service and reaches the container
through that service's network alias instead.
/command/s6-svc -r /run/service/caddy
/command/s6-svc -r /run/service/php-fpm
s6-rc -a list
s6-svstat /run/service/caddy
Configuration files are located in the config/ and php-fpm/ directories.
config/entrypoint.shconfig/etc/caddy/Caddyfileconfig/etc/caddy/snippets/
headers_default.caddy — Default response headersheaders_security.caddy — Security headers (HSTS, X-Frame-Options, etc.)logging.caddy — Request loggingstatic_caching.caddy — Static asset caching (CSS, JS, fonts, images)php-fpm/php-fpm.confphp-fpm/www.confphp-fpm/php-vars.iniconfig/etc/s6-overlay/s6-rc.d/config/etc/s6-overlay/user-bundles.d/user/The s6-rc.d directory contains service definitions for s6-overlay, organized to manage services like caddy and php-fpm.
caddy and php-fpm Directories:
dependencies.d: Lists dependencies required by the service to start. caddy depends on php-fpm, php-fpm depends on base.run: The main command to start the service as a longrun process.finish: Cleanup commands that run when the service stops.type: Set to longrun for automatic restart on failure.Bundle membership — which services belong to the user bundle that starts on
boot — lives separately, in
config/etc/s6-overlay/user-bundles.d/user/contents.d/ (one empty file per
member service, caddy and php-fpm), alongside the sibling type file
containing bundle, which is what makes that directory a bundle rather than a
service. It is not under s6-rc.d/user/ even
though that would seem the natural place: s6-overlay 3.2.3.x writes into
s6-rc.d/user unconditionally whenever that path exists, and this image runs
as www-data, which cannot write there. Keeping bundle membership under
user-bundles.d/ instead avoids that write. If you go looking for the user
bundle and only find s6-rc.d/{caddy,php-fpm}/, this is why — please don't
"tidy" it back into s6-rc.d/user/.
Both variants load the same set of PHP extensions. The authoritative list is
tests/expected-extensions/php{x.y}-{cli,fpm}.txt, which the smoke test enforces
on every build rather than leaving it to this document to stay accurate.
The commonly relevant ones: bcmath, bz2, calendar, ctype, curl, dom, exif, ffi, fileinfo, ftp, gd, gettext, gmp, iconv, igbinary, imagick, intl, mbstring, memcache, mysqli, opcache, pdo_mysql, pdo_pgsql, pdo_sqlite, pgsql, phar, posix, redis, session, soap, sockets, sodium, sqlite3, tokenizer, xml, xsl, zip.
From PHP 8.5 onward, OPcache is built into the PHP package rather than shipped separately, and no PECL memcache build exists — on both Debian and Alpine.
See Alpine variant for the two allowed exceptions to strict
parity (msgpack, and pcntl under FPM).
The entrypoint script (config/entrypoint.sh) performs the following steps:
.env.docker from /var/www/html/.env.docker if present./docker/scripts/envsubst_mapping.sh if present./docker/scripts/bootstrap.sh if present./init (s6-overlay) if no arguments are provided.Modify the TIMEZONE environment variable in the Dockerfile:
ENV TIMEZONE=Your/Timezone
In Dockerfile, add the package to the apt-get install command:
php$PHP_VERSION-<extension-name>
In Dockerfile.alpine, add the package to the apk add command instead, using
Alpine's package naming (no dot in the version, e.g. php84-<extension-name>
— Dockerfile.alpine already derives this as $PHP_PKG-<extension-name>).
Add the extension to both Dockerfiles to keep the variants in parity, and add
it to tests/expected-extensions/ so the smoke test enforces it.
Edit php-fpm/php-vars.ini to change PHP settings like memory_limit, upload_max_filesize, etc.
This file is shared by both variants and is also where settings are pinned whose
distro defaults would otherwise diverge — expose_php, session.save_path and
session.gc_probability. Put such settings here rather than in either
Dockerfile, so the two images cannot drift apart.
Start every comment in that file with ;. PHP's ini parser does not treat #
as a comment character: a # line containing (, ) or " raises
PHP: syntax error and the parser then discards every setting below it, so
they silently revert to their defaults while the image boots and serves traffic
normally. The file uses ; throughout for this reason — please do not convert
it back.
Edit config/etc/caddy/Caddyfile and the snippets in config/etc/caddy/snippets/ to customize Caddy's behavior.
The project includes a GitLab CI pipeline (.gitlab-ci.yml) that:
tests/smoke-test.sh for PHP 8.5, PHP 8.4 and PHP 8.3 in a
test stage; the build stage only runs once it passes.
See Testing.TRIVY_GATE_SEVERITY (default CRITICAL) fail the pipeline. Set it to an
empty value on a manual run to ship an urgent fix despite an open finding.linux/amd64 images (set by BUILD_PLATFORMS) of the Debian variant
for all three PHP versions in parallel. The Alpine variant is on hold and not
published: for CVE-2026-109618/109619 Alpine shipped the patched PHP about
half a day after sury. Dockerfile.alpine stays in the repository.MIN_PHP), as
an offline safety net next to the php.net check.The pipeline runs on:
vX.Y.Z
tag, no release candidates) from its own tagged Dockerfiles, so the published
tags keep receiving PHP and OS security fixes. Projects pin a tag, and without
a rebuild a pinned tag never changes;RELEASE_TAG to rebuild an older release.The schedule itself lives in GitLab under Build → Pipeline schedules
(0 4 * * 1, Europe/Berlin, target branch master).
Images are published as zeacom/phpfpm-caddy:{distro}-{version}-php{x.y}, where
{distro} is trixie or alpine — for example alpine-v1.1.0-php8.4.
Git tags are plain version tags (v1.1.0). The distro segment comes from the CI
build matrix, since one commit now produces both variants. Tags before v1.1.0 used
a distro-prefixed git tag (trixie-v1.0.0).
Neither service has a dedicated s6 log service configured, so s6-svlogd
against /run/service/<service-name>/log will not find anything. Both Caddy
and PHP-FPM write to the container's stdout/stderr instead; check them from
the host:
docker logs <container>
Ensure that file permissions are set correctly for the web server and PHP-FPM processes.
Validate configurations:
caddy validate --config /etc/caddy/Caddyfilephp-fpm -tNeither service writes to a log file inside the container. Both are configured
to log to the container's stdout/stderr, viewable with docker logs:
config/etc/caddy/snippets/logging.caddy); Caddy's own runtime log uses its default (stderr).error_log is set to /proc/self/fd/2, i.e. stderr (php-fpm/php-fpm.conf).Content type
Image
Digest
sha256:5298302c1…
Size
159.4 MB
Last updated
2 days ago
docker pull zeacom/phpfpm-caddy:trixie-v1.1.1-php8.5