# 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 /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 ``` ## 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` | (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.