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

<Note>
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.
</Note>

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](/docs/ship/self-host).

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.

## Choose where the files live

**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](/docs/ship/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.

## 1. Put the build on the server

```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.

## 2. The server block

```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.

## Markdown mirrors are not downloads

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.

## The charset

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](/docs/ship/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.

## Directory pages and trailing slashes

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.

## The 404 page

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.

## Caching

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.

## Compression

`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.

## Behind a reverse proxy or CDN

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.

## Verify after the first deploy

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](/docs/ship/deploy) describes.

## Related

- [Cloudflare Workers](/docs/ship/hosts/cloudflare-workers) uses the same staged layout
  at the edge.
- [Vercel](/docs/ship/hosts/vercel) is the other host on these pages that needs the
  header rules translated.
- [Build output](/docs/ship/output) lists every file you are serving.
- [site and base](/docs/site/addressing) for what `base` changes.
- [llms.txt](/docs/derived/llms-txt) for why its content type matters.
