Files
blog/docs/architecture.md
T

109 lines
6.6 KiB
Markdown
Raw Normal View History

# Architecture
## Services
2026-09-11 11:25:14 +02:00
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
```text
2026-09-11 11:25:14 +02:00
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
("/admin" redirects to /_/, handled by Nitro)
```
2026-09-11 11:25:14 +02:00
- **pocketbase** — a single PocketBase binary, the sole source of editorial truth. Embedded
SQLite, no separate database service. 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
2026-09-11 11:25:14 +02:00
under `/content/*` (Nitro), which are the only code in the repo allowed to call PocketBase.
- **caddy** — single reverse proxy, single TLS certificate, single public domain
2026-09-11 11:25:14 +02:00
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain or port.
`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)
2026-09-11 11:25:14 +02:00
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
2026-09-11 11:25:14 +02:00
- `@pocketbase path /_/* /api/*``pocketbase:8090`, **unprefixed** (100MB body limit, for
uploads).
- everything else → `frontend:3000`.
This means the English routes under `/en/*` ([frontend.md](./frontend.md#english-content))
need **no Caddy changes** at all: they're plain Nuxt pages like any other, so they already fall
under "everything else."
2026-09-11 11:25:14 +02:00
`/admin` is **not** a Caddy rule: it's a Nitro route
(`frontend/server/routes/admin.get.ts`) that redirects to `${pocketbaseUrl}/_/` (PocketBase's own
fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather
than Caddy means it works identically in dev, where Caddy isn't part of the stack — `/admin`
redirects to `http://localhost:8090/_/` there — and in production, where it redirects to the same
origin's `/_/`, which Caddy then proxies to PocketBase.
2026-09-11 11:25:14 +02:00
**Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
`/admin/foo``/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
API call the dashboard's own JS makes afterwards targets `/_/...`/`/api/...` directly — those
requests would then miss the `/admin` prefix and never reach the rewrite rule, landing on Nuxt
instead and breaking the dashboard. Routing `/_/*` and `/api/*` at the domain root, unprefixed, is
the only configuration PocketBase's own code is written to expect (confirmed against a live
container: dashboard HTML, its JS/CSS assets under `/_/assets/...`, and REST calls under
`/api/...` all resolve correctly this way). This is also PocketBase's own documented recommendation
for reverse-proxy deployments.
2026-09-11 11:25:14 +02:00
**`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
Nitro endpoints live under `/content/*` instead (`frontend/server/routes/content/`, not
`frontend/server/api/`, since Nitro auto-prefixes anything under `server/api/` with `/api`).
PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro
server, over `POCKETBASE_URL=http://pocketbase:8090`; and directly by the browser for the two
things that don't go through Nitro — the admin UI and cover images.
## Request flow: reading an article
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
2026-09-11 11:25:14 +02:00
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/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/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
`frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
with a PocketBase filter/fields query built by `frontend/server/utils/queries.ts`. PocketBase's
`listRule`/`viewRule` on the `articles` collection already exclude unpublished entries — the
endpoint doesn't need to check that itself.
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.
2026-09-11 11:25:14 +02:00
6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
the browser fetches straight from PocketBase.
## Request flow: publishing content
2026-09-11 11:25:14 +02:00
1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
authenticated superuser only, no public sign-up, see [content-model.md](./content-model.md)).
2. Editor writes/edits an Article (Markdown body) or Category, and sets `publishedAt` to publish
it.
3. PocketBase writes to its embedded SQLite database (on the `pocketbase-data` volume). No cache
to invalidate: the next public request for that slug hits PocketBase live through the Nitro
endpoint.
## Environment variables
2026-09-11 11:25:14 +02:00
Defined in [.env.example](../.env.example) (root, drives docker-compose).
| 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. |
2026-09-11 11:25:14 +02:00
| `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
| `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
| `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
2026-09-11 11:25:14 +02:00
| `PUBLIC_POCKETBASE_URL` | frontend, browser | Public-facing PocketBase origin (same domain as `PUBLIC_SITE_URL`; Caddy proxies `/_/*` and `/api/*` there) for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_POCKETBASE_URL`. |
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
only).