Skip to content

Handoff: Strapi CMS + Project Page V2

Session date: 2026-05-31


What was built

1. Strapi v5 CMS scaffold — feat/strapi-cms (open PR)

Branch: feat/strapi-cms

Files added:

Path Purpose
cms/ Full Strapi v5 app in plain JS (no TypeScript, avoids compile-before-config problem)
cms/config/database.js Postgres connection via DATABASE_CLIENT=postgres, DATABASE_SCHEMA=strapi
cms/config/server.js Host/port/app keys
cms/config/admin.js Admin JWT, API token salt
cms/config/middlewares.js CORS origins (localhost + PUBLIC_URL)
cms/src/index.js Bootstrap: seeds iac-platform-framework entry + public read permissions on first start
cms/src/api/project-page/ project-page collection type (one entry per slug)
cms/src/api/hero-section/ hero-section singleton
cms/src/api/about-section/ about-section singleton
cms/src/components/shared/ 6 reusable components: meta-field, architecture-section, architecture-bullet, code-snippet, diagram-callout, design-decision
Dockerfile.strapi Multi-stage production build
shared/cms.types.ts TypeScript types for Strapi REST responses
client/src/hooks/useProjectPageCMS.ts React Query hook — fetches from /cms/api/project-pages?filters[slug][$eq]={slug}&populate=*, falls back to static projectV2Data on error
scripts/postgres-init/01-strapi-schema.sql CREATE SCHEMA IF NOT EXISTS strapi — runs automatically on postgres container init

Files modified:

Path Change
client/src/pages/project-page-v2.tsx Replaced projectV2Data[slug] direct lookup with useProjectPageCMS(slug) hook
docker-compose.yml Added strapi service (node:22-alpine, source mount, named volumes); postgres gets init SQL volume; frontend gets CMS_URL=http://portfolio-strapi:1337
nginx.default.conf.template Added /cms/ proxy location using ${CMS_URL}
Dockerfile.frontend Added CMS_URL to envsubst substitution list
server/index.ts Added /cms proxy middleware (http-proxy-middleware) for dev server
vite.config.ts Added /cms proxy rewrite
cms/package.json Strapi 5.47.0 + admin + users-permissions + provider-upload-local + pg

Key design decisions:

  • Plain JS config files (not TypeScript) — Strapi v5 develop command cleans dist/ before recompiling, so TS configs must live in dist/config/ after compilation. Skipping TypeScript for the Strapi app avoids this complexity entirely.
  • placeholderData (not initialData) in the React Query hook — initialData marks the query as fresh and suppresses the CMS fetch when static data exists. placeholderData shows static data immediately but still fires the background fetch.
  • CMS fetch errors fall back to static projectV2Data — pages always render even when Strapi is unreachable.

2. Feature flag + V2 migration for all 7 projects — PR #485 (open)

Branch: feat/project-page-v2-migration

  • project-page-v2 flag added to backend (feature_flags.py, FeatureFlagsResponse, GET/PATCH endpoints)
  • projectPageV2: boolean added to frontend FeatureFlags interface and defaultFlags
  • ProjectRoute in App.tsx now gates on flags.projectPageV2 — flag off = V1 for everyone
  • FEATURE_PROJECT_PAGE_V2=true in docker-compose.yml (local dev default)
  • All 7 slugs added to projectV2Data with titleLines, tagline, overviewBody, metaFields:
Slug Title lines
iac-platform-framework Infrastructure-as-Code / Platform Framework (full bespoke content)
genai-framework Scalable GenAI Framework / for Unstructured Data
data-platform-migration Enterprise Data Platform / Migrations
realtime-bi-integration Real-time BI / Integration
metadata-ingestion Metadata-Driven / Ingestion Framework
azure-batch-optimization Azure Batch / Optimisation
healthcare-cost-analysis Healthcare Cost / Analysis

3. Azure infrastructure for Strapi — PR #486 (open)

Branch: feat/strapi-infra

container_apps.bicep

  • New params: deploy_cms, cms_custom_domain, deploy_cms_custom_domain
  • New MI: dna-{env}-portfolio-we-id-cms (conditional on deploy_cms)
  • cms_managed_cert — managed TLS cert for cms.*.sven-relijveld.com
  • cms_app — Strapi CA:
  • Name: dna-{env}-portfolio-we-ca-cms
  • External ingress, port 1337, single revision mode
  • minReplicas: 0, maxReplicas: 1
  • Reads 5 KV secrets: strapi-app-keys, strapi-api-token-salt, strapi-admin-jwt-secret, strapi-jwt-secret, database-url
  • MANAGED_IDENTITY_CLIENT_ID → CMS MI client ID for Postgres auth
  • PUBLIC_URL → custom domain if set, otherwise ACA FQDN
  • Frontend CA: CMS_URL env var injected when deploy_cms=true
  • RBAC: CMS MI → Key Vault Secrets User + AcrPull
  • Outputs: cms_app_name, cms_fqdn, cms_url, cms_identity_name, cms_principal_id

key_vault.bicep

4 Strapi secrets generated deterministically from KV resource ID (stable across redeploys):

