12 KiB
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
- Repository layout
- Prerequisites
- Quick start (local dev)
- Docker deployment (homeserver)
- Updating the site after a content change
- 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 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 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.
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
# 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:
openssl rand -hex 32 # for PAYLOAD_SECRET
openssl rand -hex 24 # for POSTGRES_PASSWORD
Drop in self-hosted fonts (optional but recommended)
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 softvowels-cms
The first time Payload starts it prints a one-time URL for the first admin user. Find it with:
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)
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:
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:
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):
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 | 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
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:
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:
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:
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.