Docker Guide

OxPHP runs as a container. This guide covers building, configuring, and running it with Docker, from a minimal single-stage image to a multi-stage setup with separate development and production targets.

Minimal Dockerfile

The simplest way to containerize your application:

Dockerfile
FROM ghcr.io/oxphp/oxphp:0.10.0 COPY --chown=www-data:www-data . /var/www/html/public

This copies your application into the container. The default DOCUMENT_ROOT is /var/www/html/public. For Laravel, Symfony, or any project that already ships a public/ subdirectory, use COPY --chown=www-data:www-data . /var/www/html instead so the framework's own public/ aligns with the default. The server listens on port 80 by default.

Multi-stage Dockerfile

For real-world applications, use a multi-stage Dockerfile with separate dev and prod targets. The dev target includes PHP CLI, Composer, and Xdebug. The prod target builds on the minimal OxPHP image with only what's needed in production.

Tip

A ready-to-use version of this Dockerfile lives at examples/dockerfile/Dockerfile in the repository. Copy it into your project and adjust the extensions to match your needs.

Dockerfile
# ── Stage: php-base — shared PHP extensions ────────────────── FROM php:8.4-zts-alpine3.23 AS php-base RUN apk add --no-cache \ icu-dev \ icu-libs \ postgresql-dev \ libpq \ && docker-php-ext-install \ pdo \ pdo_mysql \ pdo_pgsql \ intl \ && apk del icu-dev postgresql-dev # ── Stage: php-dev — add Xdebug on top of base ─────────────── FROM php-base AS php-dev RUN apk add --no-cache $PHPIZE_DEPS linux-headers \ && pecl install xdebug \ && docker-php-ext-enable xdebug \ && apk del $PHPIZE_DEPS linux-headers # ── Stage: composer ─────────────────────────────────────────── FROM composer:2 AS composer # ── Stage: oxphp — pull OxPHP artifacts ────────────────────── FROM ghcr.io/oxphp/oxphp:0.10.0 AS oxphp # ── Target: dev ────────────────────────────────────────────── # Includes: PHP CLI, Composer, Xdebug, OxPHP binary + extension FROM php-dev AS dev RUN apk add --no-cache libgcc # Composer COPY --from=composer /usr/bin/composer /usr/local/bin/composer # OxPHP binary COPY --from=oxphp /usr/local/bin/oxphp /usr/local/bin/oxphp # Bridge library COPY --from=oxphp /usr/local/lib/liboxphp_bridge.so /usr/local/lib/ # OxPHP PHP extension RUN EXT_DIR=$(php -r 'echo ini_get("extension_dir");') && \ echo "$EXT_DIR" > /tmp/ext_dir COPY --from=oxphp /usr/local/lib/php/extensions/ /tmp/oxphp-ext/ RUN cp /tmp/oxphp-ext/*/oxphp_sapi.so "$(cat /tmp/ext_dir)/" && \ rm -rf /tmp/oxphp-ext /tmp/ext_dir # PHP config RUN echo "extension=oxphp_sapi.so" > /usr/local/etc/php/conf.d/oxphp-ext.ini # Dev-friendly OPcache (validates timestamps) RUN { \ echo "[opcache]"; \ echo "opcache.enable=1"; \ echo "opcache.enable_cli=1"; \ echo "opcache.validate_timestamps=1"; \ echo "opcache.revalidate_freq=0"; \ } > /usr/local/etc/php/conf.d/opcache-dev.ini # Xdebug — connect back to host RUN { \ echo "[xdebug]"; \ echo "xdebug.mode=debug"; \ echo "xdebug.start_with_request=trigger"; \ echo "xdebug.client_host=host.docker.internal"; \ echo "xdebug.client_port=9003"; \ } > /usr/local/etc/php/conf.d/xdebug-config.ini RUN adduser -D -H -u 82 -G www-data -s /sbin/nologin www-data 2>/dev/null || true RUN mkdir -p /var/www/html/public && chown -R www-data:www-data /var/www/html ENV LD_LIBRARY_PATH=/usr/local/lib # Framework layout: project ships a public/ subdir (Laravel/Symfony/Slim). # For a bare index.php at the project root, copy into /var/www/html/public instead. COPY --chown=www-data:www-data . /var/www/html EXPOSE 80 443 CMD ["oxphp"] # ── Stage: prod-extensions — compile extensions for prod ───── FROM php-base AS prod-extensions RUN EXT_DIR=$(php -r 'echo ini_get("extension_dir");') && \ mkdir -p /ext-out && \ cp "$EXT_DIR"/pdo.so \ "$EXT_DIR"/pdo_mysql.so \ "$EXT_DIR"/pdo_pgsql.so \ "$EXT_DIR"/intl.so \ /ext-out/ # ── Target: prod — minimal, based on OxPHP image ───────────── FROM oxphp AS prod USER root RUN apk add --no-cache icu-libs libpq COPY --from=prod-extensions /ext-out/*.so /usr/local/lib/php/extensions/no-debug-zts-20240924/ RUN { \ echo "extension=pdo_mysql.so"; \ echo "extension=pdo_pgsql.so"; \ echo "extension=intl.so"; \ } > /usr/local/etc/php/conf.d/app-extensions.ini # Framework layout: project ships a public/ subdir (Laravel/Symfony/Slim). # For a bare index.php at the project root, copy into /var/www/html/public instead. COPY --chown=www-data:www-data . /var/www/html USER www-data EXPOSE 80 443 CMD ["oxphp"]

