-
Notifications
You must be signed in to change notification settings - Fork 1
Recipe Reverse Proxy
For Rails/Django/Express/Next.js/anything-that-renders-HTML behind Caddy.
The static-first path doesn't apply — there's no root on disk — so the
plugin runs in capture-and-convert mode: it wraps the response,
captures the HTML, converts it, caches the result in memory (LRU only,
no disk sidecar), and serves the Markdown.
agent ──Accept: text/markdown──> Caddy ──HTML──> backend
│
└── convert + cache + serve as MD
The backend is unaware that the request is for Markdown. It always
receives an HTML request (the URL suffix and query param are stripped
before reverse_proxy sees them). Same URL, same auth, same routing.
example.com {
markdown_for_agents {
main_selector main
strip_selectors nav footer ".site-chrome"
}
reverse_proxy backend:8080
}That's it. No root. The module sees no root, infers dynamic mode,
and wraps reverse_proxy.
The dynamic path applies real HTTP caching semantics. A response is not cached if any of the following:
- Request was not
GETorHEAD - Request carried
AuthorizationorCookie(unless you setallow_authenticated) - Upstream sent
Set-Cookie - Upstream sent
Cache-Control: privateorno-store - Upstream sent a
Varyvalue other thanAccept-Encoding - Response body exceeded
max_body_bytes(default 4 MiB) - Response was an oversized entry (exceeded
cache_entry_bytes, default 1 MiB)
In all those cases the conversion still happens — the agent still gets Markdown — but the result is served once and discarded.
For the dynamic path the key is path + ?query. So:
/api/post?id=1
/api/post?id=2
…are independent entries. Query order isn't normalized, so prefer canonical query strings if your app permits.
Status code is 204, 3xx, or 5xx? Pass through unchanged.
Content-Type is anything except text/html? Pass through unchanged.
Image responses, JSON APIs, RSS feeds — none of them get touched.
The Authorization and Cookie checks catch the obvious cases. If your
app personalizes on other signals — mTLS client cert, X-Forwarded-User
from upstream auth, IP-based ACL — those are not automatically
considered cache-unsafe. Two options:
- Have the upstream set
Cache-Control: privateon personalized responses. The module honors it. - Run separate module instances per personalization dimension (e.g. one route per tenant).
The strip_selectors directive runs the same way as in static mode.
Concrete examples:
markdown_for_agents {
main_selector "#content"
strip_selectors ".navbar" ".flash" ".footer-links"
}
reverse_proxy rails:3000markdown_for_agents {
main_selector main
strip_selectors "header" "footer" ".cookie-consent"
}
reverse_proxy next:3000markdown_for_agents {
main_selector ".entry-content"
strip_selectors \
".wp-block-buttons" \
".sharedaddy" \
"#comments" \
".related-posts" \
nav \
footer
}
reverse_proxy wp:80If most of your routes are public but a few require login, the safe
default (cache-bypass on Cookie/Authorization) does the right thing
automatically — public agent traffic gets cached, authenticated traffic
doesn't.
If you've got a full SaaS where every request is authenticated and content is still identical for all users on a given URL (rare, but e.g. landing pages behind a marketing-site CDN gate), you can opt in:
markdown_for_agents {
allow_authenticated
}
reverse_proxy backend:8080Read Configuration carefully before flipping this. The wrong call here serves User A's session-rendered page to User B's agent.
For backends with long tail-latency responses, bump the timeout:
markdown_for_agents {
convert_timeout 15s
max_concurrent 32
}max_concurrent is a semaphore over conversions, not over upstream
requests. The upstream still sees its normal request rate; this only
limits how many HTML→Markdown passes run in parallel. Default is
max(4, NumCPU).
Start a fake backend for a smoke test:
docker run --rm -d --name fake -p 8080:80 nginxdemos/helloMinimal Caddyfile pointing at it:
:80 {
markdown_for_agents
reverse_proxy localhost:8080
}Then:
curl -sI http://localhost/ -H "Accept: text/markdown"
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
# ETag: "..."
curl http://localhost/ -H "Accept: text/markdown"
# (the nginxdemos hello page, rendered as Markdown)If your backend is just serving static files (e.g. a Hugo build), prefer the static-site recipe. The static-first path gives you:
- Persistent disk sidecars (capture-and-convert is in-memory only)
- Author-written
.mdprecedence -
mtime-based invalidation - Lower per-request overhead
Docs
Reports