Secret name Value
strapi-app-keys 4-part comma-separated uniqueString(kv.id, salt)
strapi-api-token-salt uniqueString(kv.id, 'strapi-api-token-salt')
strapi-admin-jwt-secret uniqueString(kv.id, 'strapi-admin-jwt-secret')
strapi-jwt-secret uniqueString(kv.id, 'strapi-jwt-secret')

main.bicep / bicepparams

  • New params wired through to container_apps module
  • parameters.dev.bicepparam: cms_custom_domain=cms.dev.sven-relijveld.com, deploy_cms=false
  • parameters.prod.bicepparam: cms_custom_domain=cms.sven-relijveld.com, deploy_cms=false

app_configuration.bicep / shared.bicep

All 4 feature flags now fully seeded in Bicep — no manual portal work:

Flag dev prod
cv-generation true true
portfolio-content-api true true
hero-role-cycler true true (synced from live prod value)
project-page-v2 true false (flip project_page_v2_prod_enabled=true when ready)

Controlled via parameters.shared.bicepparam — push to shared.bicep to change prod values.

deploy-application.yml

  • Builds and pushes portfolio-cms image on every deploy run
  • Extracts cms_app + cms_url from deployment outputs
  • Post-deploy step: initialises strapi Postgres schema via backend CA exec
  • Smoke test: probes CMS_URL/_health when CMS is deployed

Merge order

Merge in this order to avoid dependency issues:

  1. PR #486 (feat/strapi-infra) — no app code dependency, safe to merge first
  2. feat/strapi-cms — Strapi scaffold; safe to merge alongside #486
  3. PR #485 (feat/project-page-v2-migration) — feature flag + all-project V2 migration

Activation sequence (after all PRs merged)

Dev

1. Run deploy-infrastructure-shared.yml
   → Creates project-page-v2 flag in App Config (dev=true, prod=false)

2. Run deploy-application (push to main or workflow_dispatch)
   → Builds portfolio-cms image and pushes to dev ACR
   → project-page-v2 flag now live in dev — V2 pages active on dev frontend

3. Edit infra/parameters.dev.bicepparam:
     param deploy_cms = true
   Push to infra/** → triggers Deploy Infrastructure on dev
   → Creates dna-dev-portfolio-we-ca-cms Container App
   → Creates strapi schema in Postgres
   → Injects CMS_URL into frontend CA

4. Add DNS record in shared DNS zone:
     CNAME  cms.dev.sven-relijveld.com  →  <cms-ca-fqdn>
   (fqdn from Azure portal or: az containerapp show --name dna-dev-portfolio-we-ca-cms --rg dna-dev-portfolio-westeurope-rg --query properties.configuration.ingress.fqdn -o tsv)

5. Edit infra/parameters.dev.bicepparam:
     param deploy_cms_custom_domain = true
   Push → binds managed TLS cert

6. Open https://cms.dev.sven-relijveld.com/admin
   → Create first Strapi admin account
   → iac-platform-framework entry already seeded by bootstrap

7. Verify https://dev.sven-relijveld.com/project/iac-platform-framework
   → Network tab should show /cms/api/project-pages → 200

8. Add Strapi entries for the 6 remaining project slugs via admin UI
   (or populate via API — see cms/src/index.js bootstrap for the data shape)

Prod

1. Edit infra/parameters.prod.bicepparam:
     param deploy_cms = true
   Push to infra/** on main (or v* tag) → triggers prod infra deploy

2. Add DNS: CNAME cms.sven-relijveld.com → <prod-cms-ca-fqdn>

3. Set deploy_cms_custom_domain = true → re-run infra

4. Create Strapi admin at https://cms.sven-relijveld.com/admin

5. Migrate all 7 project entries from dev Strapi to prod
   (use Strapi Data Transfer: strapi transfer --to https://cms.sven-relijveld.com)

6. Flip project-page-v2 on prod:
   Edit infra/parameters.shared.bicepparam:
     param project_page_v2_prod_enabled = true
   Push to shared infra (deploy-infrastructure-shared.yml)

What's next in code

Phase Scope Effort
Frontend singletons Replace hardcoded hero/about/footer/contact strings with Strapi API calls + static fallback. Add useHeroSection, useAboutSection hooks. ~3h
Blog / speaking / announcements New Strapi collection types + frontend routes /blog, /blog/:slug, /speaking, /speaking/:slug, /announcements ~4h
Case study join Strapi case-study collection (slug joins to Postgres project), merge on V2 page ~2h
V1 deprecation Delete project-detail.tsx + V1 branch in App.tsx once all slugs confirmed stable on prod ~30min
Strapi Postgres schema grant Add GRANT CREATE ON DATABASE portfolio TO strapi_mi to the post-deploy AAD admin step in deploy-infrastructure.yml — currently done manually via postgres-init SQL on first start ~30min
  1. Activate on dev (follow activation sequence above)
  2. Verify all 7 project pages end-to-end
  3. Activate on prod
  4. Frontend singletons (hero/about/footer)
  5. Blog + speaking routes (enables Strapi as the primary content authoring tool)
  6. V1 deprecation