diff --git a/.env.example b/.env.example index c154749..c837acf 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ # Required POSTGRES_PASSWORD=change-me-strong-password PAYLOAD_SECRET=change-me-to-a-very-long-random-string -REVALIDATE_TOKEN=change-me-shared-token # Hostnames (used to build CORS / canonical URLs) WEB_HOSTNAME=softvowels.example diff --git a/AGENTS.md b/AGENTS.md index 90abe4b..42a32f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,23 +4,7 @@ A monorepo with two apps: ``` apps/web Astro 5 (static site, Tailwind v4) -apps/cms Payload 3 (admin UI + REST/GraphQL) -``` - -## Package manager - -`pnpm` 9. From the repo root: - -```bash -pnpm install # install both -pnpm dev # dev:web -pnpm dev:web # Astro only -pnpm dev:cms # Payload only -pnpm build # build:web -pnpm build:cms # build Payload -pnpm typecheck # Astro check -pnpm format # prettier -pnpm format:check # prettier --check +apps/cms Payload 3 (admin UI + REST/GraphQL, Next.js 15) ``` ## Source paths @@ -31,7 +15,8 @@ pnpm format:check # prettier --check - `apps/web/src/styles/` — `global.css` (Tailwind v4 `@theme` tokens) and `prose.css` - `apps/web/public/fonts/` — drop `Geist-Variable.woff2` + `GeistMono-Variable.woff2` here - `apps/cms/src/collections/` — Payload collections -- `apps/cms/src/hooks/` — Payload hooks (e.g. `revalidateAstro`) +- `apps/cms/src/hooks/` — Payload hooks +- `apps/cms/src/lib/` — Payload helpers (e.g. `withRevalidate`) ## Code style @@ -42,46 +27,87 @@ pnpm format:check # prettier --check - Prefer `border border-border-subtle rounded-md` over arbitrary values to stay on the 4px baseline. - For code blocks in MDX / Lexical output, the article body uses `prose-article` (see `prose.css`). -## Lint / typecheck +## Lint / typecheck (web app) ```bash -pnpm typecheck # astro check -pnpm format:check +cd apps/web +npx astro check +npx prettier --check "src/**/*.{ts,astro,css}" ``` There is no separate ESLint run; the project's quality bar is enforced by `astro check` and Prettier. +## Local development (optional, not required for homeserver deploy) + +If you have Node + pnpm on your dev machine: + +```bash +pnpm install +pnpm dev:web # Astro on :4321 +pnpm dev:cms # Payload on :3000 +``` + +The homeserver deploy does **not** require Node or pnpm on the host — Docker builds both images from source. + ## Build outputs -- `apps/web/dist/` is bind-mounted into the `web` nginx container (`./apps/web/dist:/usr/share/nginx/html:ro`). The host-side webhook rewrites this folder on demand; the nginx container does not need to be rebuilt. -- `apps/cms/dist/` is built by Payload (Next.js) and runs as the CMS Node process. +- `apps/web/dist/` is generated by `astro build` inside the `softvowels-web` Docker image. Baked into the image at `/usr/share/nginx/html/`. +- `apps/cms/.next/standalone/` is generated by `next build` (with `output: 'standalone'`) inside the `softvowels-cms` Docker image. Baked into the image and run via `node server.js`. +- `apps/cms/media/` (uploaded files) lives in a named Docker volume (`softvowels-cms-uploads`), not the image. -## Deploy (homeserver) +## Deploy (homeserver, Docker only) -1. Copy the repo to `/opt/softvowels` on the server. -2. `cp .env.example .env` and fill in the secrets. -3. `docker compose up -d --build`. -4. In Nginx Proxy Manager add two proxied hosts: - - `softvowels.example` → `web:80` (force SSL, HSTS) - - `cms.softvowels.example` → `cms:3000` (force SSL, HSTS) -5. On the host, install the rebuild webhook: - ```bash - sudo cp scripts/softvowels-webhook.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl enable --now softvowels-webhook - ``` - Make sure `REBUILD_TOKEN` in `/etc/systemd/system/softvowels-webhook.service` matches `REVALIDATE_TOKEN` in `.env`. +The host needs Docker and Docker Compose v2. Nothing else. -## Revalidate flow +```bash +# 1. Get the code on the server +git clone /opt/softvowels +cd /opt/softvowels -1. Editor publishes in `/admin`. -2. Payload `afterChange` hook POSTs to `http://host.docker.internal:4321/__revalidate?secret=…&collection=…&op=…`. -3. The host-side Node webhook spawns `scripts/rebuild.sh`, which `git pull`s and runs `pnpm --filter web build`. -4. nginx container now serves the new `apps/web/dist/`. +# 2. Configure environment +cp .env.example .env +$EDITOR .env # fill in POSTGRES_PASSWORD, PAYLOAD_SECRET, hostnames + +# 3. Build and start +docker compose up -d --build + +# 4. Watch the first boot +docker compose logs -f softvowels-cms +# On first run, Payload prints a one-time URL for creating the admin +# account. Open it, then visit https://cms.softvowels.example/admin. +``` + +### Updating the site (no webhook yet) + +Until the webhook is in place, each site update requires: + +```bash +cd /opt/softvowels +git pull +docker compose build softvowels-web +docker compose up -d softvowels-web +``` + +That's the entire "redeploy" for a content change. The CMS does not need rebuilding for content changes — only the web image. + +## Nginx Proxy Manager setup + +Add two proxied hosts in NPM: + +| Domain | Forward to | Notes | +|---|---|---| +| `softvowels.example` | `softvowels-web:80` (http) | Force SSL, HSTS, HTTP/2, websockets off | +| `cms.softvowels.example` | `softvowels-cms:3000` (http) | Force SSL, HSTS, websockets on (Payload admin uses them) | + +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 with: + +```bash +docker network connect nginx_proxy_manager +``` ## Self-hosting fonts -Drop these two files into `apps/web/public/fonts/`: +Drop these two files into `apps/web/public/fonts/` (before `docker compose build`): - `Geist-Variable.woff2` (https://github.com/vercel/geist-font — rename `GeistVF.woff2`) - `GeistMono-Variable.woff2` (rename `GeistMonoVF.woff2`) @@ -95,28 +121,8 @@ Root `.env` (used by `docker compose`): ``` POSTGRES_PASSWORD= PAYLOAD_SECRET= -REVALIDATE_TOKEN= -WEB_HOSTNAME= -CMS_HOSTNAME= +WEB_HOSTNAME= # e.g. softvowels.example +CMS_HOSTNAME= # e.g. cms.softvowels.example ``` -`apps/web/.env`: - -``` -PUBLIC_SITE_URL= -PUBLIC_PAYLOAD_API= -PAYLOAD_API_INTERNAL= -REVALIDATE_TOKEN= -``` - -`apps/cms/.env`: - -``` -PAYLOAD_SECRET= -DATABASE_URI= -PAYLOAD_PUBLIC_SERVER_URL= -SERVER_URL= -CORS_ORIGINS= -ASTRO_REVALIDATE_URL= -ASTRO_REVALIDATE_TOKEN= -``` +`REVALIDATE_TOKEN` was removed when the webhook was deferred. Re-add it to `.env` and `docker-compose.yml` when the on-publish rebuild is wired back in. diff --git a/README.md b/README.md index 5634d24..24ffcb6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # SoftVowels -> A premium tech publication. **Astro 5 (static)** frontend + **PayloadCMS 3** headless backend, deployed with **Docker** and fronted by **Nginx Proxy Manager**. +> 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 regenerated on demand and served as static HTML by nginx. +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 @@ -10,9 +10,8 @@ SoftVowels is a content-driven site for long-form engineering writing. Editors w - [Repository layout](#repository-layout) - [Prerequisites](#prerequisites) - [Quick start (local dev)](#quick-start-local-dev) -- [Production build](#production-build) - [Docker deployment (homeserver)](#docker-deployment-homeserver) -- [On-demand rebuilds (webhook flow)](#on-demand-rebuilds-webhook-flow) +- [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) @@ -25,105 +24,84 @@ SoftVowels is a content-driven site for long-form engineering writing. Editors w | Layer | Technology | |---|---| | Frontend | [Astro 5](https://astro.build/) — `output: 'static'`, Tailwind v4, MDX, sitemap, RSS | -| Backend / CMS | [PayloadCMS 3](https://payloadcms.com/) running on Next.js 15 | +| 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, serves `apps/web/dist/`) | +| 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 | -| Language / runtime | Node.js 20+, pnpm 9 | + +**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) +│ ├── 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 +│ │ │ ├── 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 +│ └── cms/ # PayloadCMS 3 admin │ ├── src/ -│ │ ├── collections/ # Articles, Authors, Categories, Tags, Editions, Subscribers, Media, Users -│ │ ├── hooks/ # revalidateAstro (afterChange → POST webhook) -│ │ └── app/ # Next.js routes -│ ├── Dockerfile +│ │ ├── 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/ -│ ├── rebuild.sh # git pull + pnpm --filter web build -│ ├── webhook.js # host-side Node webhook receiver -│ └── softvowels-webhook.service -├── docker-compose.yml # db + cms + web +├── 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 +├── 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) +That's it. No Node, no pnpm, no git on the host at runtime. + ## Quick start (local dev) -Local dev doesn't need Docker for the apps themselves; only Postgres is required. +Optional — only useful if you want to iterate on the source code on your dev machine. Production deployment is below. ```bash -# 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 +docker compose up -d softvowels-db 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: - -```bash -pnpm build # builds apps/web (static) -pnpm build:cms # builds apps/cms (Next.js production server) -``` - -Or build the containers directly: - -```bash -docker compose build -``` - -The static site is emitted to `apps/web/dist/` and is what the `web` nginx container serves. +> 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: `db`, `cms`, and `web`. +The repo ships a `docker-compose.yml` with three services, all on the external `nginx_proxy_manager` network: -### One-time setup on the server +| 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 @@ -141,18 +119,17 @@ $EDITOR .env |---|---| | `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: +Generate secrets with: ```bash openssl rand -hex 32 # for PAYLOAD_SECRET -openssl rand -hex 24 # for REVALIDATE_TOKEN +openssl rand -hex 24 # for POSTGRES_PASSWORD ``` -### Drop in self-hosted fonts +### Drop in self-hosted fonts (optional but recommended) ```bash curl -L -o apps/web/public/fonts/Geist-Variable.woff2 \ @@ -161,84 +138,60 @@ 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`.) +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 cms # watch the first migration +docker compose logs -f softvowels-cms ``` -The first time Payload starts it will print a one-time setup URL in the `cms` logs: +The first time Payload starts it prints a one-time URL for the first admin user. Find it with: ```bash -docker compose logs cms | grep -i "first user" +docker compose logs softvowels-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`. +Open the URL in your browser to create the first admin user, then visit `https://cms.softvowels.example/admin` for the admin UI. -## 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 +### Wipe everything (clean start) ```bash -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 +docker compose down -v +docker volume ls -q | grep softvowels | xargs -r docker volume rm ``` -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`). +## Updating the site after a content change -Logs land at `/var/log/softvowels-rebuild.log`. Tail with: +Until the on-publish webhook is wired up, the rebuild flow is manual: ```bash -sudo journalctl -u softvowels-webhook -f -tail -f /var/log/softvowels-rebuild.log +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. Add two proxied hosts: +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 | 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) | +| Domain | Forward to | Scheme | Websockets | +|---|---|---|---| +| `softvowels.example` | `softvowels-web:80` | http | off | +| `cms.softvowels.example` | `softvowels-cms:3000` | http | 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). +Both need: "Force SSL" + HSTS enabled. -If NPM is also in Docker, attach it: +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 softvowels-app +docker network connect nginx_proxy_manager ``` -### 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: @@ -255,6 +208,12 @@ 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/`: @@ -264,7 +223,7 @@ Drop the two variable woff2 files into `apps/web/public/fonts/`: | `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. +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 @@ -284,52 +243,47 @@ The Astro side renders Lexical richText to HTML using `@payloadcms/richtext-lexi ## Useful commands ```bash -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 +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 -**`pnpm install` complains about a broken `pnpm` binary on Windows.** Use `npm` inside each app folder instead: -```bash -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. +**`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`). -**`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; subsequent boots are fast. -**`/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: +**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 -ls -la apps/web/dist/index.html -docker compose exec web ls -la /usr/share/nginx/html/index.html +cd /opt/softwovls +git pull +docker compose build softvowels-web +docker compose up -d softvowels-web ``` -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.) +**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. +Built with Astro 5, PayloadCMS 3, Tailwind v4, and Postgres 16. Runs in Docker. diff --git a/apps/cms/.dockerignore b/apps/cms/.dockerignore new file mode 100644 index 0000000..a7ef789 --- /dev/null +++ b/apps/cms/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +dist +build +.cache +media +uploads +.env +.env.* +!.env.example +*.log diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 9c514b5..36fc179 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -1,4 +1,4 @@ -# Payload +# Payload (used for local dev via `pnpm dev:cms`) PAYLOAD_SECRET=change-me-to-a-long-random-string DATABASE_URI=postgres://payload:payload@localhost:5432/softvowels @@ -9,8 +9,4 @@ SERVER_URL=https://cms.softvowels.example # Origins allowed to call the API (Astro public site) CORS_ORIGINS=https://softvowels.example,http://localhost:4321 -# Internal rebuild webhook -ASTRO_REVALIDATE_URL=http://host.docker.internal:4321/__revalidate -ASTRO_REVALIDATE_TOKEN=change-me - PORT=3000 diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile index 18f5a1c..097b0e5 100644 --- a/apps/cms/Dockerfile +++ b/apps/cms/Dockerfile @@ -1,17 +1,23 @@ # syntax=docker/dockerfile:1.7 # # PayloadCMS production image. Built and run by docker-compose. -# We install pnpm globally via npm so we don't depend on corepack's -# (sometimes strict) version negotiation. +# +# We use Next.js `output: 'standalone'` so the runtime stage doesn't have +# to ship the entire node_modules tree — Next analyses the code, picks +# only the files that are actually required at runtime, and bundles them +# under .next/standalone/. This sidesteps the pnpm content-addressable +# store / bin-link issues we hit with the workspace install. +# +# We install pnpm via `npm install -g pnpm@9` instead of corepack so +# the version negotiation is unambiguous across Node 20 minor releases. FROM node:20-alpine AS base RUN npm install -g pnpm@9 WORKDIR /repo # ---- deps ----------------------------------------------------------------- -# Install ALL workspace deps at the root so the `apps/cms` package can -# resolve its siblings (@payloadcms/*, next, react, etc.) and the root -# tooling (prettier) is also available. +# Install ALL workspace deps at the root so apps/cms can resolve its +# siblings (@payloadcms/*, next, react, etc.). FROM base AS deps COPY pnpm-workspace.yaml package.json ./ COPY apps/cms/package.json ./apps/cms/package.json @@ -29,11 +35,25 @@ FROM deps AS build COPY apps/cms ./apps/cms WORKDIR /repo/apps/cms RUN pnpm run build +# Some apps don't ship a `public/` dir; ensure one exists so the +# runtime COPY below doesn't fail. +RUN mkdir -p /repo/apps/cms/public # ---- runtime -------------------------------------------------------------- -FROM base AS runtime +# Compose the self-contained runtime image from the standalone output. +# - .next/standalone/ contains the server.js entrypoint and a trimmed +# node_modules tree with only the files Next determined are needed. +# - .next/static/ is the hashed client-side bundles; standalone +# does NOT include it, so we copy it over manually. +# - public/ static assets served from the web root (may be +# absent; the COPY is guarded). +FROM node:20-alpine AS runtime ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 WORKDIR /repo/apps/cms -COPY --from=build /repo ./ +COPY --from=build /repo/apps/cms/.next/standalone ./ +COPY --from=build /repo/apps/cms/.next/static ./.next/static +COPY --from=build /repo/apps/cms/public ./public EXPOSE 3000 -CMD ["pnpm", "start"] +CMD ["node", "server.js"] diff --git a/apps/cms/next.config.ts b/apps/cms/next.config.ts index 0abf21c..f47ebd3 100644 --- a/apps/cms/next.config.ts +++ b/apps/cms/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactStrictMode: true, + output: 'standalone', experimental: { reactCompiler: false, }, diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..a205896 --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.astro +.cache +.env +.env.* +!.env.example +*.log diff --git a/apps/web/.env.example b/apps/web/.env.example index 55ae2d6..6b0e811 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,7 +5,4 @@ PUBLIC_SITE_URL=https://softvowels.example PUBLIC_PAYLOAD_API=https://cms.softvowels.example # Internal Payload base URL used at build time (container-to-container) -PAYLOAD_API_INTERNAL=http://cms:3000 - -# Token required to call the /__revalidate webhook -REVALIDATE_TOKEN=change-me +PAYLOAD_API_INTERNAL=http://softvowels-cms:3000 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index ea2a387..ee7a17d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,15 +1,17 @@ # syntax=docker/dockerfile:1.7 # -# Single-shot build image for the Astro site. The production deployment uses -# nginx:1.27-alpine with a bind mount to apps/web/dist (see docker-compose.yml), -# so the webhook can rewrite the site without rebuilding any image. +# Astro static site production image. Built and run by docker-compose. # -# You only need this Dockerfile if you want a self-contained static site -# image (e.g. for a registry). For the normal homeserver flow, this image -# is never built. +# Multi-stage: +# deps - installs the workspace (only apps/web and apps/cms are +# needed for the web build, but we keep apps/cms/package.json +# so the workspace resolves cleanly). +# build - runs `astro build` to produce apps/web/dist. +# runtime - small nginx:1.27-alpine serving the baked-in dist/ and +# the custom nginx-default.conf. # -# We install pnpm globally via npm to avoid corepack's strict version -# negotiation across Node 20 minor versions. +# We install pnpm via `npm install -g pnpm@9` instead of corepack so +# the version negotiation is unambiguous across Node 20 minor releases. FROM node:20-alpine AS base RUN npm install -g pnpm@9 @@ -30,7 +32,7 @@ RUN if [ -f pnpm-lock.yaml ]; then \ FROM deps AS build ENV PUBLIC_PAYLOAD_API=https://cms.softvowels.example ENV PUBLIC_SITE_URL=https://softvowels.example -ENV PAYLOAD_API_INTERNAL=http://cms:3000 +ENV PAYLOAD_API_INTERNAL=http://softvowels-cms:3000 COPY apps/web ./apps/web WORKDIR /repo/apps/web RUN pnpm run build diff --git a/apps/web/src/components/ui/Button.astro b/apps/web/src/components/ui/Button.astro index 85ec7ee..737b813 100644 --- a/apps/web/src/components/ui/Button.astro +++ b/apps/web/src/components/ui/Button.astro @@ -27,13 +27,13 @@ const { const base = 'inline-flex items-center justify-center gap-2 font-label-md rounded-md transition-all duration-200 select-none disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'; -const sizes: Record = { +const sizes: Record = { sm: 'h-9 px-4 text-[13px]', md: 'h-10 px-5 text-sm', lg: 'h-12 px-7 text-label-md', }; -const variants: Record = { +const variants: Record = { primary: 'bg-primary text-white hover:brightness-110', secondary: 'bg-white text-on-surface border border-border-subtle hover:border-primary', ghost: 'text-primary hover:bg-primary/5', diff --git a/apps/web/src/components/ui/Card.astro b/apps/web/src/components/ui/Card.astro index 51e5b49..efeb89d 100644 --- a/apps/web/src/components/ui/Card.astro +++ b/apps/web/src/components/ui/Card.astro @@ -17,11 +17,11 @@ const { ariaLabel, } = Astro.props; -const surfaces: Record = { +const surfaces: Record, string> = { raised: 'bg-surface-raised', flat: 'bg-surface-container', }; -const pads: Record = { +const pads: Record, string> = { none: '', sm: 'p-4', md: 'p-6', diff --git a/apps/web/src/components/ui/Chip.astro b/apps/web/src/components/ui/Chip.astro index 33506c2..4022ca7 100644 --- a/apps/web/src/components/ui/Chip.astro +++ b/apps/web/src/components/ui/Chip.astro @@ -11,7 +11,7 @@ interface Props { const { variant = 'solid', href, class: className = '', ariaLabel } = Astro.props; const base = 'inline-flex items-center gap-1.5 rounded-full font-label-md tracking-wider uppercase text-[11px] px-2.5 py-1 transition-colors duration-200'; -const variants: Record = { +const variants: Record = { solid: 'bg-primary text-white', outline: 'border border-primary/40 text-primary', soft: 'bg-primary/10 text-primary', diff --git a/apps/web/src/components/ui/Icon.astro b/apps/web/src/components/ui/Icon.astro index b0bbda3..b980499 100644 --- a/apps/web/src/components/ui/Icon.astro +++ b/apps/web/src/components/ui/Icon.astro @@ -10,7 +10,7 @@ interface Props { } const { name, size = 'md', class: className = '', filled = false, ariaLabel } = Astro.props; -const sizes: Record = { +const sizes: Record, string> = { sm: 'text-[18px]', md: 'text-[20px]', lg: 'text-[24px]', diff --git a/docker-compose.yml b/docker-compose.yml index 4ba24de..c10184d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,19 +4,23 @@ name: softvowels # SoftVowels — production docker-compose # # This stack publishes a static site (Astro) and a PayloadCMS -# admin panel. Both attach to the homeserver's existing -# `nginx_proxy_manager` network so Nginx Proxy Manager can -# reverse-proxy them with TLS. +# admin panel. All three containers attach to the homeserver's +# existing `nginx_proxy_manager` network so Nginx Proxy Manager +# can reverse-proxy them with TLS. # # - softvowels-db Postgres 16 -# - softvowels-cms Payload admin + REST/GraphQL API (Node 20) -# - softvowels-web nginx serving the static site (Astro build) +# - softvowels-cms Payload admin + REST/GraphQL API (Node 20, +# Next.js standalone output) +# - softvowels-web nginx serving the static site (Astro build, +# dist baked into the image) # -# How to access: +# How to access (after `docker compose up -d --build`): # 1. https://softvowels.example -> softvowels-web:80 # 2. https://cms.softvowels.example -> softvowels-cms:3000/admin -# Add both as "Proxy Hosts" in Nginx Proxy Manager, pointing -# to the matching container name + port. See README. +# Add both as "Proxy Hosts" in Nginx Proxy Manager, pointing to +# the matching container name + port. See README. +# +# Host requirements: Docker only. No Node, no pnpm, no git needed. # ---------------------------------------------------------------- services: @@ -55,27 +59,22 @@ services: PAYLOAD_PUBLIC_SERVER_URL: https://${CMS_HOSTNAME} SERVER_URL: https://${CMS_HOSTNAME} CORS_ORIGINS: https://${WEB_HOSTNAME},http://localhost:4321 - ASTRO_REVALIDATE_URL: http://host.docker.internal:4321/__revalidate - ASTRO_REVALIDATE_TOKEN: ${REVALIDATE_TOKEN} + # media uploads persist in a named volume mounted at the same + # path Next standalone's CWD expects. Payload's Media collection + # uses `staticDir: 'media'` relative to apps/cms. volumes: - - softvowels-cms-uploads:/app/apps/cms/media + - softvowels-cms-uploads:/repo/apps/cms/media networks: - nginx_proxy_manager - # The public site. We deliberately do NOT bake dist/ into the image: - # scripts/rebuild.sh (run by the host-side webhook) writes fresh HTML to - # ./apps/web/dist on the host, and we mount that path into nginx. That way - # publishing in /admin → new content is live within ~30-60s without any - # docker build / image push step. softvowels-web: - image: nginx:1.27-alpine + build: + context: . + dockerfile: ./apps/web/Dockerfile container_name: softvowels-web restart: unless-stopped depends_on: - softvowels-cms - volumes: - - ./apps/web/dist:/usr/share/nginx/html:ro - - ./apps/web/nginx-default.conf:/etc/nginx/conf.d/default.conf:ro networks: - nginx_proxy_manager