2026-06-17 22:48:09 +05:30
2026-06-17 22:48:09 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:48:09 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:48:09 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30
2026-06-17 22:37:47 +05:30

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

Layer Technology
Frontend Astro 5output: 'static', Tailwind v4, MDX, sitemap, RSS
Backend / CMS PayloadCMS 3 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 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.

# 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:

pnpm build         # builds apps/web (static)
pnpm build:cms     # builds apps/cms (Next.js production server)

Or build the containers directly:

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

# 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:

openssl rand -hex 32      # for PAYLOAD_SECRET
openssl rand -hex 24      # for REVALIDATE_TOKEN

Drop in self-hosted fonts

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

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:

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

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:

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:

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

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:

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:

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.

S
Description
No description provided
Readme 304 KiB
Languages
Astro 63%
TypeScript 23.9%
CSS 7.7%
JavaScript 2.2%
Dockerfile 2.1%
Other 1.1%