davide 071bc1eaa5 Make the site responsive for phones, tablets, and desktop
Mobile-first layout with breakpoints at 48rem (tablet) and 64rem (desktop):
stacked nav on phones, progressive column counts for the article grid and
footer, scrollable markdown tables, larger tap targets on nav links.
2026-08-25 19:04:40 +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%