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

  • @markdown is a named matcher. It matches requests where the Accept header contains text/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_files picks 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 .html variants, 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