Deploy Kirby Sites on Fly.io

Wednesday, August 16, 2023
Tags: kirby hosting

This assumes you’re starting with an existing Kirby project and you don’t keep your content/ folder in version control.

Create a new “Laravel” app:

touch artisan
fly launch
rm artisan

Set up persistent storage

Since Fly.io is a fancy platform-as-a-service and not an old school host where you just throw files and they stay there no matter what, you’ll need to take special care to make a place for Kirby’s content/ (where all your content is) and storage/ (where your user account data is) that lives outside of what code you deploy.

Create a folder called persistent in the root of your project. Move both your content and storage folders into it.

Tell Kirby where to find where to find those folders. Adjust the values for content and storage in public/index.php like so:

<?php

include __DIR__ . '/../kirby/bootstrap.php';

$kirby = new Kirby([
    'roots' => [
        'index'    => __DIR__,
        'base'     => $base    = dirname(__DIR__),
        'content'  => $base . '/persistent/content', # CHANGED
        'site'     => $base . '/site',
        'storage'  => $storage = $base . '/persistent/storage', # CHANGED
        'accounts' => $storage . '/accounts',
        'cache'    => $storage . '/cache',
        'sessions' => $storage . '/sessions',
    ]
]);

echo $kirby->render();

Create the volume. Make sure to use the same region as you selected for your app.

fly volumes create persistent --size 1

Mount the volume in your fly.toml (at the end of the file):

# More stuff…

[[mounts]]
  source = "persistent"
  destination = "/var/www/html/persistent"
  processes = ["app"]

Adjust the “Laravel” things Fly auto-generated

Adjust things as needed in .gitignore and .dockerignore.

Be sure to add persistent/ as well as other stuff that Kirby already has in their default .gitignore such as:

