Skip to main content

Strapi Common Module

Overview

Strapi Common is a pure-configuration Terraform module in the RAD Modules ecosystem. It generates a config object consumed by platform modules (App CloudRun, App GKE) to deploy Strapi — an open-source headless CMS — on Google Cloud. The module provisions five GCP Secret Manager secrets (JWT signing keys, token salts, and application keys), defines one GCS bucket for media uploads, and emits all container configuration as Terraform outputs.

Strapi has specific cryptographic requirements: four distinct secrets must be consistent across restarts and instances or existing sessions and tokens become invalid. This module generates all five secrets at provision time and surfaces them through the secret_ids output (with a 30-second propagation wait) so that App CloudRun/App GKE can inject them as secret environment variables.


Architecture

┌──────────────────────────────────────────────────────────────────────────────┐
│ Strapi_Common (Layer 1) │
│ │
│ Inputs: project_id, tenant_deployment_id, deployment_id, │
│ enable_redis, redis_auth, ... │
│ │
│ ┌──────────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ GCP Resources │ │ Config Output (consumed by Layer 2) │ │
│ │ │ │ │ │
│ │ Secret Manager API │ │ container_image: "" (custom build) │ │
│ │ 5 secrets: │ │ container_port: 1337 │ │
│ │ jwt-secret │ │ database_type: POSTGRES_15 │ │
│ │ admin-jwt-secret │ │ initialization_jobs: [db-init] │ │
│ │ api-token-salt │ │ startup_probe: HTTP /_health 30s │ │
│ │ transfer-token-salt│ │ liveness_probe: HTTP /_health 15s │ │
│ │ app-keys (4×32) │ │ REDIS_HOST/PORT/PASSWORD (opt.) │ │
│ │ 30s propagation │ │ │ │
│ │ wait │ │ │ │
│ │ │ │ │ │
│ │ GCS Bucket │ │ │ │
│ │ strapi-uploads │ │ │ │
│ └──────────────────────┘ └─────────────────────────────────────────┘ │
│ │
│ resource_prefix = "{application_name}-{tenant_deployment_id}-{ │
│ deployment_id}" │
└──────────────────────────────────────────────────────────────────────────────┘


App_CloudRun / App_GKE (Layer 2)

GCP Resources Created

ResourceName PatternDescription
google_project_servicesecretmanager.googleapis.comEnsures Secret Manager API is active
random_password × 8Individual 32-char alphanumeric values
google_secret_manager_secret{prefix}-jwt-secretUser JWT signing secret
google_secret_manager_secret{prefix}-admin-jwt-secretAdmin panel JWT signing secret
google_secret_manager_secret{prefix}-api-token-saltAPI token salt
google_secret_manager_secret{prefix}-transfer-token-saltData transfer token salt
google_secret_manager_secret{prefix}-app-keysSession keys (4 values, comma-joined)
time_sleep30s wait after all secret versions are written

GCS Bucket (defined in storage_buckets output, created by Layer 2):

Bucket SuffixLocationPurpose
strapi-uploadsdeployment_regionStrapi media library uploads via GCS provider

resource_prefix format: "{application_name}-{tenant_deployment_id}-{deployment_id}" — uses hyphen separators, unlike most other modules which concatenate without separators. Example: strapi-prod-a1b2c3d4.

APP_KEYS format: The secret value is four 32-character keys joined with a comma: key1,key2,key3,key4. Strapi reads this as an array via env.array('APP_KEYS') in config/server.js.


Module Outputs

OutputTypeDescription
configobjectFull application configuration for App_CloudRun/App_GKE
storage_bucketslist(object)One bucket spec: strapi-uploads
secret_idsmap(string)Secret IDs for all 5 Strapi secrets (gated by 30s sleep)
secret_valuesmap(string) (sensitive)Plaintext secret values — used by Strapi GKE to inject secrets via explicit_secret_values without a Secret Manager data source read
pathstringAbsolute path to this module directory

secret_ids keys:

