Files
blog/docs/architecture.md
T
davide 47a8e7788f Add docs/ with architecture, content model, frontend reference
Require CLAUDE.md changes to keep docs/ in sync with new features
or architecture.
2026-09-09 15:28:38 +02:00

5.5 KiB

Architecture

Services

Four containers (production, see docker-compose.yml):

Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
                 └─ everything else                 → Nuxt 4 SSR (frontend) → Strapi REST (internal)
  • databasepostgres:17-alpine. Not exposed publicly; only reachable by cms on the Docker network. Data on the postgres-data volume.
  • cms — Strapi 5, the sole source of editorial truth. No other backend framework exists in this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new service.
  • frontend — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints under /api/* (Nitro), which are the only code in the repo allowed to call Strapi.
  • caddy — single reverse proxy, single TLS certificate, single public domain (PUBLIC_DOMAIN). Routes by path, not subdomain.

docker-compose.dev.yml is a local-only override (publishes ports on localhost, drops Caddy) and must always be passed explicitly with -f docker-compose.yml -f docker-compose.dev.yml — it's not an auto-merged override.yml, precisely so it can't be picked up by accident in production.

Path routing (Caddy)

See caddy/Caddyfile. One site block on {$PUBLIC_DOMAIN}, one path matcher @cms listing every Strapi/plugin top-level prefix that must bypass Nuxt:

/admin*  /content-manager*  /content-type-builder*  /upload*  /i18n*
/email*  /content-releases*  /review-workflows*  /users-permissions*  /cloud*

Everything matching @cms goes to cms:1337 (100MB body limit, for media uploads). Everything else — including /api/* — goes to frontend:3000.

/api/* is reserved for Nuxt's own Nitro endpoints, never for Strapi. Strapi's public REST API (/api/articles, /api/categories) is reached only from inside the Docker network, by the Nitro server, over STRAPI_URL=http://cms:1337. The browser never sees a Strapi URL for content — only for cover images (PUBLIC_STRAPI_URL/uploads/..., read-only, unauthenticated).

If you add a Strapi plugin that mounts its own admin API path, add its prefix to @cms in the Caddyfile — this is the one place that list is maintained.

Request flow: reading an article

  1. Browser requests /blog/my-slug → Caddy → Nuxt SSR.
  2. frontend/app/pages/blog/[slug].vue calls useFetch('/api/articles/my-slug') — a same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the network in production).
  3. frontend/server/api/articles/[slug].get.ts calls strapiFetch() (in frontend/server/utils/strapi.ts), which hits STRAPI_URL (internal Docker address) with a Strapi-specific query built by frontend/server/utils/queries.ts.
  4. The endpoint converts the article's Markdown content to HTML server-side (marked, via renderMarkdown()) and derives a meta description (summarise()). The response shape is Article from frontend/shared/types/blog.ts.
  5. Nuxt renders the page with the HTML already embedded (v-html) — no Markdown parser ships to the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
  6. The cover image <img> tag points directly at PUBLIC_STRAPI_URL/uploads/... — the only asset the browser fetches straight from Strapi.

Request flow: publishing content

  1. Editor logs into /admin (Strapi admin panel, authenticated, editor/admin only — no public sign-up, see content-model.md).
  2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
  3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that slug hits Strapi live through the Nitro endpoint.

Environment variables

Defined in .env.example (root, drives docker-compose) and cms/.env.example (standalone Strapi dev, e.g. npm run develop outside Docker).

Variable Consumed by Purpose
PUBLIC_DOMAIN caddy Domain Caddy serves and requests a TLS cert for.
ACME_EMAIL caddy Contact email for Let's Encrypt.
POSTGRES_DB/USER/PASSWORD database, cms Postgres credentials.
APP_KEYS cms Strapi session/cookie signing keys (comma-separated).
API_TOKEN_SALT cms Salt for Strapi API token hashing.
ADMIN_JWT_SECRET cms Signs Strapi admin panel JWTs.
TRANSFER_TOKEN_SALT cms Salt for Strapi data-transfer tokens.
JWT_SECRET cms Signs users-permissions (public API) JWTs.
ENCRYPTION_KEY cms Strapi's encrypted-field key.
STRAPI_URL frontend (server-only) Internal Docker address of Strapi (http://cms:1337); mapped to NUXT_STRAPI_URL. Never sent to the browser.
NUXT_STRAPI_TOKEN frontend (server-only) Optional Bearer token for Strapi requests; unset by default.
PUBLIC_SITE_URL frontend Canonical public site URL for SEO/OG tags; mapped to NUXT_PUBLIC_SITE_URL.
PUBLIC_STRAPI_URL frontend, browser Public-facing Strapi origin for building absolute cover-image URLs; mapped to NUXT_PUBLIC_STRAPI_URL.

All Strapi secrets have standalone copies in cms/.env.example for local development without Docker (defaults to SQLite there, since DATABASE_CLIENT is unset).

Never commit .env files or real secret values — keep .env.example sanitized (placeholders only).