Setting up FrankenPHP with trusted certs for local development
Monday, 24th February 2025
For a few small personal projects I'm working on at the moment, I've been using the standalone binary version of FrankenPHP. I like the way I can just dip in and out of the projects and run a single command to have a working development version of the app without needing Docker containers or the complexities of setting up PHP-FPM and nginx - or something similar.
I can simply run: frankenphp php-server -r . -l 0.0.0.0:7070
and then start working on my app and testing in the browser at http://localhost:7070.
However, there is a flaw this approach. The server running over HTTP only and browsers are not going to offer the same behaviour as they would with a secure HTTPS connection. For example, using the "Secure" flag on cookies will not work, nor will you be able to access certain browser API's like geolocation.
Generating a self-signed certificate is easy enough, but it means navigating the "Connection is not secure" warning periodically. Even then, it leaves the "Not Secure" marker in the address bar, it's not the same as a true trusted connection. So I decided to setup my own certificate authority (CA) root cert and add that to my machine's trust store - I can then use that for issuing certificates and browsers treat it exactly the same as any other globally trusted certificate chain - such as Let's Encrypt.
To start things off, I created some space to store the openssl
config files and generated keys and certificates (As this is just for local development I am happy with the keys just being on my local system). I created a "top level" directory at ~/Documents/pki
, then sub-directories for ~/Documents/pki/certs
and ~/Documents/pki/keys
.
(Note: from now on I'm working in the ~/Documents/pki
directory, so file paths in the examples will be relative)
Creating CA keys and certificate
The first config file I needed is ca.cnf
which contains the settings for setting up the CA. It looks like this.
# OpenSSL CA configuration file
[ ca ]
default_ca = CA_default
[ CA_default ]
default_days = 365
database = index.txt
serial = serial.txt
default_md = sha256
copy_extensions = copy
unique_subject = no
[ req ]
prompt=no
distinguished_name = distinguished_name
x509_extensions = extensions
[ distinguished_name ]
organizationName = PMC
commonName = PMC CA
[ extensions ]
keyUsage = critical,digitalSignature,nonRepudiation,keyEncipherment,keyCertSign
basicConstraints = critical,CA:true,pathlen:1
[ signing_policy ]
organizationName = supplied
commonName = optional
[ signing_node_req ]
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth,clientAuth
Next I need a CA signing key. This is created using openssl
with the following commands. Changing the permissions is important, otherwise openssl
will refuse to use the key in later steps.
$> openssl genrsa -out keys/ca.key 2048
$> chmod 400 keys/ca.key
Now I create the CA certificate using the previously created config file and key. I've made it valid for 10 years:-
$> openssl req \
-new \
-x509 \
-config ca.cnf \
-key keys/ca.key \
-out certs/ca.crt \
-days 3650 \
-batch
Finally, for this stage, I add this new certificate to the systems trusted list of CA certs using trust
.
$> sudo trust anchor certs/ca.crt
My system will now trust any certificates signed by this one.
Creating the node/server certificate
Now I need to create the actual server certificate that I'll use with FrankenPHP. This requires two differences from the generation of a normal "slf-signed" cert. Firstly, I need to ensure it has a Subject Alternate Name (SAN) of localhost and secondly it will be signed with the CA cert created earlier.
First I created localhost.cnf
:-
# OpenSSL node configuration file
[ req ]
prompt=no
distinguished_name = distinguished_name
req_extensions = extensions
[ distinguished_name ]
organizationName = PMC
[ extensions ]
subjectAltName = DNS:localhost
Then, in the same way as I created the CA key above, I create localhost.key
:-
$> openssl genrsa -out keys/localhost.key 2048
$> chmod 400 keys/localhost.key
Then I bering these together to create a Certificate Signing Request (CSR):-
openssl req \
-new \
-config localhost.cnf \
-key keys/localhost.key \
-out localhost.csr \
-batch
One final step before issuing certificates with the "CA", is to initialise index.txt
and serial.txt
which are required when using the openssl ca
command. The index is an empty file and serial starts at "01":-
$> touch index.txt
$> echo "01" >serial.txt
Finally, I use the CSR to issue the actual certificate which will be signed by the CA:-
openssl ca \
-config ca.cnf \
-keyfile keys/ca.key \
-cert certs/ca.crt \
-policy signing_policy \
-extensions signing_node_req \
-out certs/localhost.crt \
-outdir certs/ \
-in localhost.csr \
-batch
Configuring FrankenPHP for SSL
For a quick test, I create a new temporary "Project" directory and add index.php
:-
<?php
phpinfo();
Then I symlink my localhost certificate and key, just to make syntax simpler later. So I have the following:-
total 4
-rw-rw-r--. 1 paul paul 18 Feb 23 16:12 index.php
lrwxrwxrwx. 1 paul paul 27 Feb 24 07:36 localhost.crt -> ../temp/certs/localhost.crt
lrwxrwxrwx. 1 paul paul 26 Feb 24 07:36 localhost.key -> ../temp/keys/localhost.key
Finally, FrankenPHP is built intop of Caddy, and can be configured in more detail with a Caddyfile
in the directory you're running it from. So I add one at the root of my test project:-
{
http_port 7070
https_port 7071
frankenphp
}
localhost {
tls ./localhost.crt ./localhost.key
root * .
php_server
}
Now I can launch my browser and head over to https://localhost:7071 and I get my test page. No more security warnings. 🎉
Firefox highlights the fact that the CA has been added to the system and is not one inherently trusted by Mozilla:-
Chrome shows it just like any other:-
And they both show the hierarchy with the localhost cert being signed by our custom CA:-
Now that I have my own CA of sorts, I can expand this out and easily generate certs for other things on my home network which pop up security warnings now and then - like my firewall, pihole and grafana servers which are all internal only have have no routable DNS.