Introduction

High Performance with Caddy and FrankenPHP


title: High Performance with Caddy and FrankenPHP description: Learn how Kipchak leverages FrankenPHP for high performance.


Kipchak is built with performance in mind. One of its key features is its native support for FrankenPHP in worker mode, running on the Caddy web server.

What is Caddy?

Caddy is a powerful, enterprise-ready web server with automatic HTTPS. It's written in Go and designed to be simple to configure and use. Caddy serves as the foundation for FrankenPHP, providing:

  • Automatic HTTPS: Built-in SSL/TLS certificate management with Let's Encrypt
  • HTTP/2 and HTTP/3: Modern protocol support out of the box
  • Reverse Proxy: Advanced load balancing and proxy capabilities
  • Static File Serving: Efficient static asset delivery with automatic compression
  • Extensibility: Plugin architecture for custom functionality

NOTE: In the docker container the HTTPS features of Caddy are disabled by default.

If you run on Kubernetes, it's likely that you will be terminating TLS at the ingress controller.

What is FrankenPHP?

FrankenPHP is a modern PHP application server built on top of Caddy. It embeds the PHP interpreter directly into the web server, enabling:

  • Worker Mode: Keep your PHP application in memory across requests
  • Early Hints: HTTP 103 support for faster page loads
  • Real-time: Built-in support for Server-Sent Events (SSE) and Mercure
  • Production Ready: Battle-tested in high-traffic applications

FrankenPHP Worker Mode

FrankenPHP's worker mode is a game-changer for PHP performance. Unlike traditional PHP-FPM, where each request boots up the entire application framework, worker mode boots your application once and keeps it in memory, handling multiple requests without restarting.

How Worker Mode Works

  1. Boot Once: Your application (including Kipchak, dependencies, and configuration) loads once on startup
  2. Handle Requests: Each incoming HTTP request is passed to the in-memory application
  3. Reset State: After each request, the worker resets to a clean state
  4. Reuse Resources: Database connections, compiled templates, and other resources persist between requests

Benefits of Worker Mode

  • 10-20x Faster: No boot-up time for each request dramatically reduces latency
  • Shared Resources: Database connections, caches, and other resources can be shared across requests
  • High Throughput: Handle thousands of requests per second on modest hardware
  • Lower Memory: Fewer processes needed compared to traditional FPM
  • Reduced CPU: No repeated framework initialization on every request

Debugging in Worker Mode

Worker mode does not allow you to echo or print to screen. This output goes to stdout and is not visible in the browser. You will also not be able to use the exist command after a var_dump() as that kills the worker process.

If you would like to use this way of working (rather than using xdebug, for instance), you can either us the dump() function packaged with Kipchak or run FrankenPHP in classic mode like FPM/Apache.

The dump function works in contollers and needs to be prefaced with a return statement.

You can use it like this in a Controller:

use function Kipchak\Core\dump;

return dump('$var');

Configuring FrankenPHP and Caddy

Kipchak comes pre-configured to work with FrankenPHP. The Caddyfile included in the starter project is set up to run the application in worker mode:

# The Caddyfile is an easy way to configure FrankenPHP and the Caddy web server.
#
# https://frankenphp.dev/docs/config
# https://caddyserver.com/docs/caddyfile

{
	skip_install_trust

	{$CADDY_GLOBAL_OPTIONS}

    metrics

    admin :2019

	frankenphp {
		{$FRANKENPHP_CONFIG}
        num_threads 2 # Sets the number of PHP threads to start. Default: 2x the number of available CPUs.
		max_threads 4 # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'.
		max_wait_time 60s # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
		# php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
        worker {
			file ./index.php # Sets the path to the worker script.
	    	num 1 # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs.
			# env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
			# watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
			name kipchak # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file
			max_consecutive_failures 7 # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6.
		}
	}
}

{$CADDY_EXTRA_CONFIG}

