initial commit
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# SoftVowels
|
||||
|
||||
> A premium tech publication. **Astro 5 (static)** frontend + **PayloadCMS 3** headless backend, deployed with **Docker** and fronted by **Nginx Proxy Manager**.
|
||||
|
||||
SoftVowels is a content-driven site for long-form engineering writing. Editors write in `/admin`; the public site is regenerated on demand and served as static HTML by nginx.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Stack](#stack)
|
||||
- [Repository layout](#repository-layout)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick start (local dev)](#quick-start-local-dev)
|
||||
- [Production build](#production-build)
|
||||
- [Docker deployment (homeserver)](#docker-deployment-homeserver)
|
||||
- [On-demand rebuilds (webhook flow)](#on-demand-rebuilds-webhook-flow)
|
||||
- [Nginx Proxy Manager setup](#nginx-proxy-manager-setup)
|
||||
- [Customising the design](#customising-the-design)
|
||||
- [Self-hosting fonts](#self-hosting-fonts)
|
||||
- [Content model](#content-model)
|
||||
- [Useful commands](#useful-commands)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | [Astro 5](https://astro.build/) — `output: 'static'`, Tailwind v4, MDX, sitemap, RSS |
|
||||
| Backend / CMS | [PayloadCMS 3](https://payloadcms.com/) running on Next.js 15 |
|
||||
| Database | PostgreSQL 16 (one schema, one volume) |
|
||||
| Static host | nginx 1.27 (one container, serves `apps/web/dist/`) |
|
||||
| Reverse proxy / TLS | [Nginx Proxy Manager](https://nginxproxymanager.com/) on the homeserver |
|
||||
| Container runtime | Docker + Docker Compose v2 |
|
||||
| Language / runtime | Node.js 20+, pnpm 9 |
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── web/ # Astro 5 frontend (static)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── components/ # layout, home, article, category, search, newsletter, ui
|
||||
│ │ │ ├── layouts/ # BaseLayout, ArticleLayout
|
||||
│ │ │ ├── lib/ # payload client, types, seo, format
|
||||
│ │ │ ├── pages/ # routes
|
||||
│ │ │ └── styles/ # global.css (@theme tokens), prose.css
|
||||
│ │ ├── public/fonts/ # drop Geist*.woff2 here
|
||||
│ │ ├── Dockerfile # build → dist, copy to nginx
|
||||
│ │ ├── nginx-default.conf # serves dist/, gzip, /admin → CMS
|
||||
│ │ └── astro.config.mjs
|
||||
│ └── cms/ # PayloadCMS 3 admin
|
||||
│ ├── src/
|
||||
│ │ ├── collections/ # Articles, Authors, Categories, Tags, Editions, Subscribers, Media, Users
|
||||
│ │ ├── hooks/ # revalidateAstro (afterChange → POST webhook)
|
||||
│ │ └── app/ # Next.js routes
|
||||
│ ├── Dockerfile
|
||||
│ └── payload.config.ts
|
||||
├── scripts/
|
||||
│ ├── rebuild.sh # git pull + pnpm --filter web build
|
||||
│ ├── webhook.js # host-side Node webhook receiver
|
||||
│ └── softvowels-webhook.service
|
||||
├── docker-compose.yml # db + cms + web
|
||||
├── .env.example
|
||||
├── AGENTS.md # contributor conventions
|
||||
└── README.md # ← you are here
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20.11+** (`node --version`)
|
||||
- **pnpm 9** (`npm i -g pnpm`)
|
||||
- **Docker 24+** with Compose v2 (`docker compose version`)
|
||||
- A homeserver (or any Linux box) with **Nginx Proxy Manager** already running
|
||||
- Two DNS records pointing at the homeserver:
|
||||
- `softvowels.example` (the public site)
|
||||
- `cms.softvowels.example` (the admin)
|
||||
|
||||
## Quick start (local dev)
|
||||
|
||||
Local dev doesn't need Docker for the apps themselves; only Postgres is required.
|
||||
|
||||
```bash
|
||||
# 1. Install workspace dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Copy environment templates
|
||||
cp .env.example .env
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
cp apps/cms/.env.example apps/cms/.env
|
||||
|
||||
# 3. Fill in the secrets in .env (at minimum POSTGRES_PASSWORD and PAYLOAD_SECRET)
|
||||
|
||||
# 4. Start Postgres in Docker
|
||||
docker compose up -d db
|
||||
|
||||
# 5. Run the two apps
|
||||
pnpm dev:cms # Payload on http://localhost:3000/admin
|
||||
pnpm dev:web # Astro on http://localhost:4321
|
||||
```
|
||||
|
||||
The Astro dev server fetches articles from `PAYLOAD_API_INTERNAL=http://localhost:3000`. If Payload is empty, every page still renders a graceful empty state.
|
||||
|
||||
> First Payload boot will print a one-time URL in the logs to create the first admin user. Open it in your browser.
|
||||
|
||||
## Production build
|
||||
|
||||
Build both apps as containers:
|
||||
|
||||
```bash
|
||||
pnpm build # builds apps/web (static)
|
||||
pnpm build:cms # builds apps/cms (Next.js production server)
|
||||
```
|
||||
|
||||
Or build the containers directly:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
The static site is emitted to `apps/web/dist/` and is what the `web` nginx container serves.
|
||||
|
||||
## Docker deployment (homeserver)
|
||||
|
||||
The repo ships a `docker-compose.yml` with three services: `db`, `cms`, and `web`.
|
||||
|
||||
### One-time setup on the server
|
||||
|
||||
```bash
|
||||
# 1. Get the code on the server
|
||||
git clone <your-git-url> /opt/softvowels
|
||||
cd /opt/softvowels
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
```
|
||||
|
||||
`.env` requires these values:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Postgres password (used by `db` and `cms`) |
|
||||
| `PAYLOAD_SECRET` | Long random string, signs Payload sessions |
|
||||
| `REVALIDATE_TOKEN` | Shared secret for the `/__revalidate` webhook |
|
||||
| `WEB_HOSTNAME` | e.g. `softvowels.example` |
|
||||
| `CMS_HOSTNAME` | e.g. `cms.softvowels.example` |
|
||||
|
||||
Generate them with:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # for PAYLOAD_SECRET
|
||||
openssl rand -hex 24 # for REVALIDATE_TOKEN
|
||||
```
|
||||
|
||||
### Drop in self-hosted fonts
|
||||
|
||||
```bash
|
||||
curl -L -o apps/web/public/fonts/Geist-Variable.woff2 \
|
||||
https://github.com/vercel/geist-font/raw/main/fonts/GeistVF.woff2
|
||||
curl -L -o apps/web/public/fonts/GeistMono-Variable.woff2 \
|
||||
https://github.com/vercel/geist-font/raw/main/fonts/GeistMonoVF.woff2
|
||||
```
|
||||
|
||||
(The `@font-face` rules are already in `apps/web/src/styles/global.css`.)
|
||||
|
||||
### First boot
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose logs -f cms # watch the first migration
|
||||
```
|
||||
|
||||
The first time Payload starts it will print a one-time setup URL in the `cms` logs:
|
||||
|
||||
```bash
|
||||
docker compose logs cms | grep -i "first user"
|
||||
```
|
||||
|
||||
Open it in your browser to create the first admin user, then start publishing at `https://cms.softvowels.example/admin`.
|
||||
|
||||
## On-demand rebuilds (webhook flow)
|
||||
|
||||
The static site has to be rebuilt whenever content changes. The flow is:
|
||||
|
||||
```
|
||||
Editor hits Publish in /admin
|
||||
│
|
||||
▼
|
||||
Payload afterChange hook (apps/cms/src/hooks/revalidateAstro.ts)
|
||||
│ POST http://host.docker.internal:4321/__revalidate?secret=…&collection=…&op=…
|
||||
▼
|
||||
scripts/webhook.js (running on the host, not in Docker)
|
||||
│ spawns scripts/rebuild.sh
|
||||
▼
|
||||
scripts/rebuild.sh
|
||||
│ git pull origin main → pnpm install → pnpm --filter web build
|
||||
▼
|
||||
The next browser request gets the fresh dist/ via the web container
|
||||
```
|
||||
|
||||
### Install the webhook on the host
|
||||
|
||||
```bash
|
||||
sudo cp scripts/softvowels-webhook.service /etc/systemd/system/
|
||||
sudo systemctl edit --full softvowels-webhook.service
|
||||
# (set WEBHOOK_PORT, REBUILD_TOKEN, REPO_DIR, LOG_FILE in [Service])
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now softvowels-webhook
|
||||
sudo systemctl status softvowels-webhook
|
||||
```
|
||||
|
||||
The `REBUILD_TOKEN` value here **must match** the one in `.env`. The webhook listens on `127.0.0.1:4321`; Payload reaches it via `host.docker.internal:4321` (already wired in `docker-compose.yml`).
|
||||
|
||||
Logs land at `/var/log/softvowels-rebuild.log`. Tail with:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u softvowels-webhook -f
|
||||
tail -f /var/log/softvowels-rebuild.log
|
||||
```
|
||||
|
||||
## Nginx Proxy Manager setup
|
||||
|
||||
NPM runs on the host. Add two proxied hosts:
|
||||
|
||||
| Domain | Forward to | Notes |
|
||||
|---|---|---|
|
||||
| `softvowels.example` | `web:80` | Force SSL, enable HSTS, HTTP/2, websockets off |
|
||||
| `cms.softvowels.example` | `cms:3000` | Force SSL, HSTS, websockets on (Payload admin uses them) |
|
||||
|
||||
Because the `web` and `cms` services are on a private Docker network named `app`, NPM must run on the **same host** (or be given network access — easiest is host networking or adding NPM's container to the `app` network).
|
||||
|
||||
If NPM is also in Docker, attach it:
|
||||
|
||||
```bash
|
||||
docker network connect softvowels-app <npm_container_name>
|
||||
```
|
||||
|
||||
### Useful NPM custom locations (optional)
|
||||
|
||||
The `web` container already redirects `/admin` to the CMS. If you want NPM to handle that instead, add a `location /admin` redirect in NPM's "Advanced" tab.
|
||||
|
||||
## Customising the design
|
||||
|
||||
All tokens are declared in `apps/web/src/styles/global.css` inside the `@theme` block. Change once, applies everywhere:
|
||||
|
||||
- Surfaces: `--color-background`, `--color-surface-*`, `--color-border-subtle`
|
||||
- Text: `--color-on-surface`, `--color-on-surface-variant`, `--color-text-*`
|
||||
- Brand: `--color-primary` (default `#318db8`)
|
||||
- Radius: `--radius-sm | -md | -lg | -xl | -2xl | -full`
|
||||
- Type: `--text-display-lg | -headline-lg | -headline-md | -body-lg | -body-md | -label-md | -code`
|
||||
- Spacing: `--spacing-gutter | -margin-mobile | -margin-desktop | -section`
|
||||
- Containers: `--container-site (1440px) | -article (720px) | -reading-lane (800px)`
|
||||
|
||||
Article prose styles live in `apps/web/src/styles/prose.css` (`.prose-article`).
|
||||
|
||||
The full design brief is in `.opencode/stitch/DESIGN.md` and the original Stitch HTML mockups in `.opencode/stitch/softvowels_*/code.html`.
|
||||
|
||||
## Self-hosting fonts
|
||||
|
||||
Drop the two variable woff2 files into `apps/web/public/fonts/`:
|
||||
|
||||
| File | Source |
|
||||
|---|---|
|
||||
| `Geist-Variable.woff2` | <https://github.com/vercel/geist-font> (rename `GeistVF.woff2`) |
|
||||
| `GeistMono-Variable.woff2` | same repo (rename `GeistMonoVF.woff2`) |
|
||||
|
||||
The `@font-face` rules are already declared in `global.css`. The Material Symbols font is delivered through `@iconify-json/material-symbols` (svg sprites), so no separate font binary is required.
|
||||
|
||||
## Content model
|
||||
|
||||
Defined in `apps/cms/src/collections/`:
|
||||
|
||||
- **Articles** — title, slug, excerpt, Lexical body, cover image, category, tags, author, status (`draft | published`), publishedAt, readingMinutes, featured, featuredRank, SEO group
|
||||
- **Authors** — name, slug, role, avatar, bio, socials (twitter/github/site)
|
||||
- **Categories** — name, slug, description, icon (Material Symbol), color, order
|
||||
- **Tags** — name, slug
|
||||
- **Editions** — newsletter issues (number, title, intro, cover, articles, publishedAt, sentCount)
|
||||
- **Subscribers** — newsletter signups (email, source, unsubscribedAt)
|
||||
- **Media** — uploads with `thumbnail | card | hero` auto-sizes via sharp
|
||||
- **Users** — admin/editor/author accounts
|
||||
|
||||
The Astro side renders Lexical richText to HTML using `@payloadcms/richtext-lexical/html`. Custom converters can be added in `apps/web/src/lib/lexical.ts`.
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Astro only
|
||||
pnpm dev:cms # Payload only
|
||||
pnpm build # Astro build
|
||||
pnpm build:cms # Payload build
|
||||
pnpm typecheck # astro check (zero errors expected)
|
||||
pnpm format # prettier --write
|
||||
pnpm format:check # prettier --check
|
||||
|
||||
docker compose up -d --build # build and start all services
|
||||
docker compose down # stop (keeps volumes)
|
||||
docker compose down -v # stop and wipe volumes
|
||||
docker compose logs -f web # tail web container
|
||||
docker compose logs -f cms # tail Payload
|
||||
|
||||
sudo journalctl -u softvowels-webhook -f # tail webhook
|
||||
tail -f /var/log/softvowels-rebuild.log # tail rebuilds
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`pnpm install` complains about a broken `pnpm` binary on Windows.** Use `npm` inside each app folder instead:
|
||||
```bash
|
||||
cd apps/web && npm install
|
||||
cd ../cms && npm install
|
||||
```
|
||||
`pnpm-workspace.yaml` works with both package managers for development. The homeserver `scripts/rebuild.sh` prefers pnpm if available, falls back to npm.
|
||||
|
||||
**`astro build` fails with `Cannot convert undefined or null to object`.** Make sure `tailwindcss` and `@tailwindcss/oxide` are pinned to the **same** version (we use 4.1.13). Mismatched versions cause the scanner to crash on Windows + Node 22.
|
||||
|
||||
**`/admin` returns 502.** The CMS container hasn't finished initialising yet. Wait ~30 s after `docker compose up`. The first boot runs Payload's database migrations.
|
||||
|
||||
**`/admin` returns 404 from the `web` container.** It should redirect to the CMS. The redirect is configured in `apps/web/nginx-default.conf` (`location /admin`). If you've overridden NPM's locations, set the redirect there instead.
|
||||
|
||||
**Webhook returns `401`.** `REBUILD_TOKEN` in `apps/web/.env`, `apps/cms/.env`, `/etc/systemd/system/softvowels-webhook.service`, and `docker-compose.yml` (passed through as `ASTRO_REVALIDATE_TOKEN` to the CMS) must all match.
|
||||
|
||||
**Webhook returns `202` but nothing rebuilds.** Check `/var/log/softvowels-rebuild.log`. Common causes: `git pull` failed (auth), `pnpm install` failed (lockfile drift), or the rebuild script exited with an error before `pnpm build` completed.
|
||||
|
||||
**Editor in `/admin` can publish but the public site doesn't update.** The `web` container is a plain `nginx:1.27-alpine` that bind-mounts `./apps/web/dist` from the host. The webhook should be rewriting that folder. Verify:
|
||||
|
||||
```bash
|
||||
ls -la apps/web/dist/index.html
|
||||
docker compose exec web ls -la /usr/share/nginx/html/index.html
|
||||
```
|
||||
|
||||
If the host file is newer but the container still serves old content, run `docker compose restart web` so nginx re-opens the file handles. (nginx should pick up file changes automatically via inotify, but a restart is the nuclear option.)
|
||||
|
||||
---
|
||||
|
||||
Built with Astro 5, PayloadCMS 3, Tailwind v4, and Postgres 16.
|
||||
Reference in New Issue
Block a user