davide 31dbf56235 Show the author and publication time on articles
Strapi already records which admin user created an entry, so the byline needs
no extra field to fill in: the Article content type sets populateCreatorFields
and the API layer exposes only that user's firstname and lastname. The name
also feeds the BlogPosting structured data.

Also fixes the gap below the article header, which until now came from the
cover image's margin and therefore vanished on articles without a cover,
leaving the body text against the byline.
2026-08-25 15:30:57 +02:00

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 ─┬─ public domain → Nuxt (website)
                 └─ cms subdomain → Strapi (CMS) → PostgreSQL

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. 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. Two A records (and AAAA if you have IPv6) on the public IP of the machine:

Record Purpose
example.com the website
cms.example.com the Strapi admin panel

Wait for the records to resolve before starting the stack — Caddy requests the certificates 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
CMS_DOMAIN cms.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://cms.example.com
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 the website on PUBLIC_DOMAIN, the CMS on CMS_DOMAIN, obtains and renews the TLS certificates on its own, and adds HSTS and the other security headers.

5. Create the administrator account at https://cms.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.

S
Description
No description provided
Readme
1.2 MiB
Languages
TypeScript 60.1%
Vue 29.7%
JavaScript 5%
CSS 4%
Dockerfile 0.9%
Other 0.3%