Skip to content

Serve with nginx

Serve a Reed build from nginx, at the root or under a base path, with correct Markdown types and caching.

This recipe is written from the nginx documentation and Reed’s output layout. It has not been run against a live server. After your first deploy, check two things: a .md mirror comes back as text/markdown, and a nested page URL such as /docs/ship/deploy/ resolves. The commands are in the last section.

nginx serves files from disk. You copy Reed’s build to the server and write a server block. Nothing runs per request, and nothing calls back to Reed. That is the whole guarantee on What you actually own.

nginx does not read _headers. The four things that file and other hosts handle for you become four pieces of configuration here: content types, directory index resolution, the 404 page, and caching.

Site at the root of its hostname. Copy the contents of dist/ to a directory and make that directory the root.

Site under a path, with base: "/docs". The built files sit at the root of dist/, but every URL starts with /docs/. You have two ways to line them up:

  • root over a staged tree. Copy dist/ into /var/www/site/docs/ and set root /var/www/site;. The path on disk matches the URL, just like the stage/ layout on Deploy. Use this when nginx serves only static files on that hostname, or when you want the same tree as your other hosts.
  • alias straight at the build. Copy dist/ to /var/www/reed-docs/ and map the location to it: location /docs/ { alias /var/www/reed-docs/; }. nginx replaces the /docs/ prefix with that path. Use this when / belongs to another application on the same server. Keep the trailing slash on both the location and the alias path.

The rest of this page uses root over a staged tree.

sh
reed build --out dist
rsync -a --delete dist/ server:/var/www/site/docs/

--delete removes files that are no longer in the build, so a page you deleted stops being served. _headers is copied along with everything else. nginx ignores it.

nginx
server {
listen 80;
server_name docs.example.com;
root /var/www/site;
charset utf-8;
charset_types text/html text/plain text/css text/markdown
application/javascript text/javascript;
location /docs/ {
try_files $uri $uri/ =404;
}
location ~ \.md$ {
types { }
default_type text/markdown;
}
location /docs/_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /docs/404.html;
gzip on;
gzip_vary on;
gzip_types text/css text/plain text/markdown application/javascript
text/javascript application/json image/svg+xml application/xml;
}

Add your TLS listen and certificate lines as usual. The sections below explain each part.

nginx picks a response type from its types map, usually the mime.types file shipped with it. That file has no entry for .md, so the mirrors get default_type. That is commonly application/octet-stream, and a browser downloads the file instead of showing it.

The location ~ \.md$ block fixes that. An empty types { } clears the map for that location, and default_type text/markdown then applies to every .md file. The nginx documentation uses the same pattern to force a type for a location.

You can instead add text/markdown md; to a types block at http level. Do not put a types block with only that line in a server block. A types block at a lower level replaces the inherited map instead of adding to it, and every other file loses its type.

Reed’s _headers asks for charset=utf-8 on both .md and llms.txt. Assistant fetchers reject a text/* response with no charset. See Response headers.

charset utf-8; makes nginx add the charset to the Content-Type of every type listed in charset_types. The default list includes text/plain, which covers llms.txt, but not text/markdown. That is why the example sets charset_types explicitly.

Reed writes each page as ship/deploy/index.html and links to it as /docs/ship/deploy/.

try_files $uri $uri/ =404 tries the exact file first, then the directory, then answers 404. When the directory form matches, nginx serves the index.html inside it. A request for /docs/ship/deploy without the slash is redirected to /docs/ship/deploy/, which is the form in Reed’s canonical URLs and sitemap.

.md mirrors and llms.txt are plain files. They match $uri directly.

Every build includes 404.html. On a based site it is at docs/404.html, so error_page 404 /docs/404.html; points there. The status stays 404. nginx serves the page body with it.

The page references its CSS and scripts with absolute /docs/... paths, so it renders correctly at any URL depth.

Everything under _astro/ has a content hash in its file name. A changed file gets a new name, so an old name never has new content. That makes these files safe to cache for a year and mark immutable.

Do not apply that header to HTML, .md, llms.txt, the sitemap or pagefind/. Their names stay the same between builds, so a long cache keeps readers on an old version. Leave them to nginx’s default ETag and Last-Modified revalidation.

One nginx rule to know: add_header directives are inherited from the enclosing level only if the current level has none of its own. If you add security headers at server level, repeat them inside location /docs/_astro/, or they disappear from those responses. nginx 1.29.3 and later also has add_header_inherit to change this.

gzip on compresses text/html always. Other types are compressed only if you list them in gzip_types. Include text/markdown and text/plain, since the mirrors and llms.txt are text meant to be fetched whole. gzip_vary on adds Vary: Accept-Encoding so shared caches keep compressed and plain copies apart.

If nginx sits behind another proxy, the proxy passes requests through and serves nginx’s headers. Nothing in Reed’s output depends on the proxy. Check that the proxy does not override Content-Type for .md, and that it does not cache HTML longer than you intend.

Check that a nested Markdown mirror has the right content type:

sh
curl -s -o /dev/null -w "%{content_type}\n" https://docs.example.com/docs/ship/deploy.md

Expect text/markdown; charset=utf-8. application/octet-stream means the .md location is not matching. A missing charset means charset_types does not list text/markdown.

Check that a nested page resolves, and that llms.txt has its charset:

sh
curl -s -o /dev/null -w "%{http_code}\n" https://docs.example.com/docs/ship/deploy/
curl -s -o /dev/null -w "%{content_type}\n" https://docs.example.com/docs/llms.txt

Expect 200 and text/plain; charset=utf-8. A 404 on the page usually means the files are at /var/www/site/ instead of /var/www/site/docs/.

Finally, request a URL that does not exist and confirm a 404 status with Reed’s page, then grep a live page for a string from this build, as Deploy describes.