Files
2026-06-17 23:58:51 +05:30

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.