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/frankenphp

frankenphp version — Show the installed FrankenPHP and Caddy version.

frankenphp version

frankenphp list-modules — List all available Caddy and FrankenPHP modules.

frankenphp list-modules

frankenphp upgrade — Upgrade FrankenPHP to the latest release in-place.

frankenphp upgrade

CLI Commands

frankenphp run — Start FrankenPHP in the foreground using the Caddyfile in the current directory.

frankenphp run

frankenphp run --config <file> — Start FrankenPHP in the foreground with a specific Caddyfile.

frankenphp run --config /etc/frankenphp/Caddyfile

frankenphp start — Start FrankenPHP as a background daemon.

frankenphp start

frankenphp stop — Stop the running FrankenPHP daemon.

frankenphp stop

frankenphp reload — Reload the Caddyfile configuration with zero downtime.

frankenphp reload

frankenphp validate --config <file> — Validate a Caddyfile for errors without starting the server.

frankenphp validate --config Caddyfile

frankenphp fmt --overwrite <file> — Format and overwrite a Caddyfile in place according to canonical style.

frankenphp fmt --overwrite Caddyfile

frankenphp php-cli <script> — Execute a PHP script from the command line using the embedded PHP runtime.

frankenphp php-cli artisan migrate

frankenphp 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-server

frankenphp php-server --listen :8080 — Start a PHP development server on a specific port.

frankenphp php-server --listen :8080

Service Management (systemctl)

systemctl start frankenphp — Start the FrankenPHP service.

systemctl start frankenphp

systemctl stop frankenphp — Stop the FrankenPHP service.

systemctl stop frankenphp

systemctl restart frankenphp — Restart FrankenPHP (briefly drops connections).

systemctl restart frankenphp

systemctl reload frankenphp — Reload the Caddyfile configuration with zero downtime.

systemctl reload frankenphp

systemctl status frankenphp — Show FrankenPHP service status and recent log output.

systemctl status frankenphp

systemctl enable frankenphp — Enable FrankenPHP to start automatically on system boot.

systemctl enable frankenphp

journalctl -u frankenphp -f — Follow FrankenPHP logs in real-time via the systemd journal.

journalctl -u frankenphp -f

Caddyfile: 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/frankenphp

docker 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/frankenphp

docker 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/frankenphp

Minimal Dockerfile to build a self-contained image from a PHP project.

FROM dunglas/frankenphp
COPY . /app/public

Production 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.com

FROM dunglas/frankenphp:latest-alpine — Use the Alpine-based image for a smaller production image size.

FROM dunglas/frankenphp:latest-alpine

docker 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 migrate

Environment Variables

SERVER_NAME=example.com — Set the domain name. FrankenPHP automatically provisions a TLS certificate for it.

SERVER_NAME=example.com frankenphp run

SERVER_NAME=:80 — Listen on HTTP only without TLS (useful in local dev or behind a TLS-terminating proxy).

SERVER_NAME=:80 frankenphp run

SERVER_NAME=localhost — Use a locally-trusted certificate from Caddy's internal CA for local development.

SERVER_NAME=localhost frankenphp run

FRANKENPHP_CONFIG='worker ./worker.php' — Inline Caddyfile snippet for the php_server block via environment variable.

FRANKENPHP_CONFIG='worker ./worker.php' frankenphp run

CADDY_GLOBAL_OPTIONS='debug' — Inject global Caddyfile options via environment variable (e.g. enable debug logging).

CADDY_GLOBAL_OPTIONS='debug' frankenphp run

XDG_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 run

Framework 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:frankenphp

Use 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.php

Serve 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.key

frankenphp trust — Install FrankenPHP's local root CA into the system and browser trust stores for localhost HTTPS.

frankenphp trust

frankenphp untrust — Remove the local root CA from trust stores.

frankenphp untrust

Set 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 -f

curl -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

  • apache – the established web server FrankenPHP can replace
  • caddy – the web server FrankenPHP is built on
  • certbot – obtain TLS certificates manually (FrankenPHP does this automatically)