13 KiB
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
- Repository layout
- Prerequisites
- Quick start (local dev)
- Production build
- Docker deployment (homeserver)
- On-demand rebuilds (webhook flow)
- Nginx Proxy Manager setup
- Customising the design
- Self-hosting fonts
- Content model
- Useful commands
- Troubleshooting
Stack
| Layer | Technology |
|---|---|
| Frontend | Astro 5 — output: '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 | heroauto-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.