290 lines
12 KiB
Markdown
290 lines
12 KiB
Markdown
# SoftVowels
|
|
|
|
> A premium tech publication. **Astro 5 (static)** frontend + **PayloadCMS 3** headless backend, deployed with **Docker only** 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 served as static HTML from a self-contained nginx container.
|
|
|
|
## Table of contents
|
|
|
|
- [Stack](#stack)
|
|
- [Repository layout](#repository-layout)
|
|
- [Prerequisites](#prerequisites)
|
|
- [Quick start (local dev)](#quick-start-local-dev)
|
|
- [Docker deployment (homeserver)](#docker-deployment-homeserver)
|
|
- [Updating the site after a content change](#updating-the-site-after-a-content-change)
|
|
- [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/) on Next.js 15 (standalone output) |
|
|
| Database | PostgreSQL 16 (one schema, one volume) |
|
|
| Static host | nginx 1.27 (one container, dist baked into the image) |
|
|
| Reverse proxy / TLS | [Nginx Proxy Manager](https://nginxproxymanager.com/) on the homeserver |
|
|
| Container runtime | Docker + Docker Compose v2 |
|
|
|
|
**The homeserver only needs Docker.** No Node, no pnpm, no git is required on the host at runtime — every image is built from source by the compose stack.
|
|
|
|
## 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 # pnpm build -> nginx alpine
|
|
│ │ ├── nginx-default.conf # serves dist/, gzip
|
|
│ │ ├── .dockerignore
|
|
│ │ └── astro.config.mjs
|
|
│ └── cms/ # PayloadCMS 3 admin
|
|
│ ├── src/
|
|
│ │ ├── collections/ # Articles, Authors, Categories, Tags, Editions, Subscribers, Media, Users
|
|
│ │ ├── hooks/ # revalidateAstro (webhook, currently unused)
|
|
│ │ ├── lib/ # withRevalidate helper
|
|
│ │ └── app/ # Next.js routes
|
|
│ ├── Dockerfile # pnpm build -> Next standalone
|
|
│ ├── .dockerignore
|
|
│ └── payload.config.ts
|
|
├── scripts/ # (legacy) host-side webhook + rebuild; not used currently
|
|
├── docker-compose.yml # db + cms + web, all in one
|
|
├── .env.example
|
|
├── AGENTS.md # contributor conventions
|
|
└── README.md # ← you are here
|
|
```
|
|
|
|
## Prerequisites
|
|
|
|
- **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)
|
|
|
|
That's it. No Node, no pnpm, no git on the host at runtime.
|
|
|
|
## Quick start (local dev)
|
|
|
|
Optional — only useful if you want to iterate on the source code on your dev machine. Production deployment is below.
|
|
|
|
```bash
|
|
pnpm install
|
|
cp .env.example .env
|
|
cp apps/web/.env.example apps/web/.env
|
|
cp apps/cms/.env.example apps/cms/.env
|
|
docker compose up -d softvowels-db
|
|
pnpm dev:cms # Payload on http://localhost:3000/admin
|
|
pnpm dev:web # Astro on http://localhost:4321
|
|
```
|
|
|
|
> First Payload boot prints a one-time URL in the logs to create the first admin user. Open it in your browser.
|
|
|
|
## Docker deployment (homeserver)
|
|
|
|
The repo ships a `docker-compose.yml` with three services, all on the external `nginx_proxy_manager` network:
|
|
|
|
| Service | Image | Port (internal) | Purpose |
|
|
|---|---|---|---|
|
|
| `softvowels-db` | `postgres:16-alpine` | 5432 | data store |
|
|
| `softvowels-cms` | built from `apps/cms/Dockerfile` | 3000 | Payload admin + REST/GraphQL API |
|
|
| `softvowels-web` | built from `apps/web/Dockerfile` | 80 | static site served by nginx |
|
|
|
|
### One-time setup
|
|
|
|
```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 |
|
|
| `WEB_HOSTNAME` | e.g. `softvowels.example` |
|
|
| `CMS_HOSTNAME` | e.g. `cms.softvowels.example` |
|
|
|
|
Generate secrets with:
|
|
|
|
```bash
|
|
openssl rand -hex 32 # for PAYLOAD_SECRET
|
|
openssl rand -hex 24 # for POSTGRES_PASSWORD
|
|
```
|
|
|
|
### Drop in self-hosted fonts (optional but recommended)
|
|
|
|
```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 softvowels-cms
|
|
```
|
|
|
|
The first time Payload starts it prints a one-time URL for the first admin user. Find it with:
|
|
|
|
```bash
|
|
docker compose logs softvowels-cms | grep -i "first user"
|
|
```
|
|
|
|
Open the URL in your browser to create the first admin user, then visit `https://cms.softvowels.example/admin` for the admin UI.
|
|
|
|
### Wipe everything (clean start)
|
|
|
|
```bash
|
|
docker compose down -v
|
|
docker volume ls -q | grep softvowels | xargs -r docker volume rm
|
|
```
|
|
|
|
## Updating the site after a content change
|
|
|
|
Until the on-publish webhook is wired up, the rebuild flow is manual:
|
|
|
|
```bash
|
|
cd /opt/softvowels
|
|
git pull
|
|
docker compose build softvowels-web
|
|
docker compose up -d softvowels-web
|
|
```
|
|
|
|
This rebuilds only the static site image and restarts the `softvowels-web` container. The CMS does not need to be rebuilt for content changes — only the web image does.
|
|
|
|
## Nginx Proxy Manager setup
|
|
|
|
NPM runs on the host. The three softvowels containers are already attached to the `nginx_proxy_manager` external network. Add two proxied hosts:
|
|
|
|
| Domain | Forward to | Scheme | Websockets |
|
|
|---|---|---|---|
|
|
| `softvowels.example` | `softvowels-web:80` | http | off |
|
|
| `cms.softvowels.example` | `softvowels-cms:3000` | http | on (Payload admin uses them) |
|
|
|
|
Both need: "Force SSL" + HSTS enabled.
|
|
|
|
NPM must be able to resolve `softvowels-web` and `softvowels-cms`. The `nginx_proxy_manager` network is `external: true` in `docker-compose.yml`; the easiest way to make NPM see the containers is to run NPM on the **same host** and add the network to its container:
|
|
|
|
```bash
|
|
docker network connect nginx_proxy_manager <npm-container-name>
|
|
```
|
|
|
|
## 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`.
|
|
|
|
After changing design tokens, rebuild the web image (it bakes the styles at build time):
|
|
|
|
```bash
|
|
docker compose build softvowels-web && docker compose up -d softvowels-web
|
|
```
|
|
|
|
## 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
|
|
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 softvowels-web # tail web container
|
|
docker compose logs -f softvowels-cms # tail Payload
|
|
docker compose restart softvowels-web # restart just the web
|
|
docker compose build softvowels-web # rebuild just the web image
|
|
docker volume ls | grep softvowels # list softvowels volumes
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
**`softvowels-cms` keeps restarting with `ERR_PNPM_NO_SCRIPT_OR_SERVER`.** The CMS uses Next.js standalone output (`next.config.ts: output: 'standalone'`). The runtime stage of `apps/cms/Dockerfile` runs `node server.js` directly — make sure the standalone build was actually produced (`docker compose build --no-cache softvowels-cms`).
|
|
|
|
**`/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; subsequent boots are fast.
|
|
|
|
**Editor in `/admin` can publish but the public site doesn't update.** That's expected — the on-publish webhook isn't wired up yet. Run the manual rebuild flow:
|
|
|
|
```bash
|
|
cd /opt/softwovls
|
|
git pull
|
|
docker compose build softvowels-web
|
|
docker compose up -d softvowels-web
|
|
```
|
|
|
|
**Public site returns 403 directory index forbidden.** The `softvowels-web` image wasn't built, or the build failed silently. Run `docker compose build --no-cache softvowels-web` and look for the `astro build` step output.
|
|
|
|
**Want to inspect the database.** Connect to it from any other container on the `nginx_proxy_manager` network:
|
|
|
|
```bash
|
|
docker run --rm -it --network nginx_proxy_manager postgres:16-alpine \
|
|
psql postgres://payload:$POSTGRES_PASSWORD@softvowels-db:5432/softvowels
|
|
```
|
|
|
|
**`/admin` first-user URL is in the past.** Payload's one-time URL expires. Restart the CMS to get a new one:
|
|
|
|
```bash
|
|
docker compose restart softvowels-cms
|
|
docker compose logs softvowels-cms | grep -i "first user"
|
|
```
|
|
|
|
---
|
|
|
|
Built with Astro 5, PayloadCMS 3, Tailwind v4, and Postgres 16. Runs in Docker.
|