Caddy
Caddy's named matchers make content negotiation concise. Serve pre-rendered .md files to agents and HTML to browsers from the same URL.
Caddy has first-class support for matching on request headers, which
makes Accept-based routing a few lines of config. This recipe assumes
you’ve pre-rendered both .html and .md variants of each page at
build time.
Caddyfile
yoursite.com {
root * /var/www/yoursite
@markdown header Accept *text/markdown*
handle @markdown {
# `{path}index.md` (no slash) resolves both layouts:
# /about → /about.md
# /about/ → /about/index.md
# / → /index.md
try_files {path}.md {path}index.md
header Content-Type "text/markdown; charset=utf-8"
header Vary Accept
file_server
}
handle {
header Vary Accept
try_files {path} {path}.html {path}/index.html
file_server
}
}
How it works
@markdownis a named matcher. It matches requests where the Accept header containstext/markdown(the*on each side is a substring glob). This is the rare case where substring-matching Accept is acceptable — Caddy handles the header value as a plain string, and you’re checking for presence, not precedence.handle @markdown— if that matches,try_filespicks the first existing Markdown file (sibling or directory index), sets the right Content-Type and Vary, and serves it.handle {}(no matcher) — the fallback. Set Vary, try the path plus.htmlvariants, serve HTML.- Both branches set
Vary: Accept, so CDNs downstream cache correctly.
A more correct matcher
The simple header matcher above doesn’t respect q-values. If a client
sends Accept: text/markdown;q=0, text/html (explicitly rejecting
Markdown), it would still match. For stricter handling, use a
request_matcher module or proxy to an app that parses Accept properly.
In practice, nobody sends Accept: text/markdown;q=0 — clients only
use q=0 when explicitly rejecting, which for Markdown is rare. The
substring match is pragmatic.
If you don’t pre-render
If your Markdown doesn’t exist as a file, reverse-proxy the
@markdown branch to a small service that converts HTML on the fly,
or use Cloudflare’s Markdown for Agents
at the edge and skip Caddy negotiation entirely.
handle @markdown {
reverse_proxy localhost:3001 {
header_up Accept "text/markdown"
}
header Vary Accept
}
Verify
curl -sI -H "Accept: text/markdown" https://yoursite.com/article
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
curl -sI -H "Accept: text/html" https://yoursite.com/article
# Content-Type: text/html; charset=utf-8
# Vary: Accept