Build each target:

bash
# Development image (includes PHP CLI, Composer, Xdebug) docker build --target dev -t myapp:dev . # Production image (minimal) docker build --target prod -t myapp:prod .
Note

The dev target is based on php:8.4-zts-alpine (swap 8.4 for 8.5 to match an OxPHP :*-php8.5* tag) with OxPHP copied in, so you get full access to PHP CLI and Composer. The prod target is based on the OxPHP image directly, which keeps the production image small.

Installing PHP extensions in production

Starting with OxPHP 0.3.0, the production image ships the full PHP toolchain (php, docker-php-ext-install, phpize) inherited from php:8.4-zts-alpine (or php:8.5-zts-alpine for the :*-php8.5* variants) and does not set a USER directive. Downstream Dockerfiles can install PHP extensions directly. No USER toggle required.

Quick-start pattern (single stage)

The shortest useful example:

Dockerfile
FROM ghcr.io/oxphp/oxphp:0.10.0 RUN docker-php-ext-install mysqli pdo_mysql COPY --chown=www-data:www-data . /var/www/html/public CMD ["oxphp"]

--chown=www-data:www-data on the COPY is important: files are owned by www-data (uid 82) inside the image, so an orchestrator-level --user www-data drop lands on a webroot the unprivileged process can read and (where needed) write to.

The container starts as root, and oxphp serve then drops to www-data by default before serving any traffic (see the security note below). Pin the identity explicitly at the orchestrator level if you want a specific uid or a non-www-data user.

Best practice pattern (two stages, smaller image)

For the smallest possible final image, compile extensions in a dedicated builder stage and copy only the compiled .so files into the runtime stage. The Multi-stage Dockerfile walkthrough above uses FROM ghcr.io/oxphp/oxphp:0.10.0 AS prod — simple, portable, and recommended as a starting point.

examples/dockerfile/Dockerfile in the repository goes further: its prod target is based on bare alpine with an explicit apk dependency list, copying only the oxphp binary, libphp.so, compiled PHP extensions, and the required shared libraries. This cuts the base image from ~188 MB down to ~76 MB (~60% reduction, excluding your app code) at the cost of tracking PHP/Alpine version bumps in the apk list. The same file also ships a prod-cli target — a short-lived image for php artisan migrate, Composer, and other maintenance commands that should stay out of the serving path.

Note

The walkthrough above still shows explicit USER root / USER www-data toggles for defense-in-depth. With v0.3.0 they are optional, since the base image no longer sets USER.

