Joshua Pangborn

August 21, 2024

Using ServerSideUp PHP Docker Image to Deploy Kirby CMS

Kirby CMS is a fantastic content management system that is simple to use and manage.  I have used it to create a number of websites and have used various deployment strategies. Lately, I have used Kamal to deploy Laravel applications as it simplifies server provisioning and zero-downtime deployment. It was time to upgrade the server for one of my website and I thought I would see I could use the ServerSideUP PHP Docker image as a basis for deploying Kirby with Kamal. 

Having used the ServerSideUP image to deploy a Laravel app, it didn’t take a lot of changes to adapt the Dockerfile for Kirby. Below is the Dockerfile used to deploy Kirby. Below it, I’ll detail the reasons for each section.

FROM serversideup/php:8.3-unit

ENV SSL_MODE=off
ENV PHP_OPCACHE_ENABLE=1
ENV UNIT_WEBROOT=/var/www/html

USER root

# Install the intl extension with root permissions
RUN install-php-extensions gd

# Drop back to our unprivileged user
USER www-data

COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
COPY ./config.d/ /etc/unit/config.d/
COPY --chown=www-data:www-data . /var/www/html

Environment Variables

The ServerSideUP Docker images include a number of environment variables that can control the behavior of the image.

  • The SSL_MODE variable controls whether the app image enables SSL in the web server. When deploying with Kamal, SSL is either handled via Traefik or via an external load balancing, so the web server in the app container does not need SSL.
  • The PHP_OPCACHE_ENABLE variable controls the OpCache. If using the Docker image for development, you will likely want this set to 0, but for production it should be set to 1 for performance reasons.
  • The UNIT_WEBROOT variable is used to change the webroot of the Unit Webserver. The ServerSideUP image is designed for Laravel with uses a /public folder with the index.php front controller. For Kirby, the index.php front controller is not in a /public subfolder, so the webroot needs to be adjusted.

PHP Extensions

Kirby requires the GD Image Processing extension, which is not included by default in the ServerSideUP image. However, ServerSideUP provides a simple method for installing additional PHP extensions when needed. The need to be installed as 'root', so the Dockerfile switches the user to ‘root’, installs the extension, and switches back to ‘www-data’.

Copying Files

Finally, we will need to copy the necessary files, such as startup scripts, configuration files, and the application files. 

First, we will copy the entry point scripts. Any scripts in the /entrypoint.d folder are automatically executed when the image is provisioned. You can read the ServerSideUP documentation for information on the execution order of the scripts. There are several ways to install Kirby; I generally install via Composer. So I include an entrypoint script that runs ‘composer install’. The contents of the ’30-composer-install.sh’ script is:

#!/bin/bash
set -e

echo "Install Composer Dependencies"

cd /var/www/html
composer install

Second, we will copy over the Unit configuration file. Kirby, by default, assumes the Apache web server and provides a .htaccess file to configure the web server. Alternatively, Kirby provides some documentation for configuring nginx. As the Docker image I’ve chosen uses the new Nginx Unit web server, there are some different configuration adjustments needed to restrict access to sensitive Kirby files and folders.

In order to adjust the configuration of Unit, we will need to provide an alternate configuration file template. We will add the following section to the routes configuration that prevents direct access to sensitive files. 

