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.
6.4 KiB
Architecture
Services
Three containers (production, see docker-compose.yml):
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. One site block on {$PUBLIC_DOMAIN}:
@pocketbase path /_/* /api/*→pocketbase:8090, unprefixed (100MB body limit, for uploads).- everything else →
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.
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
- Browser requests
/blog/my-slug→ Caddy → Nuxt SSR. frontend/app/pages/blog/[slug].vuecallsuseFetch('/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).frontend/server/routes/content/articles/[slug].get.tscallspbFetch()(infrontend/server/utils/pocketbase.ts), which hitsPOCKETBASE_URL(internal Docker address) with a PocketBase filter/fields query built byfrontend/server/utils/queries.ts. PocketBase'slistRule/viewRuleon thearticlescollection already exclude unpublished entries — the endpoint doesn't need to check that itself.- The endpoint converts the article's Markdown
contentto HTML server-side (marked, viarenderMarkdown()) and derives a meta description (summarise()). The response shape isArticlefromfrontend/shared/types/blog.ts. - 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. - The cover image
<img>tag points atPUBLIC_POCKETBASE_URL/api/files/...— the only asset the browser fetches straight from PocketBase.
Request flow: publishing content
- Editor goes to
PUBLIC_SITE_URL/admin(redirects to/_/, PocketBase's admin UI — authenticated superuser only, no public sign-up, see content-model.md). - Editor writes/edits an Article (Markdown body) or Category, and sets
publishedAtto publish it. - PocketBase writes to its embedded SQLite database (on the
pocketbase-datavolume). No cache to invalidate: the next public request for that slug hits PocketBase live through the Nitro endpoint.
Environment variables
Defined in .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).