Running CLI tools and migrations

The same prod image can run php CLI commands for migrations, Composer, or ad-hoc inspection. docker run replaces the default CMD with the command you pass: the container runs the command and exits, it does not also start the OxPHP server.

bash
# Run Laravel migrations against the prod image. # Container runs as root by default — the CLI has write access to # root-owned mounted volumes. docker run --rm \ -v "$(pwd):/var/www/html" \ ghcr.io/oxphp/oxphp:0.10.0 \ php artisan migrate # If the mounted volume is owned by www-data, pass Docker's --user: docker run --rm --user www-data \ -v "$(pwd):/var/www/html" \ ghcr.io/oxphp/oxphp:0.10.0 \ php artisan migrate

docker exec <container> docker-php-ext-install <ext> also works on a running container without any additional flags — useful for debugging a live container. For production, persist the extension in your Dockerfile so it survives restarts.

Security note

The prod image has no USER directive, so the container starts as root (matching nginx:alpine / php:*-fpm-alpine / frankenphp:alpine conventions). Starting as root lets OxPHP bind privileged ports, but it no longer means traffic is served as root: oxphp serve and oxphp run drop to www-data by default, binding as root and then permanently dropping before any request is handled or any PHP worker runs. On the official image (which ships the www-data account) this happens out of the box, with no orchestrator configuration.

You can still pin the runtime identity explicitly at the orchestrator level, recommended when you want a specific uid, extra defense-in-depth, or a non-www-data user:

bash
docker run --user www-data ghcr.io/oxphp/oxphp:0.10.0

When you start the container as non-root this way, OxPHP is already unprivileged and the default self-drop is a no-op, but the process then cannot bind ports below 1024 (see Run as non-root on port 80 to keep a privileged bind and non-root serving). To deliberately keep serving as root, pass oxphp serve --user=root.

The www-data user (uid 82, gid 82) is pre-created by the base image, and /var/www/html is chowned to it at build time, so any of these drop paths — including the default self-drop — lands on a readable webroot.

Note

CLI invocations like docker run … php artisan migrate run the php binary directly, not oxphp serve/run, so they do not self-drop; they run as the container's start user (root by default). Use Docker's --user for those, as shown above.

Run as non-root on port 80 (serve --user)

Dropping privileges at the orchestrator level (above) has one limitation: a process that starts as www-data cannot bind a privileged port (below 1024). To serve on :80/:443 you would otherwise need CAP_NET_BIND_SERVICE, a su-exec-style entrypoint, or a high port (such as :8080) behind a port mapping.

OxPHP collapses this into a single process: it binds the listeners as root, then permanently drops before any connection is accepted or any PHP worker runs. You get a privileged port and non-root request handling without extra capabilities. By default the dropped user is www-data, so on the official image simply starting the container as root already gives you a privileged bind served by www-data — no flag required. Use --user=<spec> only to drop to a different user; use --user=root to keep root.

Start the container as root — do not also set user:, because the bind needs root. The example below passes --user=www-data explicitly to be self-documenting, but it matches the default:

compose.yaml
services: oxphp: image: ghcr.io/oxphp/oxphp:0.10.0 command: ["oxphp", "serve", "--user=www-data"] ports: - "80:80" - "443:443" environment: - LISTEN_ADDR=0.0.0.0:80

<spec> accepts a user name, name:group, a numeric uid, or uid:gid. The drop runs initgroups → setgid → setuid, verifies that root cannot be regained, and is irreversible; on Linux it also sets no_new_privs. An explicit --user is fail-fast: if the process is not started as root, serve --user exits with an error rather than silently continuing as root. (The default drop is best-effort instead — started non-root it simply skips, since there is nothing to drop.)

Warning

Choose one model, not both. Use the orchestrator drop (user: / runAsUser) when a high port or an external load balancer terminates :80. Use serve --user when you want OxPHP itself to own the privileged bind. Setting user: and serve --user makes the bind fail: the container is no longer root.

File-permission checklist for the dropped user. After the drop, everything OxPHP touches at runtime must be accessible to <spec>:

Resource Requirement
DOCUMENT_ROOT Readable. /var/www/html is chowned to www-data at image build, so this is satisfied by default.
Session save path (session.save_path, default /tmp) Writable.
Upload tmp dir (upload_tmp_dir) Writable, when file uploads are used.
OPcache file cache (opcache.file_cache) Writable, when a secondary file cache is enabled.
File-based access log Writable, when logging to a file rather than stdout.
TLS private key (TLS_KEY) Readable by the dropped user — group- or world-readable, not root-only 0600. The key is read after the drop, so a root-only key makes TLS startup fail.

Docker Compose

compose.yaml
services: oxphp: build: context: . target: prod ports: - "80:80" - "443:443" - "9090:9090" environment: - LISTEN_ADDR=0.0.0.0:80 - DOCUMENT_ROOT=/var/www/html/public - ENTRY_FILE=index.php - INTERNAL_ADDR=0.0.0.0:9090 - LOG_LEVEL=info - ACCESS_LOG=error - PHP_WORKERS=4 - DRAIN_TIMEOUT_SECONDS=25 - COMPRESSION_LEVEL=4 restart: unless-stopped

Volume mounts

Host Path Container Path Purpose
./src /var/www/html Application files (PHP scripts, static assets). Use :ro in production
./custom.ini /usr/local/etc/php/conf.d/custom.ini PHP runtime configuration (OPcache, sessions, JIT). Use :ro
./certs /etc/ssl/oxphp TLS certificate and private key. Use :ro

Port reference

Port Environment Variable Purpose
80 LISTEN_ADDR Main HTTP server
443 LISTEN_ADDR Main HTTPS server (when TLS is configured)
9090 INTERNAL_ADDR Internal server: /health, /metrics, /config
Note

The internal server is disabled by default. Set INTERNAL_ADDR to enable it. In production, keep the internal port reachable only by your orchestrator or monitoring system; do not expose it publicly.

PHP configuration

Customize PHP settings by creating a custom.ini file and mounting it into the container. This is the recommended way to configure OPcache, JIT, sessions, and other PHP runtime settings.

custom.ini
; Do NOT add zend_extension=opcache — OPcache is already compiled into the ; PHP ZTS base image. The [opcache] section below configures it directly. [opcache] opcache.enable = 1 opcache.enable_cli = 1 opcache.memory_consumption = 128 opcache.interned_strings_buffer = 16 opcache.max_accelerated_files = 10000 opcache.validate_timestamps = 0 opcache.jit_buffer_size = 64M opcache.jit = tracing [Session] session.save_path = /tmp session.use_cookies = 1 session.use_only_cookies = 1
Note

Do not add zend_extension=opcache to this file. OPcache is already built into the PHP ZTS image used by OxPHP. Adding a zend_extension line will produce a warning on every request startup.

In development, set opcache.validate_timestamps = 1 and opcache.revalidate_freq = 0 so PHP picks up file changes without a container restart.

See OPcache for recommended settings and JIT configuration.

Health checks

Add a Docker health check to let Docker or your orchestrator monitor container health. This requires INTERNAL_ADDR to be set.

compose.yaml
services: oxphp: environment: - INTERNAL_ADDR=0.0.0.0:9090 healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9090/health"] interval: 10s timeout: 5s retries: 3 start_period: 5s

The /health endpoint returns 200 when the server is healthy and 503 when degraded. The response JSON includes uptime, total request count, and active connection count. For Kubernetes, use the same endpoint as both a liveness and readiness probe.

What's next

  • Configuration — full environment variable reference
  • Routing — Traditional, Framework, SPA, and Worker routing modes
  • Worker Mode — persistent PHP processes for framework applications
  • TLS — HTTPS with built-in TLS termination
  • Health Checks — health endpoint details and Kubernetes integration
  • Graceful Shutdown — drain behavior and shutdown sequence