Replace Strapi CMS with PocketBase
Strapi + Postgres are gone in favor of PocketBase: a single Go binary with embedded SQLite, built-in admin UI and per-collection API rules. No content existed yet, so this is a clean swap with no data migration. Collections and rules are defined as code in pocketbase/pb_migrations/ and applied automatically on first boot. Draft & Publish has no native PocketBase equivalent, so it's reproduced with a nullable `publishedAt` field enforced by listRule/viewRule, matching the old Strapi semantics. Routing flips: PocketBase's admin UI and REST/file API are hardwired to `/_/` and `/api/*` at the domain root (its own dashboard assets and API calls reference those paths directly, so a stripped path prefix like `/admin/*` would break them). `/api` is therefore reserved for PocketBase now, and the frontend's Nitro endpoints move to `/content/*` (frontend/server/routes/content/, not server/api/). A `/admin` vanity route in Nitro (not Caddy) redirects to `/_/`, so it works the same in dev, where Caddy isn't part of the stack, and in production. frontend/server/utils/strapi.ts becomes pocketbase.ts; queries.ts is rewritten for PocketBase's filter/sort/fields/expand query syntax. StrapiImage becomes MediaImage (no width/height — PocketBase file fields don't store dimensions, and the cover images already reserve their aspect ratio via CSS, so this is not a regression). docs/*.md, CLAUDE.md and README.md are updated in the same commit.
This commit is contained in:
+58
-53
@@ -2,22 +2,22 @@
|
||||
|
||||
## Services
|
||||
|
||||
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
Three 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)
|
||||
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
|
||||
("/admin" redirects to /_/, handled by Nitro)
|
||||
```
|
||||
|
||||
- **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.
|
||||
- **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 `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
|
||||
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.
|
||||
(`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` —
|
||||
@@ -26,74 +26,79 @@ 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:
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||
|
||||
```
|
||||
/admin* /content-manager* /content-type-builder* /upload* /i18n*
|
||||
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
|
||||
```
|
||||
- `@pocketbase path /_/* /api/*` → `pocketbase:8090`, **unprefixed** (100MB body limit, for
|
||||
uploads).
|
||||
- everything else → `frontend:3000`.
|
||||
|
||||
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
|
||||
else — including `/api/*` — goes to `frontend:3000`.
|
||||
`/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.
|
||||
|
||||
**`/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).
|
||||
**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.
|
||||
|
||||
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.
|
||||
**`/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('/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`.
|
||||
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 `<img>` tag points directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
|
||||
asset the browser fetches straight from Strapi.
|
||||
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
|
||||
|
||||
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.
|
||||
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) and
|
||||
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
|
||||
Docker).
|
||||
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. |
|
||||
| `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. |
|
||||
| `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_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).
|
||||
| `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).
|
||||
|
||||
Reference in New Issue
Block a user