Files
davide 8021ea7254 Route every Strapi plugin path to the CMS, not just /admin
Strapi mounts each plugin's admin API at its own top-level path
(/content-manager, /upload, /i18n, ...), not nested under /admin as
previously assumed. That broke the dashboard widgets: recent/count
documents calls landed on the frontend instead of Strapi. List every
prefix actually used by the installed plugins (core-bundled ones plus
users-permissions and cloud from cms/package.json) explicitly in the
Caddyfile.
2026-08-25 20:06:40 +02:00

5.5 KiB

Blog

Blog platform: a public website built with Nuxt, and a private Strapi CMS where the articles are written. Everything runs behind Caddy via Docker Compose.

Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
                 └─ everything else                  → Nuxt (website)

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.

Development

No domain and no TLS needed — the services are published on localhost instead of going through Caddy. Requires Docker.

1. Create the environment file

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:

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

docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms 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.

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.

6. Open the website at http://localhost:3000 — home, /blog, /blog/<slug> and /category/<slug>.

Useful commands:

docker compose logs -f cms                                   # 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

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.

Production

1. Point the DNS at the server. One A record (and AAAA if you have IPv6) on the public IP of the machine:

Record Purpose
example.com the website and, at /admin, the Strapi admin panel

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.

3. Configure the environment. Copy .env.example to .env on the server and set:

Variable Value
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

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.

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.

4. Start everything

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.

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.

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.

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 .

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.

Architecture, conventions and constraints are documented in CLAUDE.md.

License

Proprietary — Copyright (c) 2026 Davide Grilli. All rights reserved. See LICENSE.