Nginx

Serve Markdown from the same URL as HTML using Nginx's map + try_files.

Nginx can content-negotiate without a downstream app. Pre-render each page to both .html and .md at build time, then let Nginx pick.

# Map the Accept header to a file extension we'll try first.
map $http_accept $preferred_ext {
    default                 ".html";
    "~*text/markdown"       ".md";
}

server {
    listen 443 ssl http2;
    server_name yoursite.com;
    root /var/www/yoursite;

    # Always tell caches the response depends on Accept.
    add_header Vary Accept always;

    location / {
        # Try the preferred extension at both the sibling-file layout
        # (/about.md) and the directory-index layout (/about/index.md),
        # then fall back to HTML. Without the second try, a request for
        # `/about/` with Accept: text/markdown silently falls through to
        # /about/index.html even when /about/index.md exists.
        try_files $uri$preferred_ext $uri/index$preferred_ext $uri.html $uri/index.html =404;
    }

    # Set Content-Type explicitly for .md (Nginx doesn't know it).
    location ~* \.md$ {
        default_type text/markdown;
        charset utf-8;
        add_header Vary Accept always;
    }
}

Notes

  • try_files falls through to .html if no .md exists — degrade gracefully rather than 404ing the Markdown-speakers.
  • add_header in a nested location block replaces the outer one; the duplication is intentional, not a copy-paste error.
  • For a true “no Markdown available, reject instead of falling back” setup, use two separate location blocks and return 406 from the Markdown branch when the file is missing.

Caveat: this is pragmatic, not strict

The map directive substring-matches the Accept header. That’s fine for the common case — almost no agent sends q=0 on text/markdown, and q-value tiebreaks rarely matter for a two-type produces list. But it technically means:

  • Accept: text/markdown;q=0, text/html still matches the markdown branch even though the client explicitly rejected Markdown.
  • Accept: text/html, text/markdown;q=0.1 still prefers markdown even though HTML has higher priority.

For strict handling, reverse-proxy the @markdown branch to a small application that parses Accept properly. See Accept parsing & q-values for the algorithm.

The lazy option

If your site is behind Cloudflare, skip all of this and flip on Markdown for Agents. It does the negotiation at the edge with no origin changes.