---
title: Self-host behind Nginx or IIS
description: "Serve the generated `output/` directory from Nginx or IIS with pretty-URL rewrites and the generated `404.html` as the fallback."
canonical_url: https://usepennington.net/how-to/deployment/self-host/
sidecar_url: https://usepennington.net/how-to/deployment/self-host.md
content_hash: sha256:cd3aec0d2e6517ece19854fd9da072a0aec3e78a1bc26853735600d8ba2fc878
tokens: 2053
uid: how-to.deployment.self-host
reading_time_minutes: 3
---

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](https://usepennington.net/how-to/deployment/github-pages.md) is simpler.

 
## Before you begin

 
 - A built `output/` directory (see [Build a static site](https://usepennington.net/how-to/deployment/static-build.md)), 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)](https://usepennington.net/how-to/deployment/base-url.md).
 
 
---

 
## 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`.

 
```nginx:symbol
# 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:symbol
<?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)](https://usepennington.net/how-to/deployment/base-url.md)) 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.
 
 
## Related

 
 - Recipe: [Build a static site](https://usepennington.net/how-to/deployment/static-build.md) — what `build [baseUrl] [outputDirectory]` produces before you copy `output/` onto the server.
 - Recipe: [Host under a sub-path (base URL)](https://usepennington.net/how-to/deployment/base-url.md) — how `BaseUrlHtmlRewriter` handles a `/docs/` prefix when your Nginx or IIS site does not own the domain root.
 - Reference: [CLI and build arguments](https://usepennington.net/reference/host/cli.md) — the `build [baseUrl] [outputDirectory]` surface that produces the `output/` directory this page serves.
 - Background: [Dev mode and build mode share one code path](https://usepennington.net/explanation/core/dev-vs-build.md) — motivates why `404.html` is generated as a real HTTP response rather than a static template.
 
 
[Previous
                
                Adapt the deploy workflow for other hosts](https://usepennington.net/how-to/deployment/adapt-for-other-hosts.md)[Next
                    
                Host under a sub-path (base URL)](https://usepennington.net/how-to/deployment/base-url.md)