Docker images for PHP 8.1 to 8.6 apps: Apache (Debian, with a Symfony variant), FrankenPHP (Debian or Alpine) and CI (Alpine). PHP 8.6 images are built from the upstream release candidate until 8.6.0 is released. Legacy images for PHP 5.6 to 8.0 are still available but are no longer rebuilt automatically.
- GitHub: https://github.com/silarhi/docker-php
- Demo: https://labs.silarhi.fr/php
- Demo (404): https://labs.silarhi.fr/php/notfound
- Docker image: https://hub.docker.com/r/silarhi/php-apache
- Same tags on GHCR, without Docker Hub's pull rate limits:
ghcr.io/silarhi/php-apache(e.g.ghcr.io/silarhi/php-apache:8.5-frankenphp-alpine) - Blog post: https://blog.silarhi.fr/image-docker-php-apache-parfaite/
These tags were last pushed on 2024-12-19. They are not part of the CI matrix and are only rebuilt by hand with
make build-legacy, so they no longer receive OS or PHP security updates.
- Standard (
X.Y): Production-ready images with Apache and common PHP extensions (opcache, pdo_mysql) - CI (
X.Y-ci): Lightweight Alpine-based images optimized for CI/CD pipelines - Symfony (
X.Y-symfony): Enhanced images with additional extensions for Symfony applications (intl, zip) - FrankenPHP Alpine (
X.Y-frankenphp-alpine): Alpine-based images using FrankenPHP server for modern PHP apps - FrankenPHP Trixie (
X.Y-frankenphp-trixie): Debian Trixie-based images using FrankenPHP server for modern PHP apps - FrankenPHP Bookworm (
X.Y-frankenphp-bookworm): Same on Debian Bookworm, kept for existing users: prefer the Trixie variant
PHP settings of the Apache and FrankenPHP images live in conf/php.ini (installed as conf.d/app.ini).
| Environment variable | Default | Effect |
|---|---|---|
PHP_OPCACHE_VALIDATE_TIMESTAMPS |
0 |
opcache.validate_timestamps. With 0, PHP files are compiled once and never checked for changes: restart the container to deploy new code. Set it to 1 when the sources are mounted and edited in place (local development). |
The Symfony and FrankenPHP images answer /index.php and /index.php/foo with a 308 redirect to / and /foo,
keeping the query string and the path prefix a proxy stripped (X-Forwarded-Prefix, sent by Traefik's
stripprefix). Symfony strips the script name before routing, so these URLs would otherwise serve a
duplicate of every page, and requests like /index.php/admin would slip past proxy rules written for /admin.
The standard Apache images (X.Y) leave these URLs alone, for apps that rely on them.
Prefer the Debian variants (-trixie) in production: PHP is
noticeably slower on musl
(Alpine), especially in the thread-safe build FrankenPHP uses.
The default /etc/frankenphp/Caddyfile reads these environment variables:
| Environment variable | Default | Effect |
|---|---|---|
SERVER_NAME |
:80 |
Site address. Plain HTTP on port 80: TLS is left to the reverse proxy. |
SERVER_ROOT |
/app/public |
Document root. |
FRANKENPHP_CONFIG |
Options of the global frankenphp block: workers, threads, php_ini... |
|
FRANKENPHP_WORKER_CONFIG |
Extra options of the Symfony worker below, e.g. watch in development. |
|
CADDY_GLOBAL_OPTIONS, CADDY_SERVERS_CONFIG |
Extra global options, and options of the servers block (e.g. max_header_size 32KB). |
|
CADDY_SERVER_EXTRA_DIRECTIVES |
Extra directives of the site block. | |
CADDY_EXTRA_CONFIG |
Extra site blocks. You can also add *.caddyfile files to /etc/frankenphp/Caddyfile.d/. |
# Symfony 7.4+ (native support in symfony/runtime)
ENV FRANKENPHP_CONFIG="import symfony-worker-native.Caddyfile"
# Symfony < 7.4, with runtime/frankenphp-symfony installed
ENV FRANKENPHP_CONFIG="import symfony-worker.Caddyfile"The worker is named symfony in logs and metrics. Its code stays in memory, so PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 is
not enough to see changes in development: set FRANKENPHP_WORKER_CONFIG=watch to restart the worker when a .env,
.php, .twig or .yaml file changes under /app.
By default FrankenPHP starts 2 PHP threads per CPU and never more. Size them from a load test, and keep
max_threads × memory_limit below the container memory:
# compose.yaml: one option per line, which a Dockerfile ENV can't hold
services:
app:
environment:
# Scale up to what memory_limit allows, time out requests waiting more than 30s for a thread,
# and restart each thread after 500 requests to contain memory leaks.
FRANKENPHP_CONFIG: |
import symfony-worker-native.Caddyfile
max_threads auto
max_wait_time 30s
max_requests 500
# Let the Go runtime know the container memory limit.
GOMEMLIMIT: 900MiB
deploy:
resources:
limits:
memory: 1GSince FrankenPHP 1.13, num_threads counts only the threads left for requests no worker serves: worker threads come on
top of it. Caddy also limits request headers to 16 KiB, drops headers whose name contains _ or ., and cuts off reads
and writes stalled for 1 minute. Request bodies idle for 60s are aborted as well (request_body_timeout in
php_server).
The Apache and FrankenPHP images trust X-Forwarded-* headers from private networks (Docker, Traefik, load balancers)
and from Cloudflare's IP ranges, and resolve the proxy chain before PHP runs:
REMOTE_ADDRis the visitor IP, read right to left fromX-Forwarded-Forup to the first untrusted address (mod_remoteipon Apache,trusted_proxieson FrankenPHP).X-Forwarded-Foris not passed on to PHP.HTTPSisonwhen the request reached the proxy over HTTPS (X-Forwarded-Proto: https).
On FrankenPHP, CADDY_TRUSTED_PROXIES_RANGES replaces the trusted ranges. X-Forwarded-Proto is accepted from any
client, so only expose the containers through the proxy.
Cloudflare → Traefik → container: Traefik must trust Cloudflare as well. Otherwise it replaces X-Forwarded-For
with the Cloudflare edge address, and every visitor shows up with a Cloudflare IP.
[entryPoints.websecure.forwardedHeaders]
trustedIPs = ["173.245.48.0/20", "103.21.244.0/22", "..."] # every range from https://www.cloudflare.com/ips/Symfony: the chain is already resolved, so leave framework.trusted_proxies empty or set it to private_ranges.
Don't use REMOTE_ADDR: it is now the visitor, and Symfony would trust the visitor's own X-Forwarded-Port header
when generating URLs.