KeySecretPurpose
JWT_SECRET{prefix}-jwt-secretusers-permissions plugin JWT signing
ADMIN_JWT_SECRET{prefix}-admin-jwt-secretAdmin panel session JWT
API_TOKEN_SALT{prefix}-api-token-saltAPI token generation salt
TRANSFER_TOKEN_SALT{prefix}-transfer-token-saltData transfer token salt
APP_KEYS{prefix}-app-keysSession cookie signing keys

The secret_ids output has depends_on = [time_sleep.secret_propagation], ensuring all five secrets are fully propagated in IAM before downstream modules attempt to bind them to containers.


Input Variables

Identity & Project

VariableTypeDefaultDescription
project_idstringGCP project ID (required)
tenant_deployment_idstring"demo"Tenant identifier used in secret naming
deployment_idstring""Deployment identifier; auto-generated if empty
deployment_regionstring"us-central1"Region for the GCS bucket
resource_labelsmap(string){}Labels on all GCP resources

Application

VariableTypeDefaultDescription
application_namestring"strapi"Used in resource prefix
application_versionstring"latest"Application version tag
display_namestring"Strapi CMS"Human-readable display name
descriptionstring"Strapi Headless CMS"Description
db_namestring"strapi"PostgreSQL database name
db_userstring"strapi"PostgreSQL database user

Resources

VariableTypeDefaultDescription
cpu_limitstring"1000m"CPU limit
memory_limitstring"512Mi"Memory limit
min_instance_countnumber1Minimum instances (stays warm — not 0)
max_instance_countnumber10Maximum instances
enable_cloudsql_volumebooltrueEnable Cloud SQL Auth Proxy sidecar
environment_variablesmap(string){ NODE_ENV = "production" }Base environment variables
initialization_jobslist(any)[]Override default jobs (empty = use db-init)

Health Probes

VariableDefaultDescription
startup_probeHTTP /_health, 30s delay, 5s timeout, 10s period, 30 failuresStartup check (allows up to 330s total)
liveness_probeHTTP /_health, 15s delay, 5s timeout, 30s period, 3 failuresOngoing liveness check

failure_threshold = 30 on startup probe: Strapi runs database migrations and rebuilds its plugin registry on first boot. The high threshold (30 × 10s = 300s maximum tolerance) prevents premature pod termination while the application initialises.

Redis (Optional)

VariableTypeDefaultDescription
enable_redisboolfalseEnable Redis session store and REST cache
redis_hoststringnullRedis hostname; falls back to $(NFS_SERVER_IP) at runtime
redis_portstring"6379"Redis port
redis_authstring (sensitive)""Redis authentication string

When enable_redis = true, the following environment variables are added to the container:

VariableValue
ENABLE_REDIS"true"
REDIS_HOSTredis_host if set, otherwise "$(NFS_SERVER_IP)"
REDIS_PORTredis_port
REDIS_PASSWORDredis_auth

The $(NFS_SERVER_IP) placeholder is expanded at container startup by strapi-entrypoint.sh.


Initialization Job: db-init

PropertyValue
Imagepostgres:15-alpine
Scriptscripts/create-db-and-user.sh
execute_on_applytrue
max_retries1
Timeout600s

create-db-and-user.sh flow:

  1. Resolves target host (DB_HOSTDB_IP fallback)
  2. Waits for PostgreSQL using psql -c '\l' (full connection test, not just pg_isready)
  3. Creates/updates the database user via a DO $$ PL/pgSQL block (idempotent)
  4. Grants "$DB_USER" TO postgres (required for Cloud SQL where postgres is not a true superuser)
  5. Grants CREATEDB privilege to DB_USER (Strapi needs this to manage its own test/migration databases)
  6. Grants ALL PRIVILEGES ON DATABASE postgres to DB_USER
  7. Creates database with CREATE DATABASE … OWNER "$DB_USER" or updates owner if it already exists
  8. Grants all privileges on the database and public schema
  9. Signals Cloud SQL Auth Proxy shutdown via POST http://localhost:9091/quitquitquit (30 retries, 2s intervals)

CREATEDB grant: This is unique to Strapi Common. Strapi's Knex-based migration system creates and drops databases during certain operations. Without CREATEDB, these operations fail with permission errors.


