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

Firefox custom CA trust screenshot

Chrome shows it just like any other:-

Chrome custom CA trust screenshot

And they both show the hierarchy with the localhost cert being signed by our custom CA:-

Custom CA trust screenshot


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.