```
{
     "match": {
          "uri": [
	       "/content/*",
	       "/site/*",
	       "/kirby/*",
	       "/entrypoint.d/*",
               "/config.d/*",
	       "/.git/*"
	  ]
     },
     "action": {
         "return": 403
     }
},

The above should be integrated in the ‘ssl-off.json.template’ that is included with the ServerSideUP image. The full contents of file, ‘ssl-off.json.template’, at the time of publishing is:

{
    "listeners": {
        "*:8080": {
            "pass": "routes",
            "forwarded": {
                "client_ip": "CF-Connecting-IP",
                "recursive": false,
                "source": [
                    "173.245.48.0/20",
                    "103.21.244.0/22",
                    "103.22.200.0/22",
                    "103.31.4.0/22",
                    "141.101.64.0/18",
                    "108.162.192.0/18",
                    "190.93.240.0/20",
                    "188.114.96.0/20",
                    "197.234.240.0/22",
                    "198.41.128.0/17",
                    "162.158.0.0/15",
                    "104.16.0.0/13",
                    "104.24.0.0/14",
                    "172.64.0.0/13",
                    "131.0.72.0/22",
                    "2400:cb00::/32",
                    "2606:4700::/32",
                    "2803:f800::/32",
                    "2405:b500::/32",
                    "2405:8100::/32",
                    "2a06:98c0::/29",
                    "2c0f:f248::/32"
                ]
            }
        }
    },
    "routes": [
        {
            "match": {
                "uri": "/healthcheck"
            },
            "action": {
                "return": 200
            }
        },
        {
            "match": {
                "uri": [
                    "/content/*",
                    "/site/*",
                    "/kirby/*",
                    "/entrypoint.d/*",
                    "/config.d/*",
                    "/.git/*"
                ]
            },
            "action": {
                "return": 403
            }
        },
        {
            "match": {
                "uri": "!/index.php"
            },
            "action": {
                "share": "${UNIT_WEBROOT}$uri",
                "fallback": {
                    "pass": "applications/php"
                }
            }
        }
    ],

    "applications": {
        "php": {
            "type": "php",
            "root": "${UNIT_WEBROOT}/",
            "script": "index.php",
            "processes": {
                "max": ${UNIT_PROCESSES_MAX},
                "spare": ${UNIT_PROCESSES_SPARE},
                "idle_timeout": ${UNIT_PROCESSES_IDLE_TIMEOUT}
            }
        }
    },
    "access_log": {
        "if": "`${uri == '/healthcheck' ? false : true}`",
        "path": "/dev/stdout"
    }
}

Finally, we will copy Kirby and your application files to the Docker image.

There are two bonus sections that are useful if you are using Kamal to deploy the above Docker image.

Bonus: Health Check Route

Kamal uses a Health Check to determine if the app container is ready. It is easy to add such a route to Kirby. In your ‘config.php’, you can add the following route:

"routes" => [
    [
        'pattern' => 'up',
	'action' => function() {
	    return new Kirby\Cms\Response('Health Check Successful', 'text/plain', 200);
	}
    ],
],

Bonus: Post Deploy Scripts

If you need to run any post-deployment scripts, Kamal provides a method for that. For Kirby, I generally run two such scripts:
  1. Flush the pages cache so that any deployed changes are reflected on the site.
  2. Update the Algolia index, if using Algolia for search on your site.

To execute a post deployment script, create a file, ‘post-deploy’ in the ‘.kamal’ folder with the following contents:

#!/bin/sh

echo "Process Post Deploy Kirby Scripts"

kamal app exec --reuse 'curl -f http://localhost:8080/algolia/index'
kamal app exec --reuse 'curl -f http://localhost:8080/flush/cache' echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

This script uses Kamal to execute curl requests in the application container. The functionality for each request is defined via Kirby routes. The contents of the routes are:

[
	'pattern' => 'cache/flush',
	'action' => function() {
		if(kirby()->request()->url()->domain() != 'localhost') {
			return new Kirby\Cms\Response('Forbidden', 'text/plain', 403);
		}

		kirby()->cache('pages')->flush();

		return new Kirby\Cms\Response('Pages Cache Flushed', 'text/plain', 200);
	}
],
[
	'pattern' => 'algolia/index',
	'action'  => function () {
		if(kirby()->request()->url()->domain() != 'localhost') {
			return new Kirby\Cms\Response('Forbidden', 'text/plain', 403);
		}

		algolia()->index()->generate();

		return new Kirby\Cms\Response('Algolia Index Updated', 'text/plain', 200);
	}
],

With the above, you’ll have a Docker image configured for deploying Kirby. If you need additional information on setting up Kamal so that this image can be used for server provisioning and deployment, see my article on Using Kamal with Laravel.