Skip to content

CMS — Strapi Content Management

The portfolio uses Strapi v5 as a headless CMS. The CMS source lives in cms/ and is deployed as a separate Azure Container App alongside the frontend and backend.


Architecture

Frontend (www.sven-relijveld.com)
  └── fetches content via REST: /cms/api/...
        ├── singletons  → hero, about, footer, contact
        └── collections → project-pages, blog posts (future)

Strapi (cms.sven-relijveld.com)
  └── reads/writes to Postgres — schema: strapi
        uses CMS Managed Identity for passwordless auth

The CMS has its own custom domain, managed TLS cert, and Azure Managed Identity. It shares the same Postgres Flexible Server as the backend but uses a separate strapi schema.


Running locally

Prerequisites

  • Node 18–22
  • Postgres running locally (or use the docker-compose postgres service)

Start in development mode

cd cms
npm install
npm run develop

This starts Strapi on http://localhost:1337 with the Content-Type Builder enabled.

To connect to the local Postgres from docker-compose:

# Start postgres first
docker-compose up -d postgres

# Then start Strapi
cd cms
DATABASE_CLIENT=postgres \
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/portfolio \
DATABASE_SCHEMA=strapi \
npm run develop

On first start, Strapi runs its own migrations to create tables in the strapi schema.


Content-type workflow

Strapi content types are defined as JSON schema files committed to git. This means:

  • Schema changes must be made locally — the Content-Type Builder writes changes to cms/src/api/ on the filesystem. In the deployed container the filesystem is ephemeral — changes are lost on the next restart/redeploy.
  • The deployed CMS always runs production mode (strapi start) — Content-Type Builder is intentionally disabled in all deployed environments.

Correct workflow for schema changes

1. Run `npm run develop` locally
2. Open http://localhost:1337/admin
3. Use Content-Type Builder to add/modify content types
4. Strapi writes changes to cms/src/api/ and cms/src/components/
5. Commit those files to git
6. Push → CI builds a new image with the schema baked in
7. Deploy → the new schema is active

Wrong workflow (data loss)

Do not try to use Content-Type Builder on cms.dev.sven-relijveld.com. The admin UI there runs in production mode and the builder is disabled. Even if it were enabled, changes would be lost on the next deploy.


Content editing (data, not schema)

Editing actual content (filling in fields, publishing entries) can be done in any environment:

Content (data) is stored in Postgres and persists across deploys.


Installed plugins

Plugin Package Purpose
Documentation @strapi/plugin-documentation Swagger UI at /documentation
Color Picker @strapi/plugin-color-picker Color field type in content builder
GraphQL @strapi/plugin-graphql GraphQL endpoint at /graphql
Config Sync strapi-plugin-config-sync Tracks config changes in cms/config/sync/ for git
Preview Button strapi-plugin-preview-button Opens frontend preview from content editor
Schema Visualizer strapi-plugin-schema-visualizer Visual ER diagram in admin panel
Duplicate Button strapi-plugin-duplicate-button Duplicate content entries

Plugin config lives in cms/config/plugins.js.


Content Types

project-page

Used by the project detail pages (v2). Schema: cms/src/api/project-page/. TypeScript type: CmsProjectPage in shared/cms.types.ts.

content-post

Used by the Content & Presentations section. Schema: cms/src/api/content-post/content-types/content-post/schema.json.

Field Type Notes
title string Required
slug string URL-safe identifier; unique
content_type enum article or presentation
excerpt text Short description
cover_image media Cover image from Strapi media library
publish_date datetime Used for sorting
tags JSON string[]
external_url string Link to original publication
attachment media PDF file (for presentations)
source string Publication name (e.g. "OneDNA")
featured boolean Shown as spotlight card on /content
body Markdown Full article body; EMBED_n markers inject iframes
embeds JSON Array of {marker, url, caption} for interactive iframes

TypeScript type: CmsContentPost in shared/cms.types.ts.

Public REST API: GET /cms/api/content-posts?sort=publish_date:desc&populate=*


Infrastructure

The CMS is deployed as an Azure Container App:

Property Dev Prod
Name dna-dev-portfolio-we-ca-cms dna-prd-portfolio-we-ca-cms
URL https://cms.dev.sven-relijveld.com https://cms.sven-relijveld.com
Replicas 0–1 (scale to zero) 0–1 (scale to zero)
Mode strapi start (production) strapi start (production)
Postgres schema strapi strapi

The image is built from Dockerfile.strapi and pushed to ACR on every deploy.

Key Vault secrets

Secret Purpose
strapi-app-keys Session encryption (4 keys)
strapi-api-token-salt API token hashing
strapi-admin-jwt-secret Admin UI JWT signing
strapi-jwt-secret Content API JWT signing

Config Sync

The strapi-plugin-config-sync plugin exports Strapi configuration (roles, permissions, settings) to cms/config/sync/. Commit these files to git so config is reproducible across environments.

After changing permissions or roles in the admin UI, run:

cd cms
npx strapi config-sync export

Then commit the updated files in cms/config/sync/.


Migrations

Strapi manages its own database migrations automatically on startup. You do not need to run strapi migration:run manually — it happens when the container starts.

Schema migrations for the strapi schema in Postgres are handled by Strapi itself. The scripts/postgres-init/01-strapi-schema.sql script only creates the schema if it does not exist; Strapi creates all tables within it.


Bootstrap Seeding

cms/src/index.js runs a seed on every Strapi startup (idempotent — each post has its own guard). The seed:

  1. Uploads cover images and embedded images from cms/public/seed-images/ to the Strapi media library via the upload service
  2. Creates or updates content-post entries (upsert by slug — existing posts are updated, not skipped), attaching uploaded media IDs
  3. Resolves SEED_IMG_<n> placeholders in Markdown bodies to Strapi-hosted image URLs
  4. Grants public read permissions (find, findOne) for content-post, project-page, hero-section, about-section

To add a new seeded post: add the entry to the seed array in cms/src/index.js with a unique slug. The per-post idempotency guard ensures existing posts are not re-created on redeploy.

Page history

Field Value
Last updated 2026-06-29

Changelog

Date PRs Summary
2026-06-29 #560, #562 Add content-post collection type documentation, CmsContentPost TypeScript type, bootstrap seeding section