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 withdotnet run -- build /docs— see Host under a sub-path (base URL).
Steps
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.
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.
# 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 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:
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>/returns200 OKwithcontent-type: text/html; charset=utf-8and 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 viatry_files $uri/).curl -I https://<host>/definitely-not-a-pagereturns404 Not Foundand the body is the generated404.htmlrather than the server's default error page.
Related
- Recipe: Build a static site — what
build [baseUrl] [outputDirectory]produces before you copyoutput/onto the server. - Recipe: Host under a sub-path (base URL) — how
BaseUrlHtmlRewriterhandles a/docs/prefix when your Nginx or IIS site does not own the domain root. - Reference: CLI and build arguments — the
build [baseUrl] [outputDirectory]surface that produces theoutput/directory this page serves. - Background: Dev mode and build mode share one code path — motivates why
404.htmlis generated as a real HTTP response rather than a static template.