Introduction
The traditional and industry standard way to serve PHP applications is to use PHP FPM (FastCGI Process Manager).
FPM creates multiple handler processes for PHP and accepts data from a web server using the FastCGI protocol.
This has a few drawbacks:
- Decoupling between the web server and running the code but they both need access to the application code;
- Need to maintain two different processes with their own separate logging and lifecycle to handle the application.
These are especially tricky in the context of Docker containers which are supposed to have one main foreground process logging to standard output and error and that's it.
Here we have two processes at the minimum and they need to access the same files and communicate with each other.
We often have used two containers for PHP but that does make the general infrastructure more complex.
The FrankenPHP project extends the Caddy web server (a server written in Go that we spent some time benchmarking in the earlier days) with PHP handling still under the safe and easy memory management and concurrent programming framework of Golang.
The FrankenPHP website makes it easy to deploy using their Docker images and it's the recommended way to use FrankenPHP.
This is however not what we want to do today so please here us out ?
The PHP Embed SAPI
The "embed" PHP SAPI differs from the usual PHP SAPIs (the Apache module and FastCGI) as it was meant to be used with C bindings directly. No socket connection is required.
The project wasn't made for FrankenPHP but it did spawn multiple projects offering a more integrated PHP experience.
The embed SAPI can still dynamically link libraries and extensions from the system it's running on. However, for the current article, we'll strive to make a fully statically linked and portable binary which includes both the web server and the PHP interpreter as described in the following drawing.
The use case
We have nothing against containers but sometimes you don't want or need them.
The modern process runner for Linux (systemd) is actually capable of automatically restarting crashed programs, set environment variables from the config and/or a .env file, process logging from standard output and even set resource constraints.
So, in short, it can pretty much do anything Docker can except provide the network isolation from the get go (and the filesystem layering and isolation as well but we'd argue that is something you might want to avoid sometimes).
In certain environments it's just quicker to copy a template and start a very simple LXC container or Virtual Machine and run one or two processes on it and call it a day.
There are no surprises, it's easy to migrate, copy/clone and reason about. It works on any and all hosting providers and even any semi-modern distribution.
With FrankenPHP it becomes possible to embed the entire PHP runtime in the compiled binary, on top of it already having the Caddy web server embedded in.
This is a lot of heavy lifting done by a single portable binary, we just need to add:
- The web server config (Caddyfile);
- A php.ini file (not strictly mandatory but way better for production use).
And that's it. Caddy is even able to request SSL certificates automatically from Letsencrypt so we don't have to install certbot and request certificates manually.
The general idea is then to push these few files using something like Ansible and add the PHP application files on top afterwards and that's it you have a PHP app server ready to run standalone.
The Kamal Deploy project has a similar idea except they use Docker (Kamal installs Docker if it's absent on the target systems).
Creating the custom build of FrankenPHP
Building the binary should be done on a dev machine or a dedicated disposable build system.
For this part we do need Docker (or equivalent) and "Buildx" which is often not installed by default.
For instance on Ubuntu an extra package is needed:
apt install docker-buildx
Clone or copy the FrankenPHP repository
We first need a local copy of the FrankenPHP repository, either from cloning it or downloading an archive from the release page.
We would recommend the second option as it's faster and matches an actual versioned release of the project.
Compile the static build
If you just want the latest version of FrankenPHP with the latest version of PHP and a default set of extensions that work with most applications you can just run:
docker buildx bake --set '*.platform=linux/amd64' --load static-builder-gnu
The bake will take a while and download a lot of libraries. When the process works without errors it produces a local Docker image called "dunglas/frankenphp:static-builder-gnu".
The image is just useful to retrieve the compiled frankenphp binary from it.
The following set of commands will retrieve the binary using docker cp from a container created from the image and then remove said container.
docker cp $(docker create --name static-builder-gnu dunglas/frankenphp:static-builder-gnu):/go/src/app/dist/frankenphp-linux-$(uname -m) frankenphp ; docker rm static-builder-gnu
Keep in mind this doesn't remove the image and it's pretty big (around 5GB). You might want to remove it at some point.
Custom PHP extensions
The binary has the interpreter and its extensions built in.
However we sometimes need to install some extras. For instance, "gd" (image editing library) is not installed by default.
Fortunately there's an environement variable allowing us to install a certain set of extensions. The only downside is we have to provide all of the extensions we want exhaustively.
You'll find a nice list of all the supported extensions on the static-php project website.
Another environment variable allows picking a specific PHP version but remember FrankenPHP requires version 8.2+ so we don't have a lot to chose from.
The following command showcases both options to create a truly custom build:
docker buildx bake --set static-builder-gnu.args.PHP_EXTENSIONS="gd,curl,dom,iconv,intl,libxml,mbstring,mcrypt,mysqlnd,opcache,openssl,pdo_mysql,readline,redis,sodium,sqlite3,tidy,tokenizer,zip,zlib" --set static-builder-gnu.args.PHP_VERSION="8.2" --set '*.platform=linux/amd64' --progress=plain --load static-builder-gnu
We can then copy the frankenphp binary as we did above.
Deploying
We just need to copy the binary on to the server.
I like to put it in /opt/frankenphp with the Caddyfile, php.ini in /etc/frankenphp and our application PHP files in /var/www with the entrypoint in /var/www/public — Let's create these directories:
mkdir -p /opt/frankenphp/
mkdir -p /etc/frankenphp/
mkdir -p /var/www/public
The php.ini file can be a small set of things you really want to set, or an adjusted copy of the official php.ini-production file (don't forget it has to be named php.ini on the server).
The Caddyfile is out of scope for this article. Let's start with a basic one including a public domain name for our PHP app — The domain has to exist and point to our server.
Caddy will automatically request an SSL certificate and redirect HTTP to HTTPS for us.
# The Caddyfile is an easy way to configure FrankenPHP and the Caddy web server.
#
# https://frankenphp.dev/docs/config
# https://caddyserver.com/docs/caddyfile
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
#worker /path/to/your/worker.php
{$FRANKENPHP_CONFIG}
}
}
{$CADDY_EXTRA_CONFIG}
<YOUR_DOMAIN_NAME>, localhost:80 {
log {
format filter {
request>uri query {
replace authorization REDACTED
}
}
}
root /var/www/public
encode zstd br gzip
{$CADDY_SERVER_EXTRA_DIRECTIVES}
php_server
}
Where <YOUR_DOMAIN_NAME> should be replaced with your actual canonical domain name for the application.
The systemd script and frankenphp user
Let's create a specific user to run the frankenphp process.
The home directory should be writable because the dynamic config will be stored in there (Caddy allows modifying the config from a live API) — A good choice is /var/lib/frankenphp.
groupadd --system frankenphp
useradd --system --gid frankenphp --create-home --home-dir /var/lib/frankenphp --shell /usr/sbin/nologin frankenphp
Let's also allow it write access to /etc/frankenphp/:
chown -R frankenphp:frankenphp /etc/frankenphp
And here's a systemd script to put in /etc/systemd/system/frankenphp.service (this is pretty much the official one they bundle with their new Debian packages):
[Unit]
Description=FrankenPHP
Documentation=https://frankenphp.dev/docs/
After=network.target network-online.target
Requires=network-online.target
[Service]
Type=notify
User=frankenphp
Group=frankenphp
ExecStart=/opt/frankenphp run --environ --config /etc/frankenphp/Caddyfile
ExecReload=/opt/frankenphp reload --config /etc/frankenphp/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
LimitNPROC=512
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
We're ready to serve PHP files, start the server and make it start with the server:
systemctl enable --now frankenphp.service
Check that everything is going as planned in the logs:
journalctl -u frankenphp.service
It's of course possible to configure multiple websites with Caddy and FrankenPHP as well as add redirections, rules for static files and more.
After a few seconds we can already fetch my home page (which is just phpinfo(); and HTTP/3 works on top of the automatic HTTPS redirection:
Conclusion
It's never been that easy to ship a complete PHP environment on a fresh server even though FrankenPHP is more meant for containerized environments.
An interesting competitor will be Nginx Unit which is probably going to have a more standard interface with package managers.
However, Nginx Unit is not a fully featured web server like Caddy (for instance HTTP/2 is currently not supported) but in many cases we may be behind a reverse proxy anyway.
It's important to keep in mind that the static build doesn't allow adding extensions on the fly and will only support the PHP version it was built with.
However we could technically run multiple instances of FrankenPHP but duplicating Caddy doesn't make sense unless it's behind a reverse proxy and at that point it might be better to just work with Docker or an equivalent.
We haven't even touched the Worker mode of FrankenPHP which is a way to keep part of the PHP application in memory to be quickly reused for many requests — This requires tight coupling with the application but offers very impressive throughputs as it sort of bypasses the main weakness of PHP (rebuilding almost everything for every request).
There is one of these workers for Symfony you might want to try. The official Symfony Docker image uses FrankenPHP and the worker mode.