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
- In the repository Settings → Pages, set Source to GitHub Actions.
- Set
siteUrlindocs-site/appsettings.jsonto your Pages URL (e.g.https://<user>.github.io/<repo>/). - 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— whentrue(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
pushtofalseto commit locally without pushing. - Requires
gitonPATHand 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
}
}
}
bucketis required;prefix(optional) publishes under a sub-path of the bucket.clean: truepasses--deleteso objects no longer produced by the build are removed.regionis optional — omit it to use the AWS CLI's configured default.- Requires the AWS CLI (
aws) onPATH.
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.