Strapi v4 on Google App Engine

I have [finally] got Strapi v4 to work on Google AppEngine, running alongside a Svelte-based web app that consumes Strapi content via server-side ‘actions’ and data load functions. I have deployed with a CloudSQL PostgreSQL database. But there were a few ‘hoops’ to jump through.

1. Database access

Creating the database is easy. [Do create a new database; don’t use the ‘postgres’ database for application data.] Creating a new CloudSQL ‘user’ is also advisable, as you can then set a strong password.

You need to give the GAE service account permission to access the CloudSQL API, and then you need to use the IAM dashboard to give it at least ‘Cloud SQL Client’ role. [‘Cloud SQL Admin’ also works, but is more power than you need to grant.]

You will get a long string which is the database instance id, comprised of three parts - {gcp-account}:{gcp-region}:{database-name}. You need to turn this into a GCP socket name by prepending it with ‘/cloudsql/’ - e.g. /cloudsql/beaky-setup-123456:europe-west2:strapi-database. You then pass this as the ‘DATABASE_HOST’ environment variable, in your service.yaml file [see Deployment, below].

2. Service Specification

Running as a service inside GAE is similar to running behind a reverse proxy, but you have much less control. [In fact, it does use Nginx.] If your configuration is like mine, reserve the ‘default’ service for your main website, which you must deploy (with a standard ‘app.yaml’) before you try to deploy the Strapi service.

Once you have the default service deployed, you need an ‘app.yaml’ file for Strapi. You can call it anything you like, except one of the reserved names. I called mine ‘strapi-service.yaml’. [Super inventive, eh?] Here’s what mine looks like (with some data redacted):

service: strapi

runtime: nodejs18
instance_class: F2

env_variables:
  HOST: '0.0.0.0'
  PUBLIC_URL: 'https://xxxxx.nw.r.appspot.com/strapi'
  NODE_ENV: 'production'
  DATABASE_CLIENT: 'postgres'
  DATABASE_HOST: '/cloudsql/xxxxx:europe-west2:strapi-database'
  DATABASE_NAME: 'bloom_demo'
  DATABASE_USERNAME: 'strapi'
  DATABASE_PASSWORD: 'xxxxx'
  APP_KEYS: "xxxxx"
  API_TOKEN_SALT: "xxxxx"
  ADMIN_JWT_SECRET: "xxxxx"
  TRANSFER_TOKEN_SALT: "xxxxx"
  JWT_SECRET: "xxxxx"

build_env_variables:
  NODE_ENV: 'production'
  PUBLIC_URL: 'https://xxxxx.nw.r.appspot.com/strapi'
  APP_KEYS: "xxxxx"
  API_TOKEN_SALT: "xxxxx"
  ADMIN_JWT_SECRET: "xxxxx"
  TRANSFER_TOKEN_SALT: "xxxxx"
  JWT_SECRET: "xxxxx"

Some of those build_env_variables settings may be redundant, but the build step appears to be done by the GCP ‘Cloud Build’ service and building the Strapi Admin UI needs at least some of those values. And unfortunately does not simply adopt what’s in the main ‘env_variables’ section.

Before you can use this Yaml file, however, you need to make some changes [customisations] to Strapi - mostly (but not all) in the ‘config’ directory. In order to reduce impact on ‘localhost’ testing, I split the contents of the ‘config’ folder into ‘config/env/development’ and ‘config/env/production’. Both start out as copies, and it’s the latter that gets changed for GAE.

3. Configuration

The above ‘service.yaml’ file assumes a ‘subfolder unified’ configuration for Strapi, much like the standard guides for other proxy configurations.

// server.js

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 8080),
  url: env('PUBLIC_URL'),
  app: {
    keys: env.array('APP_KEYS'),
  },
  webhooks: {
    populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
  },
});
// admin.js

module.exports = ({ env }) => ({
  auth: {
    secret: env('ADMIN_JWT_SECRET'),
  },
  apiToken: {
    salt: env('API_TOKEN_SALT'),
  },
  transfer: {
    token: {
      salt: env('TRANSFER_TOKEN_SALT'),
    },
  },
  url: env('PUBLIC_URL') + '/admin'
});
// middlewares.js

module.exports = ({env}) => [
  'strapi::errors',
  //'strapi::security',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'script-src': ["'self'"],
          'img-src': ["'self'", 'data:', 'blob:', 'storage.googleapis.com'],
          'media-src': ["'self'", 'data:', 'blob:', 'storage.googleapis.com'],
        }
      }
    }
  },
  'global::rewritePath',
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::logger',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

The CSP exceptions are to allow cloud storage URLs to be provided from the GCP Cloud Storage provider for the upload plugin. The odd one is ‘global::rewritePath’, which invokes the following:

// src/middlewares/rewritePath.js

module.exports = () => {
  return async (ctx, next) => {
    const prefix = '/strapi';
    const original = ctx.url;
    if (original.startsWith(prefix)) ctx.url = original.slice(prefix.length);
    await next();
    ctx.url = original;
  }
}

The latter is a global middleware module that must be inserted ‘above’ any actual processing of HTTP requests. It takes the place of the Nginx ‘rewrite’ rule that would normally be required, and removes the subfolder prefix. This prefix is hard-coded in the example above, and must match that provided in the service Yaml file, within the ‘PUBLIC_URL’ environment variable.

With all the above ‘fixes’ in place, you can deploy the Strapi service, using the gcloud CLI, but it still won’t work.

4. Routing

The last rung in the ladder is to set up subfolder routing, which on GAE is done with a ‘dispatch.yaml’ file. (This name is reserved and can’t be changed.) Here’s mine:

dispatch:
  - url: "*/strapi/*"
    service: strapi

The service name must match that in the Strapi service Yaml spec, and you can see the hard-coded prefix here, too. The first asterisk means ‘match any incoming host name’ and the one at the end means 'match any further path elements. The syntax is a bit limited, so if you want ‘/strapi’ (rather than just ‘/strapi/’) to launch the Strapi landing page, you’ll need a second entry, without the trailing ‘/*’.

If you do all of the above, you should see your Strapi instance available and responding correctly on the expected external URLs. The above configuration doesn’t give it a private (VPC) IP address, but you can access it from another GAE service (e.g. the default app) using its service URL. But that’s beyond the scope of this post. Use an authorisation token to protect your APIs.

Thanks for the information.