Production-grade Docker images for PHP 8.4 and 8.5 with the
phpv8/v8js extension and Composer 2. Images
are multi-arch (linux/amd64 + linux/arm64, including Apple Silicon
Macs via Docker Desktop).
Built images are published to Docker Hub at
marekskopal/php-v8js —
see the tag matrix below for the full list of available
tags, or browse all tags on Docker Hub.
docker pull marekskopal/php-v8js:latest| PHP | Variant | OS | Tag |
|---|---|---|---|
| 8.4 | cli | trixie | 8.4-cli-trixie, 8.4-cli, 8.4 |
| 8.4 | fpm | trixie | 8.4-fpm-trixie, 8.4-fpm |
| 8.4 | apache | trixie | 8.4-apache-trixie, 8.4-apache |
| 8.4 | zts | trixie | 8.4-zts-trixie, 8.4-zts |
| 8.4 | cli | alpine | 8.4-cli-alpine |
| 8.4 | fpm | alpine | 8.4-fpm-alpine |
| 8.4 | zts | alpine | 8.4-zts-alpine |
| 8.5 | cli | trixie | 8.5-cli-trixie, 8.5-cli, 8.5, latest |
| 8.5 | fpm | trixie | 8.5-fpm-trixie, 8.5-fpm |
| 8.5 | apache | trixie | 8.5-apache-trixie, 8.5-apache |
| 8.5 | zts | trixie | 8.5-zts-trixie, 8.5-zts |
| 8.5 | cli | alpine | 8.5-cli-alpine |
| 8.5 | fpm | alpine | 8.5-fpm-alpine |
| 8.5 | zts | alpine | 8.5-zts-alpine |
Tag shorthands follow the official php image
convention: bare <php> and
<php>-<variant> resolve to the Debian/trixie variant.
The cli/fpm/apache images are non-thread-safe (NTS), like the
upstream php image defaults. The zts variant is built on the official
php:<v>-zts-* base and ships a thread-safe v8js.so (extension dir
…/no-debug-zts-…). Use it when the host PHP is ZTS — most notably
FrankenPHP, which embeds a ZTS PHP. An NTS .so
cannot load into a ZTS PHP (the TSRM ABI differs), which is the whole
reason this variant exists.
The Alpine zts image is purpose-built to have its extension copied into a
musl ZTS runtime. For FrankenPHP, copy the .so + loader and add Alpine's
nodejs-libs (which provides the libnode v8js links against):
FROM marekskopal/php-v8js:8.5-zts-alpine AS v8js
FROM dunglas/frankenphp:1-php8.5-alpine
RUN apk add --no-cache nodejs-libs
COPY --from=v8js /usr/local/lib/php/extensions/no-debug-zts-20250925/v8js.so \
/usr/local/lib/php/extensions/no-debug-zts-20250925/v8js.so
COPY --from=v8js /usr/local/etc/php/conf.d/v8js.ini \
/usr/local/etc/php/conf.d/v8js.iniThe extension-dir suffix (no-debug-zts-20250925) is the Zend module API
stamp and must match between the two images — it is identical across all PHP
8.5.x patch releases, so a .so built on 8.5-zts-alpine loads into any
FrankenPHP php8.5 Alpine tag.
There is intentionally no apache-alpine variant — the upstream
php image does not provide one, and building Apache + mod_php
against musl from source would defeat the "stay on the upstream PHP
base" production principle.
# CLI
docker run --rm marekskopal/php-v8js:latest \
php -r 'echo (new V8Js)->executeString("1+2"), PHP_EOL;'
# FPM behind nginx (Compose)
services:
php:
image: marekskopal/php-v8js:8.5-fpm
volumes:
- ./app:/var/www/html
# Apache
docker run --rm -p 8080:80 -v "$PWD/app":/var/www/html \
marekskopal/php-v8js:8.5-apache| OS family | V8 source | Pinned version |
|---|---|---|
| Debian | Built from source via depot_tools |
12.9.203 (configurable via V8_VERSION bake var) |
| Alpine | Linked against Alpine's nodejs-dev package |
Whatever Alpine 3.22's Node ships — currently V8 13.6.233.17-node.44 (Node.js 24.14.1). Recorded at /etc/v8.version in the image. |
The following were built end-to-end on a native arm64 host (Apple Silicon)
and exercised with tests/smoke.php:
| Image | V8 reported by V8Js::V8_VERSION |
Result |
|---|---|---|
8.4-cli-trixie (V8 12.9.203 from source) |
12.9.203 |
smoke OK |
8.4-cli-alpine (V8 from Node 24.14.1) |
13.6.233.17-node.44 |
smoke OK |
8.5-cli-alpine (V8 from Node 24.14.1) |
13.6.233.17-node.44 |
smoke OK |
8.5-zts-alpine (ZTS, V8 from Node) |
13.6.233.17-node.49 |
new V8Js() OK; .so also cross-loads into FrankenPHP php8.5-alpine (ZTS) |
amd64 builds were not exercised locally (would require QEMU on an arm64
host, multi-hour V8 compile). They go through the same Dockerfile and
will run on native ubuntu-24.04 in CI.
The user-facing requirement was "V8 12+, use the latest finalized major". The latest finalized V8 major is currently 14 (15.x is the development branch). However:
- phpv8/v8js does not currently build against V8 14.6+ — see open
issue phpv8/v8js#546.
V8 14.6 removed
Local::Holder()(nowHolderV2()), changedSetAlignedPointerInInternalField's signature to require anEmbedderDataTypeTag, and replacedString::WritewithWriteV2. These need ~7 source patches in v8js itself. - V8 13.x is plausibly compatible but untested — phpv8/v8js's CI matrix only actively runs against V8 10.9.194 and V8 12.9.203.
- V8 12.9.203 is the highest version upstream actively tests
(
.github/workflows/build-test.ymlin phpv8/v8js).
To bump to V8 13 or a patched V8 14, override V8_VERSION:
docker buildx bake --set "*.args.V8_VERSION=13.1.104"V8's build system depends on Chromium's bundled clang toolchain and
assumes glibc. Building V8 on musl/Alpine via depot_tools is not
supported upstream and is not what phpv8/v8js's own CI does — their
Alpine job does apk add nodejs-dev and lets v8js link against the V8
that ships with Node.js. We follow the same approach.
The trade-off: the exact V8 minor on Alpine is determined by whichever
Node.js ships in Alpine 3.22 (currently Node 24.14.1, V8 13.6.233.17).
That happens to be newer than the Debian image's pinned V8 12.9.203,
and was confirmed working end-to-end (new V8Js(), executeString,
exception marshalling) during build validation. The V8 the extension
was linked against is recorded in /etc/v8.version and in the image's
v8.source=alpine-nodejs label.
V8 has a runtime check: the embedder (v8js) must be compiled with the
same V8_COMPRESS_POINTERS / V8_ENABLE_SANDBOX settings as V8 itself,
or new V8Js() aborts with "Embedder-vs-V8 build configuration
mismatch". The Dockerfiles handle this:
- Debian (depot_tools build) enables both, and passes the matching
defines via
V8JS_CPPFLAGS. - Alpine (Node's V8) builds without either flag, so
V8JS_CPPFLAGSis left empty.
If you bump V8_VERSION and the upstream Chromium build defaults
change, the build will catch the mismatch immediately because each
Dockerfile runs tests/smoke.php (which calls new V8Js()) before
exporting the runtime image.
You need Docker 24+ with buildx. To build all variants for your host architecture only:
docker buildx bake --set "*.platform=linux/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"To build one variant:
docker buildx bake debian-cli-84
docker buildx bake alpine-fpm-85To build and push the full multi-arch matrix (requires docker login):
docker buildx bake --pushHeads up: building V8 from source for the first time takes 1–2 hours per architecture on a fast machine. Subsequent builds hit the BuildKit cache and are minutes. CI uses the GitHub Actions cache backend (
type=gha) which persists across runs.
.github/workflows/release.yml builds on every push to main, pull
request, and tag v*. Multi-arch publishing uses native runners
per architecture (ubuntu-24.04 and ubuntu-24.04-arm) rather than
QEMU emulation, because emulated V8 builds time out at the 6-hour job
limit. The two per-arch digests are stitched into final multi-arch
tags by a separate manifest job.
Secrets required:
DOCKERHUB_USERNAME— Docker Hub loginDOCKERHUB_TOKEN— Docker Hub access token (write:images)
tests/smoke.php exercises:
V8Jsclass loadexecuteStringinteger return (1+2 === 3)- JS → PHP object marshalling
V8JsScriptExceptionpropagation fromthrow
It runs during the image build (in both the ext-builder and the
final runtime stage), so a broken extension fails the build instead of
the deploy.
To run it against a built image:
docker run --rm -v "$PWD/tests:/tests" marekskopal/php-v8js:latest \
php /tests/smoke.phpThe V8 build script uses two non-default choices that turned out to be needed for native arm64 builds with Debian trixie's GCC 14:
is_clang = false+ skip cctest — V8's arm64 test code (test-assembler-arm64.cc,test-code-stub-assembler.cc) uses C++2342.15f16numeric literals and Clang-syntax inline asm. GCC 14 parses the literals but still rejects the inline asm syntax. The script invokes ninja only for the embedder targets (v8 v8_libplatform v8_libbase) so those test files never compile.use_sysroot = false— skips Chromium's vendored Debian sysroot download (which is amd64-only forarm64.releasebecause that GN preset is a cross-compile simulator config) and uses the host's libc.
A production-validated alternative (the path the Legito monolith uses
in prod) is to install Clang 17 from apt.llvm.org on arm64 and
build with is_clang = true. That builds the full V8 source including
cctest. It's heavier (extra LLVM apt install, more build artifacts)
but it's exactly what Chromium upstream tests against — if you start
hitting subtle V8 codegen bugs with the GCC build, switch to Clang.
Reference: infrastructure/legito_php/Dockerfile (V8 11.8.144 +
LLVM 17 + tools/dev/gm.py).
V8 11.8.144 is the version Legito ships in production. It's older
than this repo's default V8_VERSION=12.9.203 but is the known-good
fallback if 12.9.203 ever regresses for you:
docker buildx bake --set "*.args.V8_VERSION=11.8.144"- V8 12.9.203 is from 2024-09 and missing perf improvements from
13/14. Track v8js#546
for the V8 14 patch set; once merged, bump
V8_VERSIONhere. - ARM64 V8 builds are unproven by upstream — see
v8js#529. Builds should
work on
ubuntu-24.04-arm(native arm64, glibc), but expect to patch if V8's build system changes. Failures will surface in CI; the Debian Dockerfile's V8 stage is intentionally fail-fast. - PHP 8.5 is not yet covered by upstream v8js CI. The PHP 8.4 deprecation work in PR #545 should carry forward, but if 8.5 introduces an API break, the smoke test will catch it at build time.
- Alpine V8 version is not pinned — it tracks Alpine's nodejs
package, so an Alpine point release can shift V8. The build-time V8
version is captured in the image label and at
/etc/v8.version.
.
├── docker-bake.hcl # bake matrix (cli/fpm/apache/zts × OS × PHP)
├── images/
│ ├── debian/Dockerfile # 4 variants (cli/fpm/apache/zts) × 2 PHP versions
│ └── alpine/Dockerfile # 3 variants (cli/fpm/zts) × 2 PHP versions
├── scripts/
│ ├── build-v8.sh # depot_tools + gn + ninja (Debian only)
│ └── install-v8js.sh # phpize + configure + make install
├── tests/smoke.php # runtime sanity check
└── .github/workflows/release.yml
MIT.