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.
| You need | Variant |
|---|---|
| One container serving a PHP app over HTTP | php-fpm-nginx or frankenphp |
| Web server and PHP scaled separately | nginx + php-fpm |
| Long-running workers, HTTPS from Caddy, early hints | frankenphp |
| Queues, cron, migrations, tests | cli |
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.
services:
app:
image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
ports:
- "8080:8080"
volumes:
- "./:/app"
environment:
PUID: 1000nginx 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.
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.
services:
app:
image: ghcr.io/mintopia/php:8.5-frankenphp
ports:
- "8080:8080"
volumes:
- "./:/app"
environment:
FRANKENPHP_CONFIG: worker ./public/index.phpCommands and workers
The cli image runs whatever command you give it: one-off tasks, or queue workers and schedulers alongside a web container.
docker run --rm -v "$PWD:/app" ghcr.io/mintopia/php:8.5 php artisan migrate --forceservices:
queue:
image: ghcr.io/mintopia/php:8.5
volumes:
- "./:/app"
command: php artisan queue:workConfigure
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.
| $_SERVER | PHP sees |
|---|---|
| REMOTE_ADDR | 203.0.113.7172.18.0.2 |
| HTTPS | on(empty) |
| REQUEST_SCHEME | httpshttp |
| HTTP_X_FORWARDED_FOR | 203.0.113.7172.18.0.2 |
| HTTP_X_FORWARDED_HOST | passed through(empty) |
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/12What 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.
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| Variant | Writable tmpfs |
|---|---|
cli | none, 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.
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.
| Variant | stdout | stderr |
|---|---|---|
cli | the script's output | PHP errors and warnings |
php-fpm | nothing | php-fpm's log, an access line per request, and PHP errors |
php-fpm-nginx | nginx's access log | nginx's error log, php-fpm's log and PHP errors |
nginx | nginx's access log | nginx's error log |
frankenphp | nothing | Caddy 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.
FROM ghcr.io/mintopia/php:8.5-php-fpm
RUN install-php-extensions xsl
COPY --chmod=755 migrate.sh /docker-entrypoint.d/50-migrateComposer
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.
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 /appOn 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.
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-depsFROM 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.
services:
app:
image: ghcr.io/mintopia/php:8.5-php-fpm-nginx
environment:
PHP_INI_MODE: development
PHP_FPM_MAX_REQUESTS: 1FrankenPHP 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.
FROM ghcr.io/mintopia/php:8.5-php-fpm
RUN install-php-extensions xdebugFROM 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.mode=debug
xdebug.client_host=host.docker.internal
xdebug.start_with_request=triggerOn 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.
environment:
FRANKENPHP_CONFIG: |
worker {
file ./public/index.php
watch ./**/*.php
}