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.
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_SECRETorJWT_SECRETlater logs everyone out. ChangingENCRYPTION_KEYafter 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.