Serving llms.txt and Markdown from Ghost: What Works, What Breaks
Ghost shipped built-in GEO tools in 6.43.0 (29 May 2026) and announced them that July. If you've been generating llms.txt by hand with a serverless function or a build script, you can stop. It's a toggle now.
But the feature has sharp edges that aren't in the changelog. One of them will serve raw Markdown to your human readers if you're behind a CDN. Below is what it serves, what breaks, and the version number that fixes it.
What Ghost gives you natively
The setting lives in Settings → Meta data. Flip it on and Ghost starts serving:
/llms.txt— an index of your public content at the root of your domain/llms-full.txt— every page and post inlined as one document.mdappended to any post or page URL — a Markdown version of that content
Ghost's own changelog is the reference implementation, since it runs on Ghost. Under https://ghost.org/changelog/:
/llms.txt → ~50 KB index
/llms-full.txt → ~1.4 MB full corpus
/geo.md → text/markdownNote the path. The changelog is its own Ghost install mounted at /changelog/, so the files sit under that prefix. ghost.org/llms.txt itself is a 404. Yours will be at your own root.
The .md output isn't just the body. Ghost prepends a metadata block and a pointer back to the index:
> ## Content Index
> Fetch the complete content index at: https://ghost.org/changelog/llms.txt
> Use this file to discover other available public pages before exploring further.
# Optimize your site for AI search
- URL: https://ghost.org/changelog/geo/
- Published: 2026-07-16T18:01:16.000Z
- Updated: 2026-07-16T18:01:16.000Z
- Description: Give AI tools a clearer way to find and recommend your work
- Author: John O'Nolan
- Tags: NewThat header does real work: any agent landing on a single .md page is told where the full index lives.
Two caveats. Only public content is included: members-only and paid posts appear with their public title and excerpt, but the gated body is never exposed, and private sites don't serve these files at all. And the feature is off by default for existing sites, so nothing changed under you if you didn't touch it.
Self-hosting? Check what you're on before anything else:
curl -s https://yoursite.com/ | grep -o 'content="Ghost [0-9.]*"'The cache bug you need to check for
This is the part nobody tells you.
When llms_enabled is on, affected versions don't only serve Markdown at the documented .md path. They also content-negotiate Markdown on the canonical post URL. A request to /some-post/ carrying an Accept: text/markdown header comes back as Content-Type: text/markdown. So does one carrying Accept: text/plain, which is the alarming part.
Content negotiation is a normal HTTP mechanism, so on its own this is fine. The danger is the header asymmetry documented in the forum bug report:
| Response | Vary | Cache-Control |
|---|---|---|
| HTML | Accept-Encoding | public, max-age=20 |
| Markdown | Accept, Accept-Encoding | public, max-age=3600 |
The Markdown variant declares its Accept dependency. The HTML variant doesn't. So any shared cache that doesn't split on Vary: Accept can store the Markdown response under the canonical URL's cache key, then serve a wall of raw Markdown to every human visitor hitting that POP.
This is not a Cloudflare quirk or an Enterprise-plan problem. Cloudflare does not consider Vary in caching decisions at all by default, on every plan. The exceptions are narrow: Vary: Accept-Encoding, the Pro-and-above "Vary for Images" feature, and, since July 2026, an explicit Vary setting in Cache Rules. Unless you have deliberately configured one of those, your cache ignores the Accept split.
Because Markdown holds the cache slot 180× longer than HTML, the broken state is the favoured outcome rather than a rare race. One crawler or script touching an edge node poisons it for the next hour.
It's also nearly impossible for a publisher to reproduce. The reader who saw it and the publisher checking the link hit different edge locations, so you refresh, see a perfectly normal article, and conclude they imagined it.
Test your own site
# Should return text/html. If it returns text/markdown, you're affected.
curl -sI -H 'Accept: text/plain' https://yoursite.com/some-post/ | grep -i content-typeRun it against a canonical post URL, not the .md one. On a patched site you'll see content-type: text/html; charset=utf-8 for both text/plain and text/markdown.
The fix
This was fixed upstream in Ghost PR #30259, "Stop Accept-header markdown on HTML permalinks", merged 25 August 2026. It first ships in Ghost 6.60.0. Anything earlier with the setting enabled is exposed.
Update to 6.60.0 or later. That's the real fix, and it's the only one that removes the behaviour rather than working around it.
On managed hosting this has almost certainly already happened. Checking as I write, both Ghost's own site and Magic Pages are on 6.6x and return text/html for either Accept header. Self-hosters are the exposed group, which matters if you're comparing where to run Ghost on 2026 prices.
If you can't update immediately, you have two CDN-level options on Cloudflare. The mitigation from the bug report is a Cache Rule with the action set to Bypass cache:
(any(http.request.headers["accept"][*] contains "text/markdown")) or (any(http.request.headers["accept"][*] contains "text/plain"))Header names must be lowercase, and both clauses are required: matching only text/markdown leaves the text/plain vector wide open.
The alternative, now that Vary in Cache Rules is available on all plans including Free, is to stop bypassing and start splitting: configure the Cache Rules Vary setting to include accept. That keeps both representations cacheable under separate keys instead of throwing away caching for those requests. It's the more correct fix of the two, and it wasn't an option when the bug was originally reported.
Either way, purge your cache after adding the rule, not before, or the purge gets re-poisoned instantly.
Where it falls short: custom templates
One open question, addressed in neither the bug report nor the changelog.
If you build custom Handlebars templates that assemble content at render time (a tags table pulled with {{#get 'tags'}}, a recommendations block, a documentation index, anything generated by the template rather than typed into the editor), that content has no obvious path into the .md and llms.txt output. The Markdown representation is derived from the post or page content, not from your rendered template.
This is measurable. Taking a Ghost page whose body is largely template-assembled:
| Representation | Words |
|---|---|
| Rendered HTML page | 3,443 |
Its .md output | 272 |
Navigation and footer chrome account for some of that HTML figure, but not for a 12× gap. The page an AI agent reads is a fraction of the page a human reads.
There's no theme-layer hook for this yet. The practical guidance until there is one:
- Put anything machine-critical in the editor content, not in the template. If a listing or table matters for AI discovery, it needs to exist as content.
- Treat template-generated blocks as decoration from a GEO standpoint. Navigation, related-post grids and tag clouds were never going to carry much weight anyway.
- Audit before assuming. Fetch
.mdfor your most important custom-template pages and count what actually comes back:
curl -s https://yoursite.com/your-page.md | wc -wIf you're a theme developer, design around this now rather than retrofitting later.
Should you turn it on?
For most publishers, yes, with two conditions. Update to 6.60.0 or later, and verify the curl check above returns HTML afterwards.
Be realistic about the payoff. No major AI provider has publicly documented that it consumes llms.txt, and Google has said it doesn't use it. The .md half stands on firmer ground: crawlers that already fetch your pages get clean Markdown instead of parsing your theme's markup. Treat it as cheap plumbing, not a ranking lever.
If you'd rather not feed AI systems at all, the toggle stays off and nothing is served. That's a legitimate position and Ghost treats it as one.
Quick checklist
- Check your version:
curl -s https://yoursite.com/ | grep -o 'content="Ghost [0-9.]*"' - Update to 6.60.0 or later — the release that includes PR #30259.
- Enable the setting in Settings → Meta data.
- Verify
/llms.txtand/llms-full.txtreturn content. - Verify
.mdworks on a post URL. - Run the
Accept: text/plaincurl check against a canonical URL — expecttext/html. - Word-count
.mdfor any custom-template page and check the output isn't hollow. - Confirm a members-only post exposes only title and excerpt.
The feature is useful and took about four minutes to enable. Steps 2 and 6 are the ones that will save you.