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 at the same URL with .md appended. 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.