Files
blog/docs/architecture.md
T

100 lines
5.5 KiB
Markdown
Raw Normal View History

# Architecture
## Services
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
```text
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
```
- **database** — `postgres: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](../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](./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](../.env.example) (root, drives docker-compose) and
[cms/.env.example](../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).