FrankenPHP — Modern PHP Application Server on Caddy
Practical guide to FrankenPHP — the modern PHP app server built on Caddy: worker mode, automatic HTTPS, HTTP/3 and Early Hints, no PHP-FPM needed.
FrankenPHP is a modern PHP application server built on top of the Caddy web server, with the PHP runtime embedded directly into a single Go binary — so you no longer need a separate PHP-FPM process. Its standout feature is worker mode: your application bootstraps once and then handles thousands of requests in a long-running process, cutting per-request overhead dramatically. On top of that you get automatic HTTPS, HTTP/2 and HTTP/3 out of the box, plus Early Hints (103) so the browser can start fetching assets before your response is ready. This guide walks you through installation, the Caddyfile, worker mode and Docker deployment.
Installation
curl -L https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-x86_64 -o /usr/local/bin/frankenphp && chmod +x /usr/local/bin/frankenphp — Download and install the latest FrankenPHP static binary on Linux (x86_64).
curl -L https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-x86_64 -o /usr/local/bin/frankenphpfrankenphp version — Show the installed FrankenPHP and Caddy version.
frankenphp versionfrankenphp list-modules — List all available Caddy and FrankenPHP modules.
frankenphp list-modulesfrankenphp upgrade — Upgrade FrankenPHP to the latest release in-place.
frankenphp upgradeCLI Commands
frankenphp run — Start FrankenPHP in the foreground using the Caddyfile in the current directory.
frankenphp runfrankenphp run --config <file> — Start FrankenPHP in the foreground with a specific Caddyfile.
frankenphp run --config /etc/frankenphp/Caddyfilefrankenphp start — Start FrankenPHP as a background daemon.
frankenphp startfrankenphp stop — Stop the running FrankenPHP daemon.
frankenphp stopfrankenphp reload — Reload the Caddyfile configuration with zero downtime.
frankenphp reloadfrankenphp validate --config <file> — Validate a Caddyfile for errors without starting the server.
frankenphp validate --config Caddyfilefrankenphp fmt --overwrite <file> — Format and overwrite a Caddyfile in place according to canonical style.
frankenphp fmt --overwrite Caddyfilefrankenphp php-cli <script> — Execute a PHP script from the command line using the embedded PHP runtime.
frankenphp php-cli artisan migratefrankenphp php-cli -r '<code>' — Run inline PHP code from the command line.
frankenphp php-cli -r 'echo phpversion();'frankenphp php-server — Start a quick PHP development server in the current directory (like php -S, but better).
frankenphp php-serverfrankenphp php-server --listen :8080 — Start a PHP development server on a specific port.
frankenphp php-server --listen :8080Service Management (systemctl)
systemctl start frankenphp — Start the FrankenPHP service.
systemctl start frankenphpsystemctl stop frankenphp — Stop the FrankenPHP service.
systemctl stop frankenphpsystemctl restart frankenphp — Restart FrankenPHP (briefly drops connections).
systemctl restart frankenphpsystemctl reload frankenphp — Reload the Caddyfile configuration with zero downtime.
systemctl reload frankenphpsystemctl status frankenphp — Show FrankenPHP service status and recent log output.
systemctl status frankenphpsystemctl enable frankenphp — Enable FrankenPHP to start automatically on system boot.
systemctl enable frankenphpjournalctl -u frankenphp -f — Follow FrankenPHP logs in real-time via the systemd journal.
journalctl -u frankenphp -fCaddyfile: php_server & php
The php_server directive is FrankenPHP's main shorthand. It handles PHP files, static files, and try_files in one directive. TLS is automatic.
example.com {
root * /var/www/html
php_server
}PHP site with gzip/zstd compression enabled.
example.com {
root * /var/www/html
encode gzip zstd
php_server
}php_server with options: explicit index file and symlink resolution for the root.
php_server {
index index.php
resolve_root_symlink
}Use the lower-level php directive for fine-grained control over request handling.
example.com {
root * /var/www/html
php {
try_files {path} index.php
}
file_server
}Enable worker mode: start a persistent PHP worker script alongside the server.
example.com {
root * /var/www/html
php_server {
worker worker.php
}
}Worker Mode
Start a single PHP worker process. The script bootstraps once and handles all requests.
php_server {
worker /var/www/html/worker.php
}Start 4 parallel PHP worker processes for handling concurrent requests.
php_server {
worker {
file /var/www/html/worker.php
num 4
}
}Automatically determine the number of workers based on available CPU cores.
php_server {
worker {
file /var/www/html/worker.php
num auto
}
}frankenphp_handle_request() — Worker script pattern: bootstrap the app once outside the loop, then call frankenphp_handle_request() in a loop. The closure runs for each incoming request.
<?php
// worker.php — bootstrap once
require __DIR__ . '/vendor/autoload.php';
$app = require __DIR__ . '/bootstrap/app.php';
// Handle requests in a loop until the server shuts down
while (frankenphp_handle_request(function () use ($app) {
$kernel = $app->make(Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
);
$response->send();
$kernel->terminate($request, $response);
}));while (frankenphp_handle_request(fn() => handleRequest()));frankenphp_finish_request(); — Send the current HTTP response to the client immediately, then continue executing PHP code in the background. Useful for post-response processing.
frankenphp_finish_request();
// background work here...Return false from the callback to gracefully shut down the worker process.
frankenphp_handle_request(function(): bool {
// return false to stop the worker
return true;
});Docker
docker run -v $PWD:/app/public -p 80:80 -p 443:443 dunglas/frankenphp — Serve PHP files from the current directory with automatic HTTPS (using internal CA on localhost).
docker run -v $PWD:/app/public -p 80:80 -p 443:443 dunglas/frankenphpdocker run -e SERVER_NAME=example.com -v $PWD:/app/public -p 80:80 -p 443:443 dunglas/frankenphp — Serve a domain with automatic Let's Encrypt TLS by setting the SERVER_NAME environment variable.
docker run -e SERVER_NAME=example.com ... dunglas/frankenphpdocker run -e SERVER_NAME=:80 -v $PWD:/app/public -p 80:80 dunglas/frankenphp — Run on HTTP only (no TLS) inside Docker by setting SERVER_NAME to a port.
docker run -e SERVER_NAME=:80 -v $PWD:/app/public -p 80:80 dunglas/frankenphpMinimal Dockerfile to build a self-contained image from a PHP project.
FROM dunglas/frankenphp
COPY . /app/publicProduction Dockerfile: install Composer dependencies and set the domain.
FROM dunglas/frankenphp
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
COPY . .
ENV SERVER_NAME=example.comFROM dunglas/frankenphp:latest-alpine — Use the Alpine-based image for a smaller production image size.
FROM dunglas/frankenphp:latest-alpinedocker exec -it <container> frankenphp php-cli artisan migrate — Run a PHP CLI command inside a running FrankenPHP container.
docker exec -it myapp frankenphp php-cli artisan migrateEnvironment Variables
SERVER_NAME=example.com — Set the domain name. FrankenPHP automatically provisions a TLS certificate for it.
SERVER_NAME=example.com frankenphp runSERVER_NAME=:80 — Listen on HTTP only without TLS (useful in local dev or behind a TLS-terminating proxy).
SERVER_NAME=:80 frankenphp runSERVER_NAME=localhost — Use a locally-trusted certificate from Caddy's internal CA for local development.
SERVER_NAME=localhost frankenphp runFRANKENPHP_CONFIG='worker ./worker.php' — Inline Caddyfile snippet for the php_server block via environment variable.
FRANKENPHP_CONFIG='worker ./worker.php' frankenphp runCADDY_GLOBAL_OPTIONS='debug' — Inject global Caddyfile options via environment variable (e.g. enable debug logging).
CADDY_GLOBAL_OPTIONS='debug' frankenphp runXDG_DATA_HOME=/data XDG_CONFIG_HOME=/config — Override FrankenPHP/Caddy data and config directories (default: ~/.local/share and ~/.config).
XDG_DATA_HOME=/data XDG_CONFIG_HOME=/config frankenphp runFramework Integration
Serve a standard Laravel app (without worker mode). The document root is the public/ directory.
example.com {
root * /var/www/laravel/public
php_server
}Laravel with FrankenPHP worker mode using Laravel Octane.
example.com {
root * /var/www/laravel/public
php_server {
worker {
file /var/www/laravel/public/frankenphp-worker.php
num 4
}
}
}Install and start Laravel Octane with FrankenPHP as the server driver.
composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:frankenphpUse the FrankenPHP Symfony Runtime for zero-configuration Symfony worker mode.
composer require runtime/frankenphp-symfony
APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime php -S localhost:8000 public/index.phpServe a WordPress site. FrankenPHP replaces both Apache/nginx and PHP-FPM.
example.com {
root * /var/www/wordpress
php_server
encode gzip zstd
}Serve a Drupal site. Point root to the web/ subdirectory.
example.com {
root * /var/www/drupal/web
php_server
}TLS & HTTPS
TLS is fully automatic for public domains — no extra configuration needed.
example.com {
root * /app/public
php_server
# TLS provisioned automatically
}tls /path/to/cert.pem /path/to/key.pem — Use a manually managed TLS certificate instead of the automatic one.
tls /etc/ssl/certs/example.crt /etc/ssl/private/example.keyfrankenphp trust — Install FrankenPHP's local root CA into the system and browser trust stores for localhost HTTPS.
frankenphp trustfrankenphp untrust — Remove the local root CA from trust stores.
frankenphp untrustSet the ACME registration email in the global options block above all site blocks.
{
email admin@example.com
}
example.com {
root * /app/public
php_server
}Early Hints (103)
header('Link: </style.css>; rel=preload; as=style', false); — Send an HTTP 103 Early Hints response from PHP by calling header() before any output. FrankenPHP forwards it to the client immediately.
header('Link: </app.js>; rel=preload; as=script', false);Send multiple Early Hints before generating the full response. Browsers can start fetching assets while the server is still processing.
header('Link: </image.jpg>; rel=preload; as=image', false);
header('Link: </style.css>; rel=preload; as=style', false);
// ... generate response ...
echo $html;Logging & Debugging
Write access logs to a file in JSON format.
example.com {
log {
output file /var/log/frankenphp/access.log
format json
}
php_server
}Enable verbose debug logging in the global options block.
{
debug
}
example.com {
php_server
}journalctl -u frankenphp -f — Follow FrankenPHP logs in real-time via the systemd journal.
journalctl -u frankenphp -fcurl -s http://localhost:2019/config/ | jq '.' — Inspect the current FrankenPHP/Caddy configuration via the admin API.
curl -s http://localhost:2019/config/ | jq '.'curl -s http://localhost:2019/reverse_proxy/upstreams — Check upstream health status via the admin API.
curl -s http://localhost:2019/reverse_proxy/upstreams | jq '.'Practical Examples
Production-ready PHP site with compression and hardened security headers.
example.com {
root * /var/www/app/public
encode gzip zstd
php_server
header -Server
header X-Frame-Options DENY
header X-Content-Type-Options nosniff
}Production worker-mode deployment with auto-sized worker pool and compression.
example.com {
root * /var/www/app/public
php_server {
worker {
file /var/www/app/public/worker.php
num auto
}
}
encode gzip zstd
}Redirect www to apex domain and serve the PHP app.
www.example.com {
redir https://example.com{uri} permanent
}
example.com {
root * /var/www/app/public
php_server
}Hardened production setup: admin API disabled, ACME email set, logging to file.
{
admin off
email admin@example.com
}
example.com {
root * /app/public
encode gzip zstd
php_server
log {
output file /var/log/frankenphp/access.log
}
}docker run --rm -p 8080:8080 -e SERVER_NAME=:8080 -v $PWD:/app/public dunglas/frankenphp — Quickly serve the current directory as a PHP app on port 8080 without TLS.
docker run --rm -p 8080:8080 -e SERVER_NAME=:8080 -v $PWD:/app/public dunglas/frankenphp Conclusion
FrankenPHP collapses the classic web-server-plus-PHP-FPM stack into one self-contained binary and, with worker mode, turns PHP into a high-throughput application server that rivals Node.js or Go for request latency. The trade-off is statefulness: because your code stays resident between requests, you have to watch for memory leaks and state that bleeds from one request into the next — keep request handlers clean and reset global state inside the loop. As with Caddy, HTTPS is fully automatic, so a production deployment is often just a domain name and a php_server directive away.
Further Reading
- FrankenPHP – official documentation – installation, worker mode and configuration reference
- FrankenPHP on GitHub – source code, releases and issue tracker