persistent/
/public/media
!/public/media/index.html
/site/cache/*
!/site/cache/index.html
/site/accounts/*
!/site/accounts/index.html
/site/sessions/*
!/site/sessions/index.html
/site/config/.license
.lock
/kirby
/vendor

Adjust entrypoint script

Add these lines to the top of .fly/entrypoint.sh to make it Kirby-friendly:

#!/usr/bin/env sh

# Disable Swoole to prevent `Cannot redeclare go()` error
phpdismod swoole

# More stuff…

Basically gut the Dockerfile

Remove this stuff:

  1. Things referring to artisan or laravel and other Laravel-related things
  2. Vite-related things
    • I guess unless you’re using Vite already for some reason?
  3. All the node_modules_go_brrr stuff.
    • For my uses, I replaced it all with npm run build

Here’s my entire Dockerfile. If you figure out how to improve this (hopefully simplifying it even more), please let me know!

# syntax = docker/dockerfile:experimental

# Default to PHP 8.2, but we attempt to match
# the PHP version from the user (wherever `flyctl launch` is run)
# Valid version values are PHP 7.4+
ARG PHP_VERSION=8.2
ARG NODE_VERSION=18
FROM fideloper/fly-laravel:${PHP_VERSION} as base

# PHP_VERSION needs to be repeated here
# See https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact
ARG PHP_VERSION

LABEL fly_launch_runtime="laravel"

# copy application code, skipping files based on .dockerignore
COPY . /var/www/html

RUN composer install --no-interaction --prefer-dist --optimize-autoloader \
    && mkdir -p storage/logs \
    && chown -R www-data:www-data /var/www/html \
    && cp .fly/entrypoint.sh /entrypoint \
    && chmod +x /entrypoint

# Multi-stage build: Build static assets
# This allows us to not include Node within the final container
FROM node:${NODE_VERSION}

RUN mkdir /app

RUN mkdir -p  /app
WORKDIR /app
COPY . .

RUN npm i && npm run build;

# From our base container created above, we
# create our final image, adding in static
# assets that we generated above
FROM base

EXPOSE 8080

ENTRYPOINT ["/entrypoint"]

Adjust PHP version if needed

I had to adjust composer.json (changing the required PHP version from <8.2.0 to <8.3.0 because Fly had 8.2.9 by default) then run composer update .

composer.json:

# More stuff…
"require": {
  "php": ">=7.4.0 <8.3.0",
  # More stuff…

Manually adjust the Kirby base URL.

For some reason, the default $_SERVER['SERVER_NAME'] doesn’t seem to want to play nice on Fly. Just set it manually. It’s cool to add an array to list multiple URLs here (for local dev + remote, for example) and Kirby should sort it out automatically.

Add your URL to site/config/config.php:

<?php

return [
  'url' => 'https://whatever.fly.dev',
  # More stuff…

“Install” the Panel

fly ssh console
# Edit site/config/config.php somehow
vim site/config/config.php

Add 'install' => true to 'panel':

# More stuff…
'panel' => [
    'install' => true,
],
# More stuff…

Go to the Panel URL and create a user. Once that’s done, remove what you added to site/config/config.php.

Add your .license file to persistent storage

fly ssh console
cp site/config/.license persistent
chown www-data:www-data persistent/.license

Add the following stuff to your entrypoint.sh right above chown -R www-data:www-data /var/www/html

# More stuff…

if [ -f /var/www/html/persistent/.license ]; then
    cp /var/www/html/persistent/.license /var/www/html/site/config
fi

# More stuff…

Optional: redirect www and your default Fly URL to your base domain name

Copy the standard Nginx config from Fly’s Laravel Docker image and put it in the .fly/ folder (via). Add something like this at the top of the file:

.fly/default:

server {
    listen 8080;
    listen [::]:8080;
    server_name www.example.com;
    rewrite ^(.*) https://example.com$1 permanent;
}

server {
    listen 8080;
    listen [::]:8080;
    server_name example.fly.dev;
    rewrite ^(.*) https://example.com$1 permanent;
}

# All the existing stuff:

server {
    # More stuff…

In your Dockerfile, add a line to copy that new file (right after && chmod +x /entrypoint \)

&& chmod +x /entrypoint \ # Add trailing backslash
&& cp .fly/default /etc/nginx/sites-available/default # New

All done!

At this point, you should be good to go! Keep reading to learn how to automatically back up your content/ folder to S3-compatible storage every day.

Be sure to deploy using the command fly deploy --ha=false so it doesn’t try to create multiple machines.


BONUS: Automated backups for content/ to Backblaze B2 or other S3-compatible things

(Hopefully not actually S3. Amazon sucks.)

Add more to your Dockerfile

Put this stuff right above the COPY . /var/www/html

# Install cron
RUN apt-get install -y cron

# Install AWS CLI
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install

Fly Secrets

Set Fly secrets for these items with the appropriate values from your S3-compatible storage service of choice.

AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key

I like to keep a .gitignore‘d fly.env file in the root of my project. Then you can just run the this command to update things and keep track of the current values:

fly secrets import < fly.env

entrypoint.sh

Put this stuff in your entrypoint.sh right below the phpdismod swoole line:

# Load cron configuration.
crontab /var/www/html/crontab
echo "Cron has been configured." >> /var/log/cron.log

# Start cron as a daemon.
cron
echo "Cron has been started." >> /var/log/cron.log

# For the sake of cron having access to AWS credentials
printf "AWS_ACCESS_KEY_ID=%s\n" $AWS_ACCESS_KEY_ID >> /etc/environment
printf "AWS_SECRET_ACCESS_KEY=%s\n" $AWS_SECRET_ACCESS_KEY >> /etc/environment

This starts up cron and copies over your Fly secrets in a place where cron can read them.

Create a backup script

Create a scripts/backup.sh file:

#!/bin/bash

# Send content/ folder to B2
/usr/local/bin/aws s3 cp \
    /var/www/html/persistent/content/ s3://your-bucket/your-project/$(date +"%F")/ \
    --recursive \
    --endpoint=https://your-endpoint

Make sure the file is executable:

chmod +x scripts/backup.sh

This will create a new folder named the current date (yyyy-mm-dd) in the bucket and folder of your choice filled with what was in the content/ folder that day. You should probably set up some lifecycle rules to only keep a certain number of days. A week’s worth or so is good for me.

Create a crontab script

Create a crontab file in the root of your project that looks something like this.

# For debugging if needed. Runs every minute.
# */1 * * * * /var/www/html/scripts/backup.sh >> /var/log/cron.log 2>&1

# 5am Eastern US time every day
0 9 * * * /var/www/html/scripts/backup.sh

BONUS BONUS: Monitor your backups with Healthchecks.io

Once you sign up and configure a “check,” you’ll just need to add a bit of text at the end of that last line in your crontab. It’ll look something like

0 9 * * * /var/www/html/scripts/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://hc-ping.com/something