Container Image

The module builds from scripts/Dockerfile using a two-stage build on node:20-alpine.

Build stages

Stage 1 (build):

Base: node:20-alpine
System: build-base, gcc, autoconf, automake, zlib-dev, libpng-dev, nasm, bash, vips-dev
Steps:
1. npm install (all dependencies)
2. npm run build (Strapi admin panel compilation)
Artifacts: /opt/app/build, /opt/app/.strapi

Stage 2 (runtime):

Base: node:20-alpine
System: vips-dev, tini
+ build tools installed temporarily for npm install --omit=dev, then removed
Steps:
1. npm install --omit=dev (production deps only)
2. Copy source files
3. Remove macOS extended attribute files (._* from tar extraction)
4. mkdir -p public/uploads (required by Strapi at runtime)
5. Copy build artifacts from Stage 1
6. chown -R node:node /opt/app
7. USER node
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/strapi-entrypoint.sh"]
EXPOSE 1337

The vips-dev library is required by the sharp npm package (used for image processing/optimisation in the media library).

Build tools (build-base, gcc, autoconf, automake, zlib-dev, libpng-dev, nasm, bash) are added for the npm install --omit=dev step and then removed (apk del) to keep the runtime image small.


Scripts

strapi-entrypoint.sh

Minimal entrypoint that resolves the $(NFS_SERVER_IP) placeholder in REDIS_HOST before starting Strapi:

if echo "${REDIS_HOST:-}" | grep -q '$(NFS_SERVER_IP)'; then
export REDIS_HOST=$(echo "$REDIS_HOST" | sed "s/\$(NFS_SERVER_IP)/$NFS_SERVER_IP/g")
fi

exec node node_modules/@strapi/strapi/bin/strapi.js start "$@"

Strapi is started via direct node invocation of the Strapi CLI rather than npm start, which avoids an extra process layer and ensures signals propagate correctly through tini.


Strapi Configuration Files

config/database.js

Supports both Strapi-native (DATABASE_*) and platform-standard (DB_*) environment variables, with Strapi-native taking priority:

Strapi variablePlatform fallbackDefault
DATABASE_HOSTDB_HOST
DATABASE_PORTDB_PORT5432
DATABASE_NAMEDB_NAME
DATABASE_USERNAMEDB_USER
DATABASE_PASSWORDDB_PASSWORD
DATABASE_SSLfalse

DATABASE_SSL accepts "true", "false", or a JSON-encoded SSL options object (e.g. {"rejectUnauthorized": false}).

config/admin.js

Maps secrets to Strapi admin configuration:

Env varSourcePurpose
ADMIN_JWT_SECRET{prefix}-admin-jwt-secretSigns admin panel session JWTs
API_TOKEN_SALT{prefix}-api-token-saltSalts generated API tokens
TRANSFER_TOKEN_SALT{prefix}-transfer-token-saltSalts data transfer tokens

config/server.js

Env varDefaultPurpose
HOST0.0.0.0Listen address
PORT1337Listen port
STRAPI_URL""Public URL (set to Cloud Run service URL)
APP_KEYSComma-separated session signing keys (from {prefix}-app-keys)
WEBHOOKS_POPULATE_RELATIONSfalseInclude relations in webhook payloads

proxy: true is hardcoded — required for Strapi to correctly read X-Forwarded-Proto and X-Forwarded-For headers behind the Cloud Run load balancer.

config/plugins.js

Configures three plugin areas conditionally:

GCS Upload (always active):

Uses @strapi-community/strapi-provider-upload-google-cloud-storage for the Strapi media library. Required environment variables:

VariableDescription
GCS_BUCKET_NAMEName of the strapi-uploads GCS bucket (injected by App_CloudRun/App_GKE)
GCS_BASE_URLPublic base URL for served assets
GCS_PUBLIC_FILESWhether uploaded files are public (default: true)
GCS_UNIFORMUse uniform bucket-level access (default: true)

Redis plugin (conditional on REDIS_HOST):

