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.

Pull the official image from the GitHub Container Registry:

bash
docker pull ghcr.io/oxphp/oxphp:0.13.0

The 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 provides oxphp_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 USER directive. The image starts as root (matching nginx:alpine / php-fpm:alpine / frankenphp:alpine) so it can bind privileged ports, but oxphp serve/run then drop to www-data by default before serving, so traffic is not handled as root out of the box. The www-data user (UID 82, GID 82; 33 in the Debian image) is pre-created and /var/www/html is 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.

Nightly only

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.so reaches 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, not apk, and under Debian names: for example libicu-dev and libpq-dev to build intl and pdo_pgsql, libicu76 and libpq5 at runtime.
  • Build extensions in a php:<minor>-zts-trixie stage. A .so built against musl in a -zts-alpine stage is not compatible with this image, nor the reverse.
  • www-data is UID/GID 33, not 82: securityContext.runAsUser: 33, and anything chowned by number has to use 33.
  • There is no wget, but curl is there. A health check written for the Alpine image as wget --spider http://localhost:9090/health becomes curl -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:

text
/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.so
Note

The <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:

bash
php -r 'echo ini_get("extension_dir");' # /usr/local/lib/php/extensions/no-debug-zts-20250925

Use $(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.

Note

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:

Dockerfile
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:

Dockerfile
# 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/public

Build and run:

bash
docker build -t my-app . docker run -p 80:80 my-app

The 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:

bash
cargo build --release --no-default-features

The 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)
  • phpize and PHP development headers

Build steps

  1. Build and install the bridge library.

    bash
    cd ext/bridge make && sudo make install
  2. Build and install the PHP extension.

    bash
    cd ../ phpize && ./configure --enable-oxphp-sapi && make && sudo make install
  3. Build OxPHP. Default features include php.

    bash
    cargo build --release

The binary requires the shared libraries in the library search path at runtime:

bash
export LD_LIBRARY_PATH=/usr/local/lib ./target/release/oxphp
Note

When 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:

text
{"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:

bash
curl http://localhost/

If you enabled the internal server with INTERNAL_ADDR, verify the health endpoint:

bash
curl http://localhost:9090/health

A 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
Found a mistake? Report it →