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¶
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:
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:
- Uploads cover images and embedded images from
cms/public/seed-images/to the Strapi media library via the upload service - Creates or updates content-post entries (upsert by slug — existing posts are updated, not skipped), attaching uploaded media IDs
- Resolves
SEED_IMG_<n>placeholders in Markdown bodies to Strapi-hosted image URLs - Grants public read permissions (
find,findOne) forcontent-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 |