2026-08-25 10:17:53 +02:00
|
|
|
# Blog
|
|
|
|
|
|
2026-08-25 11:42:33 +02:00
|
|
|
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.
|
2026-08-25 10:17:53 +02:00
|
|
|
|
|
|
|
|
```text
|
2026-08-25 20:06:40 +02:00
|
|
|
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
|
|
|
|
|
└─ everything else → Nuxt (website)
|
2026-08-25 10:17:53 +02:00
|
|
|
```
|
|
|
|
|
|
2026-08-25 11:42:33 +02:00
|
|
|
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.
|
2026-08-25 10:17:53 +02:00
|
|
|
|
2026-08-25 11:42:33 +02:00
|
|
|
## Development
|
2026-08-25 10:17:53 +02:00
|
|
|
|
2026-08-25 11:42:33 +02:00
|
|
|
No domain and no TLS needed — the services are published on localhost instead of going
|
|
|
|
|
through Caddy. Requires Docker.
|
|
|
|
|
|
|
|
|
|
**1. Create the environment file**
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
cp .env.example .env
|
|
|
|
|
```
|
|
|
|
|
|
2026-08-25 11:45:41 +02:00
|
|
|
**2. Generate the secrets.** Every `change-me` must become a different random value —
|
|
|
|
|
Strapi refuses to start otherwise. This fills them all:
|
2026-08-25 11:42:33 +02:00
|
|
|
|
|
|
|
|
```bash
|
2026-08-25 11:45:41 +02:00
|
|
|
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
|
2026-08-25 11:42:33 +02:00
|
|
|
```
|
|
|
|
|
|
2026-08-25 11:45:41 +02:00
|
|
|
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
|
|
|
|
|
are for local use.
|
2026-08-25 11:42:33 +02:00
|
|
|
|
|
|
|
|
**3. Start the stack**
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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.
|
|
|
|
|
|
2026-08-25 19:49:37 +02:00
|
|
|
**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
|
2026-08-25 11:42:33 +02:00
|
|
|
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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-25 19:49:37 +02:00
|
|
|
**1. Point the DNS at the server.** One `A` record (and `AAAA` if you have IPv6) on the
|
2026-08-25 11:42:33 +02:00
|
|
|
public IP of the machine:
|
|
|
|
|
|
|
|
|
|
| Record | Purpose |
|
|
|
|
|
|---|---|
|
2026-08-25 19:49:37 +02:00
|
|
|
| `example.com` | the website and, at `/admin`, the Strapi admin panel |
|
2026-08-25 11:42:33 +02:00
|
|
|
|
2026-08-25 19:49:37 +02:00
|
|
|
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.
|
2026-08-25 11:42:33 +02:00
|
|
|
|
|
|
|
|
**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` |
|
2026-08-25 20:06:40 +02:00
|
|
|
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and Strapi's other plugin paths there (see `caddy/Caddyfile`) |
|
2026-08-25 11:42:33 +02:00
|
|
|
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
|
|
|
|
|
|
2026-08-25 11:45:41 +02:00
|
|
|
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.
|
2026-08-25 11:42:33 +02:00
|
|
|
|
|
|
|
|
> 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**
|
2026-08-25 10:17:53 +02:00
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
docker compose up -d --build
|
|
|
|
|
```
|
|
|
|
|
|
2026-08-25 20:06:40 +02:00
|
|
|
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.
|
2026-08-25 10:17:53 +02:00
|
|
|
|
2026-08-25 19:49:37 +02:00
|
|
|
**5. Create the administrator account** at `https://example.com/admin`, immediately,
|
2026-08-25 11:42:33 +02:00
|
|
|
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.
|
2026-08-25 10:17:53 +02:00
|
|
|
|
|
|
|
|
```bash
|
2026-08-25 11:42:33 +02:00
|
|
|
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 .
|
2026-08-25 10:17:53 +02:00
|
|
|
```
|
|
|
|
|
|
2026-08-25 11:42:33 +02:00
|
|
|
**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.
|
|
|
|
|
|
2026-08-25 10:17:53 +02:00
|
|
|
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
|
|
|
|
|
|
|
|
|
|
## License
|
|
|
|
|
|
|
|
|
|
Proprietary — Copyright (c) 2026 Davide Grilli. All rights reserved. See [LICENSE](LICENSE).
|