Usage

Run, configure and extend the images, with a compose file or Dockerfile for each.

Run

Pick the Variant that matches how your app takes requests.

One container

nginx and php-fpm run side by side under s6-overlay. nginx serves static files from APP_PUBLIC_PATH and passes PHP requests to php-fpm on the loopback.

Browser
HTTP
php:8.5-php-fpm-nginx
nginx:8080
FastCGI 127.0.0.1:9000
php-fpm/app
compose.yaml
services:
  app:
    image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
    ports:
      - "8080:8080"
    volumes:
      - "./:/app"
    environment:
      PUID: 1000

nginx and php-fpm, separately

The nginx image finds php-fpm by name through PHP_FPM_HOST, resolving it again every 10 seconds so php-fpm can restart or scale. nginx needs only the public files; php-fpm needs the whole app.

Browser
HTTP
nginx:alpine
nginx:8080
FastCGI php:9000
php:8.5-php-fpm
php-fpm:9000
compose.yaml
services:
  web:
    image: ghcr.io/mintopia/nginx:alpine
    ports:
      - "8080:8080"
    volumes:
      - "./public:/app/public:ro"
    environment:
      PHP_FPM_HOST: php
  php:
    image: ghcr.io/mintopia/php:8.5-php-fpm
    volumes: ["./:/app"]

FrankenPHP

Caddy and PHP in one process. Worker mode keeps the app booted between requests. Set SERVER_NAME to a hostname and Caddy fetches certificates itself.

Browser
HTTP or HTTPS
php:8.5-frankenphp
Caddy + PHP worker:8080
compose.yaml
services:
  app:
    image: ghcr.io/mintopia/php:8.5-frankenphp
    ports:
      - "8080:8080"
    volumes:
      - "./:/app"
    environment:
      FRANKENPHP_CONFIG: worker ./public/index.php

Commands and workers

The cli image runs whatever command you give it: one-off tasks, or queue workers and schedulers alongside a web container.

shell
docker run --rm -v "$PWD:/app" ghcr.io/mintopia/php:8.5 php artisan migrate --force
compose.yaml
services:
  queue:
    image: ghcr.io/mintopia/php:8.5
    volumes:
      - "./:/app"
    command: php artisan queue:work

Configure

Settings are environment variables read when the container starts. These are the ones most setups change; the README lists every one.

Behind a proxy

nginx and FrankenPHP accept X-Forwarded-* headers only from addresses in TRUSTED_PROXIES, then hand PHP the real client address and scheme. Your framework can trust every proxy safely. Switch the setting to see what PHP receives.

Browser203.0.113.7
HTTPS
Proxy172.18.0.2
HTTP, X-Forwarded-*
php:8.5-php-fpm-nginx
trusts 172.18.0.2trusts nothingnginx
FastCGI
PHP
$_SERVERPHP sees
REMOTE_ADDR203.0.113.7172.18.0.2
HTTPSon(empty)
REQUEST_SCHEMEhttpshttp
HTTP_X_FORWARDED_FOR203.0.113.7172.18.0.2
HTTP_X_FORWARDED_HOSTpassed through(empty)
compose.yaml
services:
  app:
    image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
    environment:
      # the Docker network the proxy reaches the container from
      TRUSTED_PROXIES: 172.16.0.0/12

What to trust

In front of the container Set
Nothing; clients connect directly nothing
AWS ALB, or another load balancer in a private network TRUSTED_PROXIES=10.0.0.0/16, the VPC CIDR or the load balancer's subnets
Traefik, Caddy or nginx on the same Docker network TRUSTED_PROXIES=172.16.0.0/12, or the network's subnet
Kubernetes ingress controller the pod CIDR, for example TRUSTED_PROXIES=10.244.0.0/16
Cloudflare, straight to the container Cloudflare's published IP ranges in TRUSTED_PROXIES, and CLIENT_IP_HEADER=CF-Connecting-IP

With several proxies in a chain, list every hop's range. Never trust 0.0.0.0/0 in production.

Non-root, read-only

Every image runs with a read-only root filesystem and no capabilities, given a tmpfs where the variant writes. PUID and PGID run processes as that user; php-fpm's master stays root and only its workers switch. Switching user needs SETUID and SETGID added back.

compose.yaml
services:
  app:
    image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
    read_only: true
    tmpfs:
      - "/run:exec"
      - "/tmp"
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
    environment:
      PUID: 1000
VariantWritable tmpfs
clinone, or /tmp if the app writes temp files
php-fpm/run, and /tmp for sessions and uploads
nginx/run
php-fpm-nginx/run:exec for s6-overlay, and /tmp
frankenphp/data, /config, /tmp

nginx renders its config into /run/nginx; extra *.conf files mounted in /etc/nginx/conf.d/ are still included. With FrankenPHP's automatic HTTPS, use a volume for /data so certificates survive restarts.

Status pages

Off by default. Setting a path turns that page on, reachable only from addresses in PHP_FPM_STATUS_ALLOW.

