Publishing to GitHub Pages

Netdocs builds a plain static site, so it deploys anywhere that serves files. The repository ships a GitHub Actions workflow that builds this documentation site and publishes it to GitHub Pages.

The workflow

.github/workflows/docs.yml builds the CLI, runs netdocs build against docs-site/appsettings.json, and uploads the output as a Pages artifact:

name: Docs

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # git-revision-date needs full history

      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json

      - name: Build docs
        run: dotnet run --project src/Netdocs.Cli -c Release -- build --prod --config docs-site/appsettings.json

      - uses: actions/upload-pages-artifact@v3
        with:
          path: docs-site/site

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

One-time setup

  1. In the repository Settings → Pages, set Source to GitHub Actions.
  2. Set siteUrl in docs-site/appsettings.json to your Pages URL (e.g. https://<user>.github.io/<repo>/).
  3. Push to main — the workflow builds and deploys automatically. You can also trigger it manually via Run workflow (workflow_dispatch).

Publishing your own site

Use the same pattern: point the --config at your site's appsettings.json and upload its siteDir. For a project-page URL under a subpath, make sure siteUrl includes the subpath so links, the sitemap, and social cards resolve correctly.

Using the Netdocs GitHub Action

If your site lives in its own repository (i.e. you are not building from the Netdocs source tree), you don't need .NET on the runner at all. The reusable XtremeOwnage/Netdocs action downloads the matching native netdocs binary from the releases page and runs it for you:

name: Docs

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # git-revision-date needs full history

      - name: Build with Netdocs
        uses: XtremeOwnage/Netdocs@v1
        with:
          command: build
          config: appsettings.json
          args: --prod
          # version: 1.0.0   # pin a release, or omit for 'latest'

      - uses: actions/upload-pages-artifact@v3
        with:
          path: site

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

Inputs:

Input Default Description
command build Netdocs subcommand (build, serve, import).
config (none) Path to the config file; passed as --config when set.
version latest Release version to download (e.g. 1.0.0), or latest.
args (none) Extra arguments appended verbatim (e.g. --prod --strict).
working-directory . Directory to run netdocs in.

The action runs on Linux, Windows, and macOS runners (x64, plus Apple Silicon). Pin to a major tag like @v1 for stability, or a full version tag for reproducible builds.

Any AWS credentials already configured on the runner are forwarded into the build, so a preceding aws-actions/configure-aws-credentials step is enough to authenticate plugins that read from S3 — imported docs pull sources, for example.

Built-in deploy targets

Instead of wiring up a workflow, netdocs can publish the build itself. Add a deploy section to the Netdocs block of appsettings.json and run netdocs deploy (which builds first, then publishes), or netdocs build --deploy.

Filesystem

Copy the built site to a local (or mounted) directory — handy for nginx web roots or a synced folder:

{
  "Netdocs": {
    "deploy": {
      "target": "filesystem",
      "path": "/var/www/docs",
      "clean": true
    }
  }
}
  • path — destination directory (absolute, or relative to the project root).
  • clean — when true (default), files at the destination that the build no longer produces are pruned.

Git branch

Publish the output to a branch (e.g. gh-pages) using a temporary git worktree, so your main working tree is never touched:

{
  "Netdocs": {
    "deploy": {
      "target": "git",
      "branch": "gh-pages",
      "remote": "origin",
      "message": "Deploy docs",
      "push": true
    }
  }
}
  • The branch is created (as an orphan) on first deploy if it does not exist.
  • Set push to false to commit locally without pushing.
  • Requires git on PATH and the project to be inside a git repository.
# Build and publish in one step
netdocs deploy --config appsettings.json

AWS S3

Sync the output to an S3 bucket for an AWS-hosted static site. This shells out to the AWS CLI (aws s3 sync), so credentials and region are resolved the standard AWS way (environment variables, ~/.aws/config, or an instance role):

{
  "Netdocs": {
    "deploy": {
      "target": "s3",
      "bucket": "my-docs-bucket",
      "prefix": "docs",
      "region": "us-east-1",
      "clean": true,
      "gzip": true
    }
  }
}
  • bucket is required; prefix (optional) publishes under a sub-path of the bucket.
  • clean: true passes --delete so objects no longer produced by the build are removed.
  • region is optional — omit it to use the AWS CLI's configured default.
  • Requires the AWS CLI (aws) on PATH.

Compressing text assets (gzip)

S3 serves an object as exactly the bytes it stores. Unlike GitHub Pages, it will not compress on the fly, so a site's text assets go over the wire uncompressed — and the one that hurts is search/search_index.json, which every visitor downloads in full the moment they open search. On this documentation site that file is 529 KB raw and 99 KB gzipped: 5.4× more traffic to fetch the same index from S3 than from Pages, and the gap grows with the size of the site.

"gzip": true stores those assets compressed. The deploy runs two syncs: one for everything else, then one for .json, .css, .js, .mjs, .html, .xml, .svg, .txt and .map files, uploaded gzipped with Content-Encoding: gzip. The build output on disk is untouched — the compressed copies are staged in a temporary directory — so serving the site locally still works.

A pre-compressed object is served compressed to everyone

S3 returns the stored bytes and the Content-Encoding: gzip header regardless of whether the client sent Accept-Encoding: gzip. Every browser handles that; a script pulling an object straight out of the bucket may not, which is why this is off by default.

If search does nothing on an S3-hosted site, check CORS first

Every other asset on the page — CSS, JS, images — is loaded by a <link> or <script> tag, which the browser will happily follow across origins. The search index is the one asset fetched with XHR, and that is subject to CORS. So if your bucket sits behind anything that redirects to a presigned S3 URL on another origin, the page loads perfectly and only search breaks — silently, with no visible error except a CORS error in the network panel:

search_index.json                          302  xhr / Redirect
search_index.json?AWSAccessKeyId=…&Expires=…     CORS error

The giveaway is that the redirected response comes back from Server: AmazonS3 with no Access-Control-Allow-Origin header. Fix it on the bucket, not in the site:

{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://docs.example.com"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["*"],
      "MaxAgeSeconds": 86400
    }
  ]
}
aws s3api put-bucket-cors --bucket my-docs-bucket --cors-configuration file://cors.json

