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.

Thanks man. That worked well. I had got the api working but then was having troubles with the admin panel, that rewrite thing and adding it into middleware was missing mainly and couldn’t find anywhere else.

Before I wasn’t able to get admin panel up but api was working, now I am able to get the admin panel up after doing exactly as you did. But now api isn’t working. One difference I have is I have my default app engine as backend. But I don’t think that would matter much, would it ?

Trying some more things.

The prefix applies to both API and Admin panel. If you prefer not to have a prefix on your API, you probably need another routing rule in your dispatch.yaml file.

I do want the prefix, but it just doesn’t work. Keeps giving me 404 everytime. Admin panel works fine. I have something like this /um/admin. I want to api to be /um/api. I also tried setting prefix in api but that didn’t work.

‘api’ is part of the path that Strapi understands, so your prefix would be ‘um’ (in place of the ‘strapi’ that I used). If you don’t want your admin panel to be on /um/admin then you need two routing rules for your service: one for the admin panel and one for the api. And you will need to strip both prefixes in the middleware function. Do note, though, that the admin panel also uses API endpoints, so you must ensure that they are available with either prefix.

Thanks for your replies. Yes, there was misunderstanding I realized, the api and admin works. But it’s more problem with login routes, as they are the first ones being called. I am using strapi user permissions plugin and it doesn’t seem like /um/api/connect/<provider> to work. when I manually update the url it works fine if I remove /um.

Apparently it is using the apiPrefix key to build urls. And if I set that prefix nothing works at all. strapi/packages/plugins/users-permissions/server/services/providers.js at develop · strapi/strapi · GitHub

It’s important to realise that with App Engine, each service is typically ‘concealed’ behind what Google calls an ‘ingress’ (virtual server with proxy-pass rules). When configuring and accessing, one ‘should’ therefore have a forwarding rule in dispatch.yaml. There’s a default rule that forwards everything else to the ‘default’ service. If there’s no other application service, and you configure Strapi as your default service, that default rule will still forward to the Strapi service, which can create confusion - especially if you configure the Strapi ‘public URL’ inappropriately.

If you look back at the description I gave, you will see that the Strapi instance is not the default service and is configured with a PUBLIC_URL value that includes the ‘prefix’ that I then used in dispatch.yaml. This is important, so that any URL generated within Strapi (e.g. for 3rd party sign-on) will route correctly when accessed from outside the GAE environment - i.e. through the ingress layer.

If you want to add a prefix route segment, it is essential that it is reflected in both the PUBLIC_URL value and dispatch.yaml. (And that you remove it with middleware, when a request reaches Strapi.) But if you have configured Strapi as the default service in GAE, you must realise that it can also be accessed without that prefix. So if you configure the PUBLIC_URL value without the prefix, any generated URL will still reach Strapi.

The REST API prefix only replaces the default string of ‘api’ with something else: it doesn’t change any other part of the generated URLs or routes.