This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Self-host behind Nginx or IIS

Serve the generated `output/` directory from Nginx or IIS with pretty-URL rewrites and the generated `404.html` as the fallback.

Serve an output/ directory produced by dotnet run -- build from a server you control — a VPS running Nginx or a Windows host running IIS. When a managed static host is an option, Deploy to GitHub Pages is simpler.

Before you begin

  • A built output/ directory (see Build a static site), ready to copy onto the target server.
  • Root or administrator access to install a config file and reload the web server.
  • The site serves from the domain root. Sub-path deployments (https://host/docs/) require building with dotnet run -- build /docs — see Host under a sub-path (base URL).

Steps

1

Upload output/ to the web root

Copy the full contents of output/ to the directory the web server will serve — /var/www/pennington/output/ for Nginx (the path the snippet's root points at) or the IIS site's Physical path for IIS. Keep the _content/ folder intact; fingerprinted static-web-asset bundles (Razor library CSS and JS) live under that underscore-prefixed path and ship verbatim.

2

Install the server config

Drop the snippet for your server into its config location and reload. Both snippets cover trailing-slash directory indexes, the generated 404.html as the miss fallback (served with a real 404 status), and public, immutable cache headers on /_content/ fingerprinted assets. The IIS snippet also declares MIME types for .webmanifest and .woff2, which IIS does not know by default; Nginx serves those from its global mime.types include.

Nginx

Drop into /etc/nginx/sites-enabled/ (or conf.d/), then nginx -s reload.

nginx
# Self-host a Pennington static site behind Nginx.
#
# `root` points at the directory you uploaded from your CI (the
# contents of `output/`). `try_files $uri $uri/ =404` lets the directory
# index serve `<slug>/index.html` for every trailing-slash URL the
# DocSite layout emits, and returns a real 404 status when nothing
# matches; `error_page 404 /404.html` then serves the generated
# `404.html` body for that status.
#
# If you are serving under a sub-path (e.g. `https://host/docs/`), build
# the site with `dotnet run -- build /docs` *and* mount the `output`
# directory at that same sub-path using `location /docs/ { alias … }`.
  
server {
    listen 80;
    server_name _;
  
    root /var/www/pennington/output;
    index index.html;
  
    # Immutable fingerprinted assets.
    location /_content/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
  
    # Pennington writes every content page as `<slug>/index.html`, so
    # the directory-index fallback covers every canonical URL. A miss
    # returns `=404` (a real 404 status) rather than rewriting to
    # `/404.html`, which would serve the body with a 200; `error_page`
    # below then renders the generated `404.html` for that status.
    location / {
        try_files $uri $uri/ =404;
    }
  
    # DocSite serves `sitemap.xml` and `llms.txt` as top-level files.
    location = /sitemap.xml { default_type application/xml; }
    location = /llms.txt    { default_type text/plain;      }
  
    error_page 404 /404.html;
  
    # Security headers (not strictly required for static content but
    # worth having everywhere).
    add_header X-Content-Type-Options nosniff;
    add_header Referrer-Policy strict-origin-when-cross-origin;
}

IIS

Drop web.config into the site root alongside index.html, then run iisreset or recycle the app pool.

xml
<?xml version="1.0" encoding="utf-8"?>
<!--
  Self-host a Pennington static site behind IIS.
  
  Drop the contents of `output/` into the IIS site's physical path and
  this `web.config` alongside. The rewrite rule mirrors the Nginx
  `try_files` fallback: serve any directory index, otherwise serve the
  generated `404.html` with an HTTP 404 status.
  
  IIS does not know about `.webmanifest` or the `application/manifest+json`
  MIME type by default, so those are declared explicitly.
-->
<configuration>
  <system.webServer>
    <staticContent>
      <remove fileExtension=".json" />
      <mimeMap fileExtension=".json" mimeType="application/json" />
      <remove fileExtension=".webmanifest" />
      <mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
      <remove fileExtension=".woff2" />
      <mimeMap fileExtension=".woff2" mimeType="font/woff2" />
    </staticContent>
    <defaultDocument>
      <files>
        <clear />
        <add value="index.html" />
      </files>
    </defaultDocument>
    <httpErrors errorMode="Custom" existingResponse="Replace">
      <remove statusCode="404" />
      <error statusCode="404" path="/404.html" responseMode="File" />
    </httpErrors>
    <rewrite>
      <rules>
        <rule name="Pretty URL -> directory index" stopProcessing="true">
          <match url="^(.*[^/])$" />
          <conditions>
            <add input="{REQUEST_FILENAME}" matchType="IsDirectory" />
          </conditions>
          <action type="Redirect" url="{R:1}/" redirectType="Permanent" />
        </rule>
      </rules>
    </rewrite>
    <httpProtocol>
      <customHeaders>
        <add name="X-Content-Type-Options" value="nosniff" />
        <add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

Serve under a sub-path

When the site does not own the domain root — it lives at https://host/docs/ — build with the prefix (dotnet run -- build --base-url=/docs, see Host under a sub-path (base URL)) so every internal link carries it, then point the server at output/ under that same path.

For Nginx, mount the directory with alias (not root) inside a location block named for the prefix:

nginx
location /docs/ {
    alias /var/www/pennington/output/;
    try_files $uri $uri/ =404;
}

For IIS, host the site as an application or virtual directory named docs and drop the same web.config into its physical path — the rewrite and 404.html rules apply relative to the application root, so no changes are needed.

Verify

  • Reload the server, then curl -I https://<host>/ returns 200 OK with content-type: text/html; charset=utf-8 and the landing page renders in a browser.
  • curl -I https://<host>/guides/first-page/ returns 200; dropping the trailing slash still resolves (301 → 200 on IIS, 200 directly on Nginx via try_files $uri/).
  • curl -I https://<host>/definitely-not-a-page returns 404 Not Found and the body is the generated 404.html rather than the server's default error page.