This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

Self-host behind Nginx or IIS

This page covers serving 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, see Deploy to GitHub Pages first; this page is for when that route is unavailable.

Assumptions

  • 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).
  • Editing one nginx.conf server block or one web.config file is comfortable territory.

Steps

  1. 1

    Upload output/ to the web root

    Copy the full contents of output/ to the directory the web server will serve — /var/www/pennington/ for Nginx or the IIS site's Physical path for IIS. Keep the _content/ and _spa-data/ folders intact; fingerprinted assets and island payloads live under those underscore-prefixed paths and ship verbatim.

  2. 2

    Install the server config

    Drop the snippet below into the server's config location: Nginx reads its server block from /etc/nginx/sites-enabled/ or conf.d/; IIS reads web.config from the site root alongside index.html. Reload after writing — nginx -s reload for Nginx, iisreset or an app-pool recycle for IIS.

    # 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 falls back to the generated `404.html` page
    # when nothing matches.
    #
    # 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.
        location / {
            try_files $uri $uri/ /404.html;
        }
      
        # 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;
    }
    
    <?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>
    
  3. 3

    Serve directory indexes for trailing-slash URLs

    Pennington emits every content page as <slug>/index.html and every internal link with a trailing slash, so the server needs to resolve /guides/first-page/ by serving /guides/first-page/index.html. Nginx handles this with try_files $uri $uri/ /404.html and index index.html — both are already in the snippet above. IIS needs <defaultDocument> naming index.html plus a rewrite rule that 301-redirects /guides/first-page to /guides/first-page/ so the default-document rule can fire. Without either piece, visitors see a raw directory listing or a 404 on canonical URLs.

  4. 4

    Wire 404.html as the miss fallback

    During build, OutputGenerationService materializes a real 404.html at the root of output/ by rendering the path identified by NotFoundGeneratorPath. The web server's only job is to return that file with a 404 status on misses. Nginx handles this with try_files … /404.html; and error_page 404 /404.html;; IIS uses <httpErrors errorMode="Custom" existingResponse="Replace"> with <error statusCode="404" path="/404.html" responseMode="File" />. Both snippets already include this wiring.

    NotFoundGeneratorPath = "/__pennington-404-generator"
  5. 5

    Fix MIME types and cache headers for fingerprinted assets

    Nginx's default mime.types usually covers everything Pennington emits, but IIS ships without entries for .webmanifest and on some Windows SKUs .woff2, so the web.config above registers them explicitly. Both snippets also mark /_content/ fingerprinted assets as public, immutable with a one-year expiry — that cache contract is the reason _content/ paths include content hashes, so preserve it even if you trim the rest of the snippet. The sitemap and llms.txt are top-level files; the Nginx snippet sets default_type for them, and IIS's built-in .xml and .txt MIME entries cover them with no extra config.


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.