:80 {
#{$SERVER_NAME:localhost} {
	#log {
	#	# Redact the authorization query parameter that can be set by Mercure
	#	format filter {
	#		request>uri query {
	#			replace authorization REDACTED
	#		}
	#	}
	#}

	root {$SERVER_ROOT}
	encode

	# Uncomment the following lines to enable Mercure and Vulcain modules
	#mercure {
	#	# Transport to use (default to Bolt)
	#	transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
	#	# Publisher JWT key
	#	publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
	#	# Subscriber JWT key
	#	subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
	#	# Allow anonymous subscribers (double-check that it's what you want)
	#	anonymous
	#	# Enable the subscription API (double-check that it's what you want)
	#	subscriptions
	#	# Extra directives
	#	{$MERCURE_EXTRA_DIRECTIVES}
	#}
	#vulcain

	{$CADDY_SERVER_EXTRA_DIRECTIVES}

	php_server {

	}
}

# As an alternative to editing the above site block, you can add your own site
# block files in the Caddyfile.d directory, and they will be included as long
# as they use the .caddyfile extension.

import Caddyfile.d/*.caddyfile

Understanding FrankenPHP Configuration Options

Let's break down the key FrankenPHP configuration directives and when to adjust them:

Global FrankenPHP Settings

num_threads

num_threads 2

Purpose: Sets the number of PHP threads that FrankenPHP starts initially.

Default: 2x the number of available CPU cores

When to adjust:

  • Increase for CPU-bound applications that can utilize more threads
  • Decrease to reduce memory consumption on resource-constrained systems
  • Rule of thumb: Start with 1-2x your CPU cores and adjust based on monitoring

max_threads

max_threads 4

Purpose: Limits the maximum number of additional PHP threads that can be started at runtime.

Default: Same as num_threads, or set to auto for dynamic scaling

When to adjust:

  • Set higher than num_threads to allow dynamic scaling under load
  • Set to auto to let FrankenPHP manage thread scaling automatically
  • Important: More threads = more memory usage. Monitor your system's memory capacity

max_wait_time

max_wait_time 60s

Purpose: Maximum time a request waits for a free PHP thread before timing out.

Default: Disabled (requests wait indefinitely)

When to adjust:

  • Enable in production to prevent requests from hanging forever
  • Set to 30s-60s for typical web applications
  • Set lower (10s-20s) for API endpoints that should fail fast
  • Set higher (120s+) for applications with long-running operations

Why it matters: Without this, during traffic spikes, requests can queue indefinitely. With it set, users get a clear timeout error instead of an indefinite hang.

Worker-Specific Settings

worker.file

file ./index.php

Purpose: Path to the worker script that boots your application.

Default: None (required)

Note: This should point to your application's entry point. For Kipchak, this is index.php.

worker.num

num 1

Purpose: Number of worker processes to start.

Default: 2x the number of available CPU cores

When to adjust:

  • Increase for high-traffic applications (2-4 workers per CPU core)
  • Decrease for applications with high memory usage per worker
  • Each worker maintains its own PHP application instance in memory
  • Balance: More workers = better concurrency but higher memory usage

Example scenarios:

  • Low traffic, 4 CPU cores: num 2 (conserve memory)
  • High traffic, 4 CPU cores: num 8-16 (maximize throughput)
  • Memory-intensive app: num 2-4 (prevent memory exhaustion)

worker.name

name kipchak

Purpose: Identifies the worker in logs and metrics.

Default: Absolute path of the worker file

When to adjust: Use descriptive names when running multiple workers or applications.

worker.max_consecutive_failures

max_consecutive_failures 7

Purpose: Maximum number of consecutive crashes before the worker is marked unhealthy.

Default: 6

When to adjust:

  • Increase (10-20) during development when debugging crashes
  • Decrease (3-5) in production to fail fast on persistent issues
  • Set to -1 to always restart the worker (not recommended for production)
  • Set to 0 to never restart after any failure

Why it matters: This prevents a buggy deployment from repeatedly crashing and restarting. After hitting the limit, FrankenPHP stops trying to restart that worker, allowing you to investigate without consuming resources on repeated crashes.

Example scenarios:

  • Development: max_consecutive_failures 20 (be forgiving during development)
  • Production (stable): max_consecutive_failures 5 (fail fast if something's wrong)
  • Production (experimental): max_consecutive_failures 10 (allow some recovery attempts)

worker.watch

watch /path/to/src
watch /path/to/config

Purpose: Automatically restarts workers when files in the specified paths change.

Default: None (disabled)

When to use:

  • Development only: Enables hot-reloading during development
  • Never in production: Adds overhead and can cause issues
  • Specify multiple paths to watch different directories

worker.env

env APP_ENV production
env DEBUG false

Purpose: Sets environment variables specifically for the worker process.

Default: Inherits from the system environment

When to use: Override specific environment variables for the worker without affecting the entire system.

Caddy Server Configuration

Admin Interface

admin :2019

Enables Caddy's admin API on port 2019 for runtime configuration changes and metrics.

Metrics

metrics

Enables Prometheus-compatible metrics at /metrics endpoint on the admin interface.

Encoding

encode

Enables automatic compression (gzip, zstd, brotli) for responses if the request contains an Accept-Encoding header.

Performance Tuning Tips

For High Traffic

frankenphp {
    num_threads 4
    max_threads 8
    max_wait_time 30s

    worker {
        file ./index.php
        num 8
        max_consecutive_failures 5
    }
}

For Memory-Constrained Systems

frankenphp {
    num_threads 2
    max_threads 2
    max_wait_time 45s

    worker {
        file ./index.php
        num 2
        max_consecutive_failures 7
    }
}

For Development

frankenphp {
    num_threads 1
    max_threads 2

    worker {
        file ./index.php
        num 1
        watch ./src
        watch ./config
        max_consecutive_failures 20
    }
}

Monitoring Worker Health

FrankenPHP exposes metrics through Caddy's admin interface. Access metrics at:

http://localhost:2019/metrics

Key metrics to monitor:

  • frankenphp_workers_total: Total number of workers
  • frankenphp_workers_failed_total: Number of failed workers
  • frankenphp_requests_duration_seconds: Request duration histogram
  • frankenphp_requests_total: Total requests handled

Running FrankenPHP / Caddy in Classic Mode

The starter project comes with what you need to run FrankenPHP and Caddy in classic mode.

  • It contains index.nonworker.php in the /var/www/html directory, which is a non-worker mode entry point for FrankenPHP.
  • It contains Caddyfile.nonworker in the /etc/caddy directory that runs Caddy in classic mode.

To enable classic mode, rename the existing index.php file to index.worker.php and the index.nonworker.php file to index.php in /var/www/html. Then, change your Dockerfile to add the right Caddyfile into the container. The changed file will look like this:

FROM 1x.ax/mamluk/php/8.4:franken-dev

# Copy files
COPY . /var/www/

#######
####### COMMENT out this Caddyfile
#######
# Leave the below line as it is to run FrankenPHP in Worker Mode
# COPY etc/caddy/Caddyfile /etc/frankenphp/

#######
####### UNCOMMENT this Caddyfile
#######
# If you do not want FrankenPHP to run in worker mode, uncomment the line below and comment line 7.
COPY etc/caddy/Caddyfile.nonworker /etc/frankenphp/Caddyfile

# Run Composer
RUN export COMPOSER_ALLOW_SUPERUSER=1 && cd /var/www && composer install --no-dev

# Delete stuff we do not need
RUN rm -rf /var/www/.git
RUN rm -rf /var/www/.gitignore

Now when you run docker-compose up --build --watch, FrankenPHP and Caddy will run in classic mode.

Previous
Starter project