When REDIS_HOST is set (i.e. env('REDIS_HOST') is truthy), enables strapi-plugin-redis with a default connection. Note: the ENABLE_REDIS environment variable is injected by the platform but plugins.js gates on REDIS_HOST directly, not on ENABLE_REDIS:

SettingValue
Max connections32767
Connect timeout5000ms
Max retries per request3
Lazy connecttrue
Retry strategyExponential backoff, max 2000ms, give up after 3 attempts

Also enables strapi-plugin-rest-cache with strapi-provider-rest-cache-redis for HTTP response caching. Content types to cache are configured via the contentTypes array (empty by default).

Email / SMTP (conditional on SMTP_HOST):

When SMTP_HOST is set, enables the nodemailer email provider:

VariableDescription
SMTP_HOSTSMTP server hostname
SMTP_PORTSMTP port (default: 587)
SMTP_USERNAMESMTP authentication user (auth.user in nodemailer config)
SMTP_PASSWORDSMTP authentication password
EMAIL_FROMDefault sender address
EMAIL_REPLY_TODefault reply-to address

npm Dependencies

PackageVersionPurpose
@strapi/strapi4.24.2Core Strapi framework
@strapi/plugin-users-permissions4.24.2User authentication
@strapi/plugin-i18n4.24.2Internationalisation
@strapi/plugin-cloud4.24.2Cloud deployment tooling
@strapi-community/strapi-provider-upload-google-cloud-storage^4.0.0GCS media upload
strapi-plugin-redis1.1.0Redis connection management
strapi-plugin-rest-cache4.2.8REST API response caching
strapi-provider-rest-cache-redis4.2.8Redis backend for REST cache
pg8.11.3PostgreSQL driver (Knex)
sharp^0.32.6Image processing (requires libvips)
react^18.0.0React peer dependency (required by Strapi admin panel)
react-dom^18.0.0React DOM peer dependency
react-router-dom^5.2.0Router peer dependency for Strapi admin panel
styled-components^5.2.1CSS-in-JS peer dependency for Strapi admin panel

Node.js requirement: >=18.0.0 <=20.x.x.


Platform-Specific Differences

AspectStrapi CloudRunStrapi GKE
service_urlComputed Cloud Run service URLEmpty string (not known at plan time)
enable_cloudsql_volumeOptional (Auth Proxy sidecar)Not used (TCP to Cloud SQL private IP)
DB_HOSTCloud SQL Auth Proxy socket pathCloud SQL private IP
NFSEnabled by default (enable_nfs = true)Enabled by default (enable_nfs = true)
RedisOptional; disabled by defaultOptional; disabled by default
GCS media uploadsstrapi-uploads bucket (always enabled)strapi-uploads bucket (always enabled)
Secret injectionsecret_ids map from module.strapi_appSecret values injected directly
ScalingServerless (min_instance_count = 1, max = 10)Kubernetes Deployment with configurable replicas

Usage Example

module "strapi_common" {
source = "./modules/Strapi_Common"

project_id = var.project_id
tenant_deployment_id = "prod"
deployment_id = random_id.deployment.hex
deployment_region = "us-central1"

enable_redis = true
# redis_host omitted — resolves to NFS_SERVER_IP at runtime

environment_variables = {
NODE_ENV = "production"
STRAPI_URL = "https://cms.example.com"
GCS_BASE_URL = "https://storage.googleapis.com/my-project-strapi-uploads"
}
}

module "strapi_cloudrun" {
source = "./modules/App_CloudRun"

config = module.strapi_common.config
storage_buckets = module.strapi_common.storage_buckets

secret_env_vars = {
JWT_SECRET = module.strapi_common.secret_ids["JWT_SECRET"]
ADMIN_JWT_SECRET = module.strapi_common.secret_ids["ADMIN_JWT_SECRET"]
API_TOKEN_SALT = module.strapi_common.secret_ids["API_TOKEN_SALT"]
TRANSFER_TOKEN_SALT = module.strapi_common.secret_ids["TRANSFER_TOKEN_SALT"]
APP_KEYS = module.strapi_common.secret_ids["APP_KEYS"]
}
}