Publishing

Docs Writing

Publishing

How a post gets from a Ghost draft to a deployed page, and what breaks when it doesn't.

The publication 無人之境 lives at /blog. Posts are authored in Ghost and rendered by this site at build time — Ghost is the editor and the database, but it never serves a visitor.

The path a post takes

  1. You publish in Ghost.
  2. Ghost fires its Post published webhook at a Cloudflare Workers Builds deploy hook.
  3. Cloudflare rebuilds the site Worker. During the build, the site fetches posts from the Ghost Content API and renders them into static HTML.
  4. The new Worker goes live. The post is now a prerendered page, an entry in /blog/rss.xml, and a row on the home page.

The consequence worth internalising: a post is not live until a build finishes. Editing in Ghost and refreshing /blog does nothing. If a change has not appeared, the question is always “did the deploy run”, not “did the cache expire”.

Configuration

Variable Where it must exist Why
PUBLIC_GHOST_URL Cloudflare build environment The Content API origin.
GHOST_CONTENT_API_KEY Cloudflare build environment Read access to published posts.

Both are read at build time, not at request time. Setting them as Worker runtime secrets alone is not enough — the pages are prerendered, so the fetch happens during the build or not at all.

Wiring the hook

  • In Cloudflare, create a Workers Builds deploy hook for the production branch.
  • In Ghost, point the Post published webhook at that URL.
  • Keep the event as Post published. A broader event fires builds for drafts.

Rendering

Ghost returns its own content contract (.kg-* cards: images, galleries, embeds, callouts, code). Those are restyled here rather than themed in Ghost, so the published post reads in this site’s typography and palette, in both light and dark. Code fences are rehighlighted, images get a blur-up placeholder, and YouTube and Apple Music embeds are replaced with local, privacy-preserving components.

The practical rule: write plain Ghost content and let this site style it. Custom HTML in a post will render, but it will not inherit the type scale and it will not adapt to the theme.

Unlisted posts

Use Ghost’s internal #unlisted tag when a post should work as a direct link without entering the publication’s discovery surfaces. Ghost exposes this tag with the slug hash-unlisted; the site treats that exact internal tag as the marker. A public post with any other tag remains listed.

The build keeps two post collections separate:

Collection Source Includes #unlisted posts Used by
Accessible getAccessiblePosts() Yes /blog/<slug> static paths and direct slug lookup
Listed getListedPosts() No Home and blog indexes, tag directories and archives, adjacent links, RSS, sitemap, Pagefind, palette data, llms.txt, and generated agent Markdown indexes

An unlisted post therefore has a stable URL, but readers must already have the URL. The article response emits noindex, nofollow, noarchive, nosnippet in the robots meta tag. The layout also marks the whole document with data-pagefind-ignore="all" and suppresses its text/markdown alternate link. The generated static Markdown asset is omitted; a direct request with Accept: text/markdown, or through <post URL>/index.md, renders at runtime and returns the same directives in X-Robots-Tag.

Do not remove the tag from a post and assume the page is immediately discoverable. The Ghost publish webhook starts a new site build, and the post enters listed surfaces only after that build deploys. The source of truth for this rule is src/features/posts/unlisted.ts; the collection split lives in src/features/posts/adapter/provider.ts.