# verify — this must echo your origin back
curl -sI -H "Origin: https://docs.example.com" <the redirected URL> | grep -i access-control

Serving the object from the site's own origin instead (no cross-origin redirect) avoids the issue entirely, since CORS never comes into play.

Behind CloudFront

CloudFront's own automatic compression only applies to objects up to a size limit, so a large search index can silently fall back to being served uncompressed as a site grows. Storing the asset already compressed sidesteps that entirely.

Optimization

Enable HTML minification to shrink emitted pages (whitespace collapse + comment removal, preserving pre/code/script/style), plus optional CSS and JavaScript minification of copied assets:

{
  "Netdocs": {
    "optimize": {
      "minifyHtml": true,
      "minifyCss": true,
      "minifyJs": true,
      "convertImagesToWebp": true,
      "webpQuality": 80
    }
  }
}

minifyCss/minifyJs strip comments and collapse whitespace in .css/.js assets as they are copied into the output. The minifier is conservative — it never renames identifiers or reorders rules, and it preserves string, template, and URL contents verbatim. Files that are already minified (*.min.css, *.min.js) are copied through untouched.

WebP image conversion

convertImagesToWebp emits a .webp sibling next to every copied .png/.jpg/.jpeg image and rewrites local <img> references into a <picture> element:

<picture>
  <source srcset="images/diagram.webp" type="image/webp">
  <img src="images/diagram.png" alt="Diagram">
</picture>

This is non-destructive — the original raster file is kept and referenced as the <img> fallback, so browsers without WebP support still render correctly while modern browsers download the smaller WebP. webpQuality (1–100, default 80) controls the encoder quality. Conversions are cached by source content hash under .cache/webp/, so unchanged images are not re-encoded on subsequent builds. Remote images (http://, https://, //, data:), SVGs, and images already in WebP format are left untouched.

An <img> is only wrapped when the matching .webp was actually produced for that page's src. Images that cannot be decoded, that live outside the output, or that resolve somewhere else keep their plain <img> tag — a <source> pointing at a file that does not exist would break the image in every WebP-capable browser. A <picture> you wrote yourself is left alone rather than nested inside another one, and a cache-busting ?query on the src is carried over to the generated srcset.

Incremental builds and deploys

Netdocs avoids redundant work through content-hash caches rather than a bespoke deploy manifest:

  • Render cache (.cache/render.json) keys each page's rendered HTML/TOC on its processed markdown, the pipeline configuration, and the link map. Unchanged pages skip the expensive parse/render step on the next build.
  • WebP cache (.cache/webp/) keys converted images on source content hash + quality, so unchanged images are never re-encoded.
  • Deploy transfer is already incremental at the backend: the git branch target is content-addressed and only commits changed blobs, and the S3 target uses aws s3 sync, which compares size/modified time and uploads only changed objects.

Because the deploy backends already skip unchanged files, Netdocs does not publish a separate file-hash manifest to the publish branch — doing so would duplicate git/S3 behavior. Remember that navigation or metadata changes invalidate a page's render-cache entry (the link map hash changes), so those pages are correctly rebuilt.

Other hosts

The site/ output is static HTML/CSS/JS. Upload it to any static host (Netlify, S3 + CloudFront, nginx, etc.). Point the host at the build output directory.