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 withdotnet run -- build /docs— see Host under a sub-path (base URL). - Editing one
nginx.confserver block or oneweb.configfile is comfortable territory.
Steps
- 1
Upload
output/to the web rootCopy 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
Install the server config
Drop the snippet below into the server's config location: Nginx reads its
serverblock from/etc/nginx/sites-enabled/orconf.d/; IIS readsweb.configfrom the site root alongsideindex.html. Reload after writing —nginx -s reloadfor Nginx,iisresetor 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
Serve directory indexes for trailing-slash URLs
Pennington emits every content page as
<slug>/index.htmland 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 withtry_files $uri $uri/ /404.htmlandindex index.html— both are already in the snippet above. IIS needs<defaultDocument>namingindex.htmlplus a rewrite rule that 301-redirects/guides/first-pageto/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
Wire
404.htmlas the miss fallbackDuring
build,OutputGenerationServicematerializes a real404.htmlat the root ofoutput/by rendering the path identified byNotFoundGeneratorPath. The web server's only job is to return that file with a 404 status on misses. Nginx handles this withtry_files … /404.html;anderror_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
Fix MIME types and cache headers for fingerprinted assets
Nginx's default
mime.typesusually covers everything Pennington emits, but IIS ships without entries for.webmanifestand on some Windows SKUs.woff2, so theweb.configabove registers them explicitly. Both snippets also mark/_content/fingerprinted assets aspublic, immutablewith 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 andllms.txtare top-level files; the Nginx snippet setsdefault_typefor them, and IIS's built-in.xmland.txtMIME entries cover them with no extra config.
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.