Serve the CMS admin panel under /admin on the public domain

Replace the separate CMS subdomain with path-based routing in Caddy:
/admin and /uploads go to Strapi, everything else to the frontend.
Strapi 5 nests its whole admin panel (UI and API) under /admin, so this
one prefix is enough and never collides with the frontend's own /api
routes. Drops CMS_DOMAIN and the unused STRAPI_URL env var on the cms
service; PUBLIC_STRAPI_URL now points at the same origin as the site.

Authentication is unchanged: Strapi's own admin login still gates the
panel, this only changes how it's reached.
This commit is contained in:
2026-08-25 19:49:37 +02:00
parent cb29c68d75
commit e927396505
6 changed files with 44 additions and 35 deletions
+2 -2
View File
@@ -2,7 +2,6 @@
# --- Domains (Caddy) ---
PUBLIC_DOMAIN=blog.localhost
CMS_DOMAIN=cms.blog.localhost
ACME_EMAIL=admin@example.com
# --- Database ---
@@ -25,4 +24,5 @@ STRAPI_URL=http://cms:1337
# Public base URL of the website, used for canonical URLs and Open Graph.
PUBLIC_SITE_URL=https://blog.localhost
# Public base URL of Strapi, used to build absolute media URLs in the browser.
PUBLIC_STRAPI_URL=https://cms.blog.localhost
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi.
PUBLIC_STRAPI_URL=https://blog.localhost
+14 -6
View File
@@ -16,17 +16,25 @@ sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora conf
## Architettura
```text
Browser → Caddy ─┬─ dominio pubblico → Nuxt 4 (SSR) → REST Strapi
└─ sottodominio CMS → Strapi 5 → PostgreSQL
Browser → Caddy ─┬─ /admin, /uploads → Strapi 5 → PostgreSQL
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi
```
Caddy instrada per **path**, non per sottodominio: `PUBLIC_DOMAIN` serve sia il sito che,
sotto `/admin` e `/uploads`, il pannello Strapi (Strapi 5 annida l'intera admin UI e la sua
API sotto `/admin`, senza toccare `/api`). Un solo dominio, un solo certificato TLS.
- Strapi è la **sola** fonte di verità editoriale. Niente altro backend (no Express/Nest/Fastify):
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi.
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi.
- **Il browser non parla mai con Strapi.** Le pagine chiamano gli endpoint Nitro in
`frontend/server/api/`, che sono l'unico posto dove si costruiscono query Strapi. Così
`NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e niente token nel client.
Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il ritorno in `shared/types/blog.ts`.
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione
richiesta né concessa).
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
- L'**interfaccia** è tradotta in en/it/es/fr con `@nuxtjs/i18n`: stringhe in
+13 -14
View File
@@ -4,8 +4,8 @@ Blog platform: a public website built with Nuxt, and a private Strapi CMS where
articles are written. Everything runs behind Caddy via Docker Compose.
```text
Browser → Caddy ─┬─ public domain → Nuxt (website)
└─ cms subdomain → Strapi (CMS) → PostgreSQL
Browser → Caddy ─┬─ /admin, /uploads → Strapi (CMS) → PostgreSQL
└─ everything else → Nuxt (website)
```
There are no front-end accounts: sign-up is disabled and only administrators write
@@ -46,7 +46,8 @@ The first build takes a few minutes. `docker-compose.dev.yml` publishes the port
`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
**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**
@@ -72,16 +73,15 @@ 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
**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 |
| `cms.example.com` | the Strapi admin panel |
| `example.com` | the website and, at `/admin`, 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.
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
@@ -92,10 +92,9 @@ reachable inside the Docker network — do not publish their ports.
| 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` |
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and `/uploads` to Strapi |
| `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 —
@@ -112,11 +111,11 @@ already ignored.
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.
This time Caddy is included: it serves both the website and, under `/admin` and
`/uploads`, 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://cms.example.com/admin`, immediately,
**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.
+15 -9
View File
@@ -15,15 +15,21 @@
{$PUBLIC_DOMAIN} {
import security_headers
encode zstd gzip
reverse_proxy frontend:3000
}
{$CMS_DOMAIN} {
import security_headers
encode zstd gzip
# Uploaded media can be large; Strapi's own limit still applies.
request_body {
max_size 100MB
# /admin and /uploads go to Strapi; everything else to the frontend.
# Strapi 5 nests the whole admin panel (UI + its own API) under /admin,
# so this single prefix is enough - it never touches /api, which stays
# reserved for the frontend's own Nitro endpoints.
@cms path /admin* /uploads*
handle @cms {
# Uploaded media can be large; Strapi's own limit still applies.
request_body {
max_size 100MB
}
reverse_proxy cms:1337
}
handle {
reverse_proxy frontend:3000
}
reverse_proxy cms:1337
}
-2
View File
@@ -9,8 +9,6 @@ services:
cms:
ports:
- "127.0.0.1:1337:1337"
environment:
STRAPI_URL: http://localhost:1337
frontend:
ports:
-2
View File
@@ -36,7 +36,6 @@ services:
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
STRAPI_URL: https://${CMS_DOMAIN}
volumes:
- cms-uploads:/app/public/uploads
@@ -71,7 +70,6 @@ services:
- 8.8.8.8
environment:
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN}
CMS_DOMAIN: ${CMS_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro