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:
2026-09-11 11:25:14 +02:00
parent 91540224a9
commit 49c42ecc96
64 changed files with 546 additions and 22706 deletions
+42 -49
View File
@@ -1,15 +1,15 @@
# Blog
Blog platform: a public website built with Nuxt, and a private Strapi CMS where the
Blog platform: a public website built with Nuxt, and a private PocketBase CMS where the
articles are written. Everything runs behind Caddy via Docker Compose.
```text
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
└─ everything else → Nuxt (website)
Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
└─ everything else → Nuxt (website), which also redirects /admin → /_/
```
There are no front-end accounts: sign-up is disabled and only administrators write
content. An article is a title, a Markdown body, a cover image and a category.
There are no front-end accounts: only the superuser writes content. An article is a
title, a Markdown body, a cover image and a category.
## Development
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
cp .env.example .env
```
**2. Generate the secrets.** Every `change-me` must become a different random value —
Strapi refuses to start otherwise. This fills them all:
**2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
`POCKETBASE_ADMIN_PASSWORD` in `.env` with your own — this is the superuser account
PocketBase creates (or updates) on every start. The domain and URL variables can stay as
they are for local use.
```bash
for var in POSTGRES_PASSWORD API_TOKEN_SALT ADMIN_JWT_SECRET TRANSFER_TOKEN_SALT JWT_SECRET ENCRYPTION_KEY; do
sed -i "s|^$var=.*|$var=$(openssl rand -base64 32)|" .env
done
sed -i "s|^APP_KEYS=.*|APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32)|" .env
chmod 600 .env
```
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
are for local use.
**3. Start the stack**
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
```
The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on
`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be
picked up by accident in production.
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy,
so the CMS is reached directly on its port). This is the first
run, so the form creates the account — pick your own credentials.
**4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
— this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
and log in with the credentials from `.env`.
**5. Write something.** In the admin panel: create a **Category**, then an **Article**
(the body field is Markdown), then press **Publish** — the website only shows published
content.
**5. Write something.** In the admin UI: create a **categories** record, then an
**articles** record (the `content` field is Markdown), then set `publishedAt` — the
website only shows articles whose `publishedAt` is set and not in the future.
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
`/category/<slug>`.
@@ -60,16 +55,15 @@ content.
Useful commands:
```bash
docker compose logs -f cms # follow the CMS logs
docker compose logs -f pocketbase # follow the CMS logs
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend
docker compose down # stop, keep the data
docker compose down -v # stop and WIPE the database and media
docker compose down -v # stop and WIPE the CMS database and media
```
To iterate on the code without rebuilding an image every time, run a package directly —
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is;
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
## Production
@@ -78,14 +72,16 @@ public IP of the machine:
| Record | Purpose |
|---|---|
| `example.com` | the website and, at `/admin`, the Strapi admin panel |
| `example.com` | the website, and, at `/admin`, the PocketBase admin UI |
Wait for the record to resolve before starting the stack — Caddy requests the
certificate on the first boot and a failed challenge means a retry delay.
**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses
it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only
reachable inside the Docker network — do not publish their ports.
it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
site (reachable by anyone who knows `/admin`, protected only by the superuser login, so
keep the password strong); PocketBase itself is never published directly, only Caddy's
proxy to it.
**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set:
@@ -94,16 +90,15 @@ reachable inside the Docker network — do not publish their ports.
| `PUBLIC_DOMAIN` | `example.com` |
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
| `PUBLIC_SITE_URL` | `https://example.com` |
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and Strapi's other plugin paths there (see `caddy/Caddyfile`) |
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
| `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
| `POCKETBASE_URL` | leave it as `http://pocketbase:8090` — internal address, never public |
| `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD` | your real superuser credentials |
Then generate **fresh** secrets on that machine with the same loop as in development —
different values from the ones you use locally. Keep `.env` out of version control; it is
already ignored.
Keep `.env` out of version control; it is already ignored.
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out.
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values
> unreadable. Set them once, then back up the file somewhere safe.
> Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
> password immediately (the entrypoint upserts it on every boot) — a credential change,
> so treat it with the same care as any production credential rotation.
**4. Start everything**
@@ -111,24 +106,22 @@ already ignored.
docker compose up -d --build
```
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on
its own, and adds HSTS and the other security headers.
This time Caddy is included: it serves both the website and, under `/admin`, the CMS
admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
HSTS and the other security headers.
**5. Create the administrator account** at `https://example.com/admin`, immediately,
before anyone else finds the URL — the first visitor to that form is the one who gets the
account. Then publish as in development.
**5. Log into the admin UI** at `https://example.com/admin` with the credentials from
`.env`, then publish as in development.
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the
`cms-uploads` volume (all images). Nothing else on the server holds state.
**6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
media together). Nothing else on the server holds state.
```bash
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
```
**Updating a running site**: pull the new code, then `docker compose up -d --build`.
Strapi applies its own schema changes at startup; take a backup first.
PocketBase applies new `pb_migrations/` files at startup; take a backup first.
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).