compose.yaml
environment:
  PHP_FPM_STATUS_PATH: /fpm-status
  PHP_FPM_PING_PATH: /fpm-ping
  NGINX_STATUS_PATH: /nginx-status
  PHP_FPM_STATUS_ALLOW: "127.0.0.1 ::1 10.0.0.0/8"

Logs

Everything goes to stdout and stderr, so docker logs shows it all. Nothing is written to files in the container.

Variantstdoutstderr
clithe script's outputPHP errors and warnings
php-fpmnothingphp-fpm's log, an access line per request, and PHP errors
php-fpm-nginxnginx's access lognginx's error log, php-fpm's log and PHP errors
nginxnginx's access lognginx's error log
frankenphpnothingCaddy and FrankenPHP's JSON log, PHP errors included

php-fpm prefixes PHP errors with NOTICE: PHP message: and cuts lines at 8192 bytes. FrankenPHP has no access log unless you set CADDY_SERVER_EXTRA_DIRECTIVES=log.

Extend

Build your own image on top: add extensions, start-up hooks and Composer dependencies in a Dockerfile.

Extensions and start-up hooks

install-php-extensions and pie are in every image. Executables in /docker-entrypoint.d/ run in filename order at start, as root, before the command.

Dockerfile
FROM ghcr.io/mintopia/php:8.5-php-fpm
RUN install-php-extensions xsl
COPY --chmod=755 migrate.sh /docker-entrypoint.d/50-migrate

Composer

Composer isn't in the images. Run it in a build stage on the cli image: it has the same PHP and extensions as the image you deploy, so composer install checks your ext-* requirements against the real ones. Only the app and its vendor/ reach the final stage.

Dockerfile
FROM ghcr.io/mintopia/php:8.5-cli AS vendor
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative

FROM ghcr.io/mintopia/php:8.5-php-fpm
COPY --from=vendor /app /app

On trixie, also install unzip in the build stage; Composer needs it to unpack packages.

PIE extensions

pie installs and enables extensions published on Packagist. The images carry no compiler, so add $PHPIZE_DEPS and remove it in the same RUN to keep the layer small.

Dockerfile
FROM ghcr.io/mintopia/php:8.5-php-fpm
RUN apk add --no-cache --virtual .pie-deps $PHPIZE_DEPS \
    && pie install xdebug/xdebug \
    && apk del .pie-deps
Dockerfile
FROM ghcr.io/mintopia/php:8.5-trixie-php-fpm
RUN apt-get update \
    && apt-get install -y --no-install-recommends $PHPIZE_DEPS unzip \
    && pie install xdebug/xdebug \
    && apt-get purge -y --auto-remove $PHPIZE_DEPS unzip \
    && rm -rf /var/lib/apt/lists/*

On PHP 8.6, PIE is the only way to add PECL extensions. For extensions not on Packagist, install-php-extensions handles its own build dependencies, as in Extensions and start-up hooks.

Develop

The same images work for local development, with a few settings changed.

Development mode

PHP_INI_MODE=development loads php.ini-development, which displays errors. Leave PHP_OPCACHE_VALIDATE_TIMESTAMPS at 1 so code changes are picked up. PHP_FPM_MAX_REQUESTS=1 restarts each php-fpm worker after every request, so no state leaks between requests.

compose.yaml
services:
  app:
    image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
    environment:
      PHP_INI_MODE: development
      PHP_FPM_MAX_REQUESTS: 1

FrankenPHP has no per-worker request limit. Skip worker mode in development, or let the app restart its worker: Laravel Octane takes --max-requests=1, and the Symfony runtime reads FRANKENPHP_LOOP_MAX=1.

Xdebug

Install it in a derived image. On trixie there is no compiler, so add $PHPIZE_DEPS and purge it in the same RUN.

Dockerfile
FROM ghcr.io/mintopia/php:8.5-php-fpm
RUN install-php-extensions xdebug
Dockerfile
FROM ghcr.io/mintopia/php:8.5-trixie-php-fpm
RUN apt-get update \
    && apt-get install -y --no-install-recommends $PHPIZE_DEPS \
    && install-php-extensions xdebug \
    && apt-get purge -y --auto-remove $PHPIZE_DEPS \
    && rm -rf /var/lib/apt/lists/*

Configure it with an .ini file in /usr/local/etc/php/conf.d/, copied in or mounted.

xdebug.ini
xdebug.mode=debug
xdebug.client_host=host.docker.internal
xdebug.start_with_request=trigger

On Linux, host.docker.internal needs --add-host=host.docker.internal:host-gateway. Xdebug has no PHP 8.6 release yet.

Reloading FrankenPHP workers

FrankenPHP restarts worker scripts when files they watch change. Spread the worker block over several lines.

compose.yaml
environment:
  FRANKENPHP_CONFIG: |
    worker {
      file ./public/index.php
      watch ./**/*.php
    }