Installation
OxPHP is distributed as a Docker image, the fastest and recommended way to start serving PHP applications. The image bundles the server binary, PHP 8.4 or 8.5 ZTS, the OxPHP extension, and all runtime dependencies on Alpine Linux. The default :0.13.0 and :latest tags ship PHP 8.5; pull PHP 8.4 with the :0.13.0-php8.4, :php8.4, or any *-php8.4* tag variant.
Docker (recommended)
Pull the official image from the GitHub Container Registry:
docker pull ghcr.io/oxphp/oxphp:0.13.0The image includes:
- The OxPHP server binary, the async HTTP server.
- The PHP ZTS runtime, 8.4 or 8.5 depending on the tag pulled. It is thread-safe PHP for multi-worker execution.
- The OxPHP PHP extension (
oxphp_sapi.so), which providesoxphp_request_id(),oxphp_server_info(),oxphp_worker(), and other built-in functions. - The bridge library (
liboxphp_bridge.so) that connects the Rust server to the PHP runtime. - An Alpine Linux base, for a minimal runtime footprint. A Debian build is published nightly; see Debian (glibc) image.
- No
USERdirective. The image starts as root (matchingnginx:alpine/php-fpm:alpine/frankenphp:alpine) so it can bind privileged ports, butoxphp serve/runthen drop towww-databy default before serving, so traffic is not handled as root out of the box. Thewww-datauser (UID 82, GID 82; 33 in the Debian image) is pre-created and/var/www/htmlis chowned to it at build time. Pin the runtime identity explicitly at the orchestrator level for a specific uid or extra defense-in-depth:docker run --user www-data ghcr.io/oxphp/oxphp:0.13.0- Compose:
services.app.user: www-data - Kubernetes:
securityContext.runAsUser: 82
Debian (glibc) image
The nightly pipeline also publishes the same server built on Debian trixie instead of Alpine: ghcr.io/oxphp/oxphp:nightly-trixie (PHP 8.4), :php8.4-nightly-trixie and :php8.5-nightly-trixie, for linux/amd64 and linux/arm64.
Tagged releases are Alpine only. Nightly tags are built from main on nights when it has moved since the last fully successful run, and carry no stability promise. A night on which the Debian build fails leaves :nightly-trixie and :php<minor>-nightly-trixie at the last build that succeeded, so they can trail :nightly.
The Debian image spends less CPU per request, for two reasons:
- Thread-local storage. Thread-safe PHP reaches the engine's per-thread state through a thread-local pointer. Built against musl, PHP leaves that pointer on the global-dynamic TLS model, so code in
libphp.soreaches it through a call into the TLS runtime; against glibc it uses initial-exec, a load relative to the thread pointer. - Allocator. musl's allocator, which extensions and libraries such as libpq use beside PHP's own memory manager, maps fresh memory for libpq's query results and unmaps it when they are freed. On x86-64 that costs TLB shootdowns across the server's threads.
CPU time of the OxPHP container per response, Debian image against Alpine image, with the same OxPHP revision and PHP 8.5 patch: a Yii3 application under wrk -c64, median of six alternating runs. Each row was measured on one host: x86-64 on a 16-vCPU cloud VM (AMD EPYC 9575F), arm64 under Docker Desktop on Apple silicon. Postgres' own CPU is not included.
| Yii3 page | Page reading Postgres | |
|---|---|---|
| x86-64, classic mode | −18.9% | −14.1% |
| x86-64, worker mode | −18.1% | −18.6% |
| arm64, classic mode | −8.5% | −9.0% |
What changes for a Dockerfile built on it:
- Packages come from
apt-get update && apt-get install -y --no-install-recommends, notapk, and under Debian names: for examplelibicu-devandlibpq-devto buildintlandpdo_pgsql,libicu76andlibpq5at runtime. - Build extensions in a
php:<minor>-zts-trixiestage. A.sobuilt against musl in a-zts-alpinestage is not compatible with this image, nor the reverse. www-datais UID/GID 33, not 82:securityContext.runAsUser: 33, and anything chowned by number has to use 33.- There is no
wget, butcurlis there. A health check written for the Alpine image aswget --spider http://localhost:9090/healthbecomescurl -fsS -o /dev/null http://localhost:9090/health. - The image is larger: the PHP base is about 170 to 180 MiB compressed, against about 45 to 50 MiB for Alpine.
Image structure
File layout of the runtime image:
/usr/local/
├── bin/
│ └── oxphp # server binary
├── lib/
│ ├── libphp.so # PHP ZTS runtime (8.4 or 8.5, matches the image tag)
│ ├── liboxphp_bridge.so # C bridge library
│ └── php/extensions/no-debug-zts-<ABI>/
│ ├── oxphp_sapi.so # OxPHP PHP extension
│ └── sodium.so, ... # from the PHP base image; the set depends on the tag
└── etc/php/
└── conf.d/
├── docker-php-ext-*.ini # from the PHP base image; the set depends on the tag
└── extension.ini # extension=oxphp_sapi.soThe <ABI> value depends on the PHP minor. PHP 8.4 uses 20240924, PHP 8.5 uses 20250925. The examples below pin 20250925 because their FROM line targets php:8.5-zts-alpine3.23, the PHP of the default :0.13.0 tag. If you switch the FROM, you must switch the date too. To derive it portably inside the build:
php -r 'echo ini_get("extension_dir");'
# /usr/local/lib/php/extensions/no-debug-zts-20250925Use $(php -r 'echo ini_get("extension_dir");') in shell commands to avoid hardcoding.
The image carries no php.ini of its own (beside conf.d/, /usr/local/etc/php/ holds only the upstream php.ini-development and php.ini-production samples, and PHP loads neither), and no application settings either. Supply yours as a .ini file under conf.d, mounted or copied in; see the Docker guide.
The file name decides whether and when a .ini is read. PHP parses every .ini file in conf.d in alphabetical order and silently skips any other name. For an ordinary setting the last value read wins, so a file that overrides one has to sort after the file that sets it. extension= and zend_extension= are collected from every file and all loaded instead, so a later extension= never replaces an earlier one.
The three OxPHP components and their purpose, with sizes measured in the 0.11.0 image. Sizes differ between linux/amd64 and linux/arm64 and change from release to release:
| Component | Size | Purpose |
|---|---|---|
oxphp |
~12 to 15 MB | HTTP server, routing, plugins, metrics |
liboxphp_bridge.so |
~150 to 170 KB | Shared bridge library that links the server to the PHP runtime |
oxphp_sapi.so |
~700 KB | PHP functions (oxphp_request_id(), OxPHP\Http\Request, etc.) |
Dependency chain:
graph LR oxphp["oxphp"] --> libphp["libphp.so"] libphp --> deps["libxml2, libcurl, libsqlite3, libonig, ..."] oxphp --> bridge["liboxphp_bridge.so"] sapi["oxphp_sapi.so"] --> bridge
The oxphp binary links to libphp.so and liboxphp_bridge.so. The PHP extension oxphp_sapi.so also links to the bridge library so that per-request state is available to your PHP code.
Minimal Dockerfile
The base image php:8.5-zts-alpine3.23 (or php:8.4-zts-alpine3.23) already contains libphp.so and all its dependencies. Match the PHP minor in your FROM to the OxPHP tag you copy from, and the C library too: artifacts from a -trixie tag go onto php:<minor>-zts-trixie, not onto an Alpine base. You only need to copy the three OxPHP artifacts and add libgcc, which the Rust-built oxphp binary links against and the Alpine PHP image does not ship:
FROM php:8.5-zts-alpine3.23
RUN apk add --no-cache libgcc
COPY --from=ghcr.io/oxphp/oxphp:0.13.0 /usr/local/bin/oxphp /usr/local/bin/oxphp
COPY --from=ghcr.io/oxphp/oxphp:0.13.0 /usr/local/lib/liboxphp_bridge.so /usr/local/lib/
COPY --from=ghcr.io/oxphp/oxphp:0.13.0 /usr/local/lib/php/extensions/no-debug-zts-20250925/oxphp_sapi.so /usr/local/lib/php/extensions/no-debug-zts-20250925/
RUN echo "extension=oxphp_sapi.so" > /usr/local/etc/php/conf.d/oxphp.ini
COPY --chown=www-data:www-data . /var/www/html/public
EXPOSE 80 443
CMD ["oxphp"]This approach is convenient for development: PHP CLI, docker-php-ext-install, and pecl are all available. Composer and Xdebug are not part of the PHP base image. Add them yourself with COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer and pecl install xdebug, as the dev target in the Docker Guide does.
Production Dockerfile
The official OxPHP image keeps the PHP CLI and extension tooling (php, phpize, pecl, docker-php-ext-install) of the php:<minor>-zts-alpine3.23 image it is built on, so you can install extensions in it directly; see Installing PHP extensions in production. Building them in a separate stage instead keeps build-only packages such as icu-dev out of the final image. Whether you need extra PHP extensions decides which of these two builds you reach for.
If your application needs additional extensions (pdo_mysql, intl, etc.), build them in a separate stage and copy into the final image:
# Extension build stage
FROM php:8.5-zts-alpine3.23 AS extensions
RUN apk add --no-cache icu-dev postgresql-dev \
&& docker-php-ext-install pdo pdo_mysql pdo_pgsql intl
# Production
FROM ghcr.io/oxphp/oxphp:0.13.0
# Runtime dependencies for extensions
USER root
RUN apk add --no-cache icu-libs libpq
# Copy compiled extensions
COPY --from=extensions /usr/local/lib/php/extensions/no-debug-zts-20250925/*.so /usr/local/lib/php/extensions/no-debug-zts-20250925/
# Enable extensions
RUN { \
echo "extension=pdo.so"; \
echo "extension=pdo_mysql.so"; \
echo "extension=pdo_pgsql.so"; \
echo "extension=intl.so"; \
} > /usr/local/etc/php/conf.d/app-extensions.ini
USER www-data
COPY --chown=www-data:www-data . /var/www/html/publicIf your application does not need additional extensions, this is sufficient:
FROM ghcr.io/oxphp/oxphp:0.13.0
COPY --chown=www-data:www-data . /var/www/html/publicBuild and run:
docker build -t my-app .
docker run -p 80:80 my-appThe server listens on port 80 by default. The document root is /var/www/html/public, and the snippets above copy the project directly into it. For Laravel, Symfony, or other frameworks that already ship a public/ subdirectory, use COPY --chown=www-data:www-data . /var/www/html instead so the framework's own public/ lines up with the default. If your structure differs further, override the document root with the DOCUMENT_ROOT environment variable.
Source build (without PHP)
Build OxPHP from source with the PHP feature disabled to serve static files only:
cargo build --release --no-default-featuresThe binary is at target/release/oxphp. It uses a stub executor that returns a placeholder response for PHP requests while serving static files normally. This mode is useful for testing the server without a PHP runtime present.
Source build (with PHP)
Building OxPHP with full PHP support requires the bridge library and PHP extension to be compiled and installed first.
Prerequisites
- Rust toolchain (1.91.1 or later)
- PHP 8.4 or 8.5 with ZTS (Zend Thread Safety) enabled
- C compiler (gcc or clang)
phpizeand PHP development headers
Build steps
-
Build and install the bridge library.
cd ext/bridge make && sudo make install -
Build and install the PHP extension.
cd ../ phpize && ./configure --enable-oxphp-sapi && make && sudo make install -
Build OxPHP. Default features include php.
cargo build --release
The binary requires the shared libraries in the library search path at runtime:
export LD_LIBRARY_PATH=/usr/local/lib
./target/release/oxphpWhen deploying to Alpine Linux, build inside the same php:{8.4,8.5}-zts-alpine image used for the PHP runtime, matching the minor of the OxPHP image you ship. Mixing glibc and musl builds causes runtime errors. The official Docker image handles this correctly.
Verifying installation
After starting OxPHP, structured JSON log output confirms the server is running:
{"timestamp":"...","level":"INFO","message":"OxPHP HTTP server starting","listen_addr":"0.0.0.0:80",...}
{"timestamp":"...","level":"INFO","message":"Server listening","addr":"0.0.0.0:80"}Test that the server responds:
curl http://localhost/If you enabled the internal server with INTERNAL_ADDR, verify the health endpoint:
curl http://localhost:9090/healthA healthy server returns 200 with JSON status. A degraded server returns 503.
What's next
- Quick Start: create a project, run OxPHP with Docker Compose, and make your first request
- Docker Guide: Dockerfiles for development and production, Compose configuration, and volume mounts
- Configuration: full environment variable reference