Strapi and Cloudflare

Introduction

I'm currently using Strapi as the Content Management System for one of my websites. That site is essentially an aggregator for APIs exposed by my other websites. However, it does also have some local "wordage" requirements.

So I wanted a CMS that could provide the basic text and a few images for the site. But I wanted a CMS that would keep out of the way and not try and "own" the site. I wanted a "headless CMS".

The process that led to choosing Strapi will be documented in another blog post but, so far, I've been very happy with Strapi.

However, as the very first thing displayed on the website (a full page background image) is specified in an API from Strapi (and the image is hosted on Strapi), I wanted to squeeze every ounce of performance I could out of the six Strapi APIs I'm currently using. As the following screen grab shows, I think this is working well.

Strapi APIs - cached performance

The above API calls average roughly 30 milliseconds each. They are cached in the Cloudflare CDN. Performance without Cloudflare (to a VPS in the same country) certainly isn't bad - the screen grab below shows an average response time of about 60 milliseconds

Strapi APIs - Cache misses

Constraints

Before getting into the nitty gritty of how to set up Cloudflare caching of Strapi based APIs, it's worth looking at where this might be useful and, therefore, where it is not appropriate

  • We need to be able to flush the appropriate Cloudflare cache entry every time we update the content in Strapi. If on a free Cloudflare plan, there are generous limits on the the number of Cloudflare API calls that can be made. But if you have a huge churn in content, this may not work for you
  • The "access patterns" for the Strapi APIs must be known and fixed. These access URLs form part of the cache keys in Cloudflare and we need to be able to specify which cache keys we want Cloudflare to purge. We aren't just blindly purging the whole Cloudflare cache on every content update.
    So this isn't going to work that easily if, say, you are offering a flexible GraphQL exposure of your Strapi APIs.
  • There's an "overhead" in keeping the Strapi API configuration in step with the URLs being specified in the client application.
    That's an overhead I'm willing to pay as this is a hobby project and I own the client and the server code. This might be too constraining in a team set up

Implementation

First up, we need to tell Cloudflare to cache the APIs. Cloudflare is quite conservative in what it caches and certainly won't cache API data by default. Cloudflare offers up to three "page rules" per domain on its free tier - here's the page rule I have for caching the output from Strapi APIs.

Cloudflare page rule for caching Strapi APIs

Note - I have Nginx configured to split the Strapi API and the Strapi admin tool URLs - with the API having the unsurprising path of "/api".

Although we are caching in Cloudflare, we don't want this data cached in the client so we set appropriate cache-control response headers.

API response headers

Now that we have the API content stored in Cloudflare for a month, we need a way to remove it when content is updated in the Strapi admin tool.

Strapi has the concept of "lifecyle hooks". For every "content type" you define in Strapi, the Strapi framework allows you to implement functions that will be automatically called at different stages in the lifecycle of a content item (e.g. when first created, when updated etc). The file to edit is:
<project-root>/api/<content-type>/models/<content-type>.js
Here's an example for a "headers" content type.

'use strict';

const files = [{
    url: "https://cms.aidanwhiteley.com/api/headers?website=aidan",
    headers: { Origin: "https://aidanwhiteley.com" }
}];

module.exports = {

    lifecycles: {
        async afterCreate(result, data) {
            strapi.services.cloudflare.cacheClear(files, 'headers');
        },
        async afterUpdate(result, params, data) {
            strapi.services.cloudflare.cacheClear(files, 'headers');
        },
        async afterDelete(result, params) {
            strapi.services.cloudflare.cacheClear(files, 'headers');
        },
    },
};
Content type life cycle hooks
  • The need for afterUpdate and afterDelete hooks should be clear. The afterCreate is needed because we are purging the cache for an API that gets all "headers". Without the afterCreate hook, we wouldn't see our new entry!
  • The Cloudflare cache key also contains the "Origin" request header and that is why we are passing that value through to the code that talks to Cloudflare
  • The "files" parameter is an array of objects. So if you access the API with multiple URLs and/or different query parameters, these can all be passed to Cloudflare in one API call.

Now we need to implement a service that makes the cache purge API call to Cloudflare.

'use strict';

const axios = require('axios');

const cloudflareCacheClearEnabled = strapi.config.get('server.cloudflareCacheClearEnabled', false);
const cloudflareApiToken = strapi.config.get('server.cloudflareApiToken', '');
const cloudflareZone = strapi.config.get('server.cloudflareZone', '');
const cloudflareApiUrlPrefix = strapi.config.get('server.cloudflareApiUrlPrefix', '');

axios.defaults.headers.common['Authorization'] = 'Bearer ' + cloudflareApiToken;
axios.defaults.headers.common['Content-Type'] = 'application/json';

const postCloudflareCacheClear = (files, collection) => {

    if (cloudflareCacheClearEnabled && cloudflareApiToken) {
        axios.post(cloudflareApiUrlPrefix + cloudflareZone + '/purge_cache', {
            "files": files
        }).then((response) => {
            strapi.log.debug('Cache clear for ' + collection + ' was: ' + JSON.stringify(response.data));
        }, (error) => {
            strapi.log.error('Cache clear error for ' + collection + ' was: : ' + JSON.stringify(error));
        });
    } else {
        strapi.log.debug('Cloudflare cache clear not enabled.');
    }
};

module.exports = {
    cacheClear: (files, collection) => {
        postCloudflareCacheClear(files, collection);
    },
};
Cache clear service
  • we access the values of four environment variables. These are:
    - whether the use of Cloudflare cache clearing in Strapi is on or off globally
    - the Cloudflare API token (see below)
    - the Cloudflare zone (see the Cloudflare "home page" for your domain)
    - the API prefix (in my case, Nginx has this configured as/api
  • the authorization type is "Bearer" as we are using a Cloudflare token
  • this service lives at:
    <project-root>/api/cloudflare/services/Cloudflare.js

To access the environment variables the <project-root>/config/server.js is edited as follows:

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('STRAPI_API_URL', 'http://localhost:1337'),
  admin: {
    auth: {
      secret: env('ADMIN_JWT_SECRET', 'not-a-real-secret'),
    },
    url: env('STRAPI_ADMIN_URL', 'http://localhost:1337/admin'),
  },
  cloudflareCacheClearEnabled: env.bool('STRAPI_CLOUDFLARE_CACHECLEAR_ENABLED', false),
  cloudflareApiToken: env('STRAPI_CLOUDFLARE_API_TOKEN', ''),
  cloudflareZone: env('STRAPI_CLOUDFLARE_ZONE', ''),
  cloudflareApiUrlPrefix: env('STRAPI_CLOUDFLARE_API_URL_PREFIX', 
      'https://api.cloudflare.com/client/v4/zones/'),
});
Accessing environment variables

And finally we need the Cloudflare token. The scope of the token is set such that it is only allowed to be used for cache purge work.

Cloudflare access token

And that's it. Hopefully this is useful to someone. If it doesn't work for you, it is probably worth mentioning that the last time I used any server side JavaScript was around about 2001 with a product called Broadvision!