Files
softvowels-webpage/README.md
T
2026-06-17 23:58:51 +05:30

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

Layer Technology
Frontend Astro 5output: '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
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 | 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

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.