diff --git a/CLAUDE.md b/CLAUDE.md
index e8e4c3a..ac2b4f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,6 +112,14 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
Il riferimento Hostinger è solo ispirazione visiva. Non copiare codice o asset.
+## Documentazione
+
+`docs/` descrive struttura e comportamento del repo (architettura, content model, frontend) a un
+livello più approfondito di questo file. Se implementi nuove funzionalità, endpoint, content type
+o cambi l'architettura (nuovo servizio, nuova rotta Caddy, nuovo modo di scambiare dati tra
+frontend e CMS), **aggiorna il file `docs/*.md` pertinente nello stesso commit**, o creane uno
+nuovo se non esiste una sezione adatta. Non lasciare `docs/` disallineata col codice.
+
## Priorità
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
@@ -133,5 +141,6 @@ built-in Strapi al reimplementare funzioni CMS in Nuxt.
## Prima di dichiarare completo
Lint → test → typecheck → build dell'app toccata, con gli script del `package.json` relativo.
-Non affermare che un check è passato se non l'hai eseguito. Chiudi riassumendo cosa è cambiato e
-quali rischi restano aperti.
+Non affermare che un check è passato se non l'hai eseguito. Se hai toccato architettura o
+funzionalità, verifica di aver aggiornato `docs/` (vedi [Documentazione](#documentazione)). Chiudi
+riassumendo cosa è cambiato e quali rischi restano aperti.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..14b7f53
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,23 @@
+# Documentation
+
+Technical documentation for this repository. For setup, deployment and day-to-day commands, see
+the root [README.md](../README.md); for coding conventions and constraints, see
+[CLAUDE.md](../CLAUDE.md). These docs explain the *structure and behavior* of the system in more
+depth than either of those.
+
+- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
+- [content-model.md](./content-model.md) — Strapi content types, admin panel, permissions, editorial workflow.
+- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
+
+## What this repository is
+
+A blog website with two faces:
+
+- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
+ no login, no client-side calls to the CMS.
+- **Admin panel** — the site owner logs into Strapi's admin UI (`/admin`) to write, edit and
+ publish articles and categories. This is the only way content changes; there is no other CMS
+ and no public user accounts.
+
+One codebase, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
+single Caddy reverse proxy on one domain.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..0a2a251
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,99 @@
+# Architecture
+
+## Services
+
+Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
+
+```text
+Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
+ └─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
+```
+
+- **database** — `postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the
+ Docker network. Data on the `postgres-data` volume.
+- **cms** — Strapi 5, the sole source of editorial truth. No other backend framework exists in
+ this repo; any server-side logic that isn't content management belongs in Nuxt's Nitro server,
+ not in a new service.
+- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
+ under `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
+- **caddy** — single reverse proxy, single TLS certificate, single public domain
+ (`PUBLIC_DOMAIN`). Routes by **path**, not subdomain.
+
+`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
+and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` —
+it's not an auto-merged `override.yml`, precisely so it can't be picked up by accident in
+production.
+
+## Path routing (Caddy)
+
+See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`, one path matcher
+`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
+
+```
+/admin* /content-manager* /content-type-builder* /upload* /i18n*
+/email* /content-releases* /review-workflows* /users-permissions* /cloud*
+```
+
+Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
+else — including `/api/*` — goes to `frontend:3000`.
+
+**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST
+API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
+Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
+— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
+
+If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the
+Caddyfile — this is the one place that list is maintained.
+
+## Request flow: reading an article
+
+1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
+2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/api/articles/my-slug')` — a same-origin
+ call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip over the
+ network in production).
+3. `frontend/server/api/articles/[slug].get.ts` calls `strapiFetch()` (in
+ `frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
+ Strapi-specific query built by `frontend/server/utils/queries.ts`.
+4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
+ `renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
+ `Article` from `frontend/shared/types/blog.ts`.
+5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
+ the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
+6. The cover image `
` tag points directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
+ asset the browser fetches straight from Strapi.
+
+## Request flow: publishing content
+
+1. Editor logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public
+ sign-up, see [content-model.md](./content-model.md)).
+2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
+3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
+ slug hits Strapi live through the Nitro endpoint.
+
+## Environment variables
+
+Defined in [.env.example](../.env.example) (root, drives docker-compose) and
+[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
+Docker).
+
+| Variable | Consumed by | Purpose |
+|---|---|---|
+| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
+| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
+| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
+| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
+| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
+| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
+| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
+| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
+| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
+| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
+| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
+| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
+| `PUBLIC_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. |
+
+All Strapi secrets have standalone copies in `cms/.env.example` for local development without
+Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
+
+Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
+only).
diff --git a/docs/content-model.md b/docs/content-model.md
new file mode 100644
index 0000000..a0d97a4
--- /dev/null
+++ b/docs/content-model.md
@@ -0,0 +1,63 @@
+# Content model & admin panel
+
+## Content types
+
+Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
+defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
+routes, services, or policies exist for either type.
+
+| Type | Fields |
+|---|---|
+| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) |
+| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
+
+Deliberately minimal: no Author (the only author is the admin account), no tags, no separate SEO
+fields. Don't add these back without a concrete need — see `CLAUDE.md`:
+
+- Meta description is derived at request time from the article body (`summarise()` in
+ `frontend/server/utils/strapi.ts`).
+- Publish date is Draft & Publish's `publishedAt`.
+- Social preview image is the cover.
+- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
+ not a content field.
+
+Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category
+(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
+numeric id.
+
+## Admin login & permissions
+
+- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
+ ([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
+ email/password admin authentication — no custom auth code in this repo.
+- Public visitors **never** authenticate. There is no visitor account system, no comments, no
+ public write access of any kind.
+- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
+ 1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
+ and Category, and nothing else (no create/update/delete, no other content type). This is
+ what lets the Nitro endpoints read published content without a token.
+ 2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
+ (`allow_register: false`), since no front-end user accounts should ever exist.
+- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
+ deliberate least-privilege boundary, not an oversight.
+- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
+ video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
+
+## Editorial workflow
+
+1. Log into `/admin`.
+2. Create/edit a Category if needed (name → slug is generated automatically).
+3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
+4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
+ permissions above — Strapi's default behavior already excludes non-published entries from the
+ public API.
+5. The change is live immediately: the public site has no cache layer to invalidate (see
+ [architecture.md](./architecture.md#request-flow-reading-an-article)).
+
+## Why Markdown, not a rich-text/WYSIWYG field
+
+`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
+happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
+in Strapi and never in the browser. This keeps `marked` out of the client bundle and keeps HTML
+generation in one place. There is no sanitization step: this is intentional, since the only author
+is the trusted admin, not arbitrary users.
diff --git a/docs/frontend.md b/docs/frontend.md
new file mode 100644
index 0000000..9fc5cce
--- /dev/null
+++ b/docs/frontend.md
@@ -0,0 +1,77 @@
+# Frontend (Nuxt)
+
+## Pages
+
+| Route | File | Behavior |
+|---|---|---|
+| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. |
+| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
+| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
+| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
+| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. |
+
+All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
+
+## Server (Nitro) endpoints — the only Strapi client
+
+`frontend/server/api/`:
+
+| Endpoint | Purpose |
+|---|---|
+| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated`. |
+| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. |
+| `GET /api/categories` | All categories (name + slug only), sorted by name. |
+| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
+
+This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call
+`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
+If you add a view that needs new data, add the endpoint here and type its return in
+`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
+
+## Supporting utilities
+
+- `frontend/server/utils/strapi.ts`
+ - `strapiFetch(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
+ Bearer token; wraps failures as a 502 so internal details never leak to the client.
+ - `renderMarkdown(source)` — `marked.parse()`, no sanitization (trusted, admin-only content).
+ - `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
+ description, word-boundary clipped.
+- `frontend/server/utils/queries.ts` — Strapi query-string builders kept intentionally minimal
+ (only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
+ `PAGE_SIZE`, `pagination()`, `pageParam()`.
+- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
+ into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
+ absolute.
+- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
+- `frontend/shared/utils/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
+ `isoDate`).
+
+## Types (`frontend/shared/types/blog.ts`)
+
+```
+StrapiImage { url, alternativeText, width, height }
+Category { name, slug }
+ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null }
+Article extends ArticleSummary { html, summary, author: string | null }
+Paginated { items: T[], page, pageCount, total }
+```
+
+`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
+listing pages use.
+
+## Layout & shared components
+
+- `app/layouts/default.vue` — the only layout: header (logo, tagline, nav), ``
+ slot, footer (contact email, nav, social links), with a skip-link for accessibility.
+- `app/components/ArticleCard.vue` — listing-grid card: cover (lazy, omitted if none), category
+ kicker, title link, formatted date.
+- `app/components/SocialIcon.vue` — inlines SVG brand marks from `simple-icons` at build time
+ (`?raw` imports) rather than bundling the whole icon set.
+
+## SEO & accessibility
+
+Every article page ships: unique ``, meta description, canonical URL, Open Graph tags, and
+JSON-LD `BlogPosting` structured data, with the article body already present in server-rendered
+HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
+meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
+— see `CLAUDE.md`.