# Architecture ## Services Three containers (production, see [docker-compose.yml](../docker-compose.yml)): ```text Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase) └─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal) ("/admin" redirects to /_/, handled by Nitro) ``` - **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 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 (`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) See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`: - `@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." `/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. **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. **`/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. 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. 6. The cover image `` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset the browser fetches straight from PocketBase. ## Request flow: publishing content 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 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. | | `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`. | | `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).