Deployment

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against DocFX 2.81.0 (released on 2026-09-25, the latest release at the time) and checked on 2026-10-26. The build, sitemap, search and 404 examples were run with that version in the mcr.microsoft.com/dotnet/sdk:10.0 Docker image against the Pocket Units documentation project. The workflow was checked with a linter but could not be run, because it needs a GitHub repository, and the Azure DevOps pipeline was not executed either. The docfx.json keys used here are explained in docfx.json, and the commands in CLI.

What gets deployed

docfx build writes a plain static site into the folder named by build.output (_site in every example of this section). DocFX has no server component: the official quick start describes the result as static HTML ready for any static hosting server, so deploying means copying that folder to a host. The build of the Pocket Units project with the settings shown later in this page produced these top-level entries:

_site/
  404.html          (from a 404.md in the content)
  CNAME             (copied by a resource mapping)
  api/              (one HTML page per namespace and type)
  favicon.ico
  index.html
  index.json        (search index, only with _enableSearch)
  logo.svg
  manifest.json
  public/           (CSS, JavaScript, fonts, Mermaid and KaTeX bundles of the modern template)
  sitemap.xml       (only with build.sitemap.baseUrl)
  toc.html
  toc.json
  xrefmap.yml

Every link and asset reference in the generated HTML is relative. The stylesheet link is public/docfx.min.css on the home page and ../public/docfx.min.css on a page under api/, so the site works unchanged at the root of a domain and under a sub-path such as the /pocket-units/ path of a GitHub project site. There is no base URL setting to keep in sync with the host, unlike the baseUrl of some other tools. The exception is the sitemap, which needs absolute URLs and therefore has its own setting.

Three docfx.json settings and the files they add to the _site folder

The figure shows which settings add files to the output folder. The remaining sections describe the two deployment routes and then those settings in detail.

Preparing the project for CI

A CI job needs three things: a .NET SDK, the docfx tool and the command that builds. The tool is a .NET global tool published on NuGet, so installing a specific version is one command:

dotnet tool install --global docfx --version 2.81.0
docfx docfx.json --warningsAsErrors

The official quick start uses dotnet tool update -g docfx, which installs the latest version when the tool is missing. Pinning with install --version makes a build reproducible, and raising the number is then a deliberate change. In the test, the first command printed nothing and the build printed:

Build succeeded.

    0 warning(s)
    0 error(s)

docfx build treats a broken link as a warning, not an error. With a deliberately broken link in index.md, the build printed the warning InvalidFileLink: Invalid file link:(~/missing.md)., wrote the site anyway and, with --warningsAsErrors, ended with exit code 255. Use the flag in CI if a broken page should stop the deployment; without it the pipeline stays green.

DocFX needs a .NET SDK even to build only Markdown, because it is itself a .NET tool. The contributor documentation lists SDK 8, 9 and 10 as supported, and 2.81.0 was run here on SDK 10.0.403. The metadata step builds your projects, so the SDK that the job installs must also be able to restore and build them.

GitHub Pages with GitHub Actions

GitHub Pages can publish either from a branch or from a workflow artifact. The artifact route needs no gh-pages branch: the build job uploads the folder as a Pages artifact and a second job deploys it. In the repository settings, under Pages, the source must be set to GitHub Actions for this to work. The DocFX quick start documents this route, but its example uses old action majors and a comment that still says the target is the gh-pages branch. The workflow below uses the current ones.

Current action versions

The latest releases on 2026-10-26, read from the GitHub releases of each repository:

Action Latest Released Used for

actions/checkout

v7.0.1

2026-07-20

Fetch the repository sources.

actions/setup-dotnet

v6.0.0

2026-07-16

Install the .NET SDK. v6 migrated the action to ESM and updated its dependencies.

actions/configure-pages

v6.0.0

2026-03-25

Enable and configure Pages. Optional for DocFX, because DocFX needs no site base path.

actions/upload-pages-artifact

v5.0.0

2026-04-10

Package the output folder as the Pages artifact.

actions/deploy-pages

v5.0.1

2026-09-01

Publish the artifact to Pages.

For comparison, the DocFX repository’s own publishing workflow uses checkout@v7, upload-pages-artifact@v5 and deploy-pages@v5, and the quick start example on the DocFX site still shows checkout@v3, setup-dotnet@v3, upload-pages-artifact@v3 and deploy-pages@v4. The pinned major tags move with each release line, so check the releases again when you copy a workflow.

The workflow

name: Publish documentation

on:
  push:
    branches: [main]
  workflow_dispatch:

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

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 10.x
      - name: Install DocFX
        run: dotnet tool install --global docfx --version 2.81.0
      - name: Build the site
        run: docfx docfx.json --warningsAsErrors
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        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@v5

The pieces that matter:

  • permissions gives the job token pages: write and id-token: write, which deploy-pages needs to prove the deployment comes from this workflow. contents: read is enough for the checkout.

  • The concurrency group serialises deployments and never cancels one that is running, which is the pattern the official example also uses.

  • The github-pages environment is created by GitHub the first time Pages is enabled, and its URL is filled in from the output of the deploy step.

  • path of the upload step is the DocFX output folder. For a project whose docfx.json is in a sub-folder, build with that path and upload <folder>/_site.

  • The workflow was checked with the rhysd/actionlint linter, which reported nothing. It was not run on GitHub, so the Pages settings, environment protection rules and token permissions of your own repository are untested here.

The Mermaid diagram shows the two jobs and how the Azure DevOps route described below differs.

flowchart LR S["Source repository"] --> B["Build job: setup-dotnet, install docfx, docfx docfx.json"] B --> O["_site folder"] O --> A["upload-pages-artifact"] A --> D["deploy-pages job"] D --> P["GitHub Pages site"] O --> G["git push to gh-pages"] G --> P

Hidden files in the artifact

Since v4.0.0 of upload-pages-artifact (2025-08-14), files and folders that start with a dot are not included in the artifact. A .nojekyll file therefore does not reach the site through this route, and it is not needed: Jekyll processing applies to branch deployments, and DocFX output contains no folder that starts with an underscore. The include-hidden-files input was added in v5.0.0 for the cases where a dotfile matters. If you deploy from a branch instead (see the next section), a .nojekyll file at the root of the branch does no harm.

Azure DevOps

The DocFX documentation has a page for building in Azure DevOps and pushing the result to the gh-pages branch of a GitHub repository. Azure Pipelines does the build, GitHub only serves the files, so the pipeline needs a GitHub personal access token that is allowed to write to the repository. The steps are: create the token, store it as a secret variable in a variable group, add the pipeline file, and set the Pages source branch to gh-pages in the GitHub settings.

trigger:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

variables:
  - group: GitHubPAT

steps:
  - task: UseDotNet@2
    displayName: Install the .NET SDK
    inputs:
      packageType: sdk
      version: 10.x

  - script: dotnet tool install --global docfx --version 2.81.0
    displayName: Install DocFX

  - script: |
      export PATH="$PATH:$HOME/.dotnet/tools"
      docfx docfx.json --warningsAsErrors
    displayName: Build the documentation

  - bash: |
      git config --global user.email "<YOUR_EMAIL>"
      git config --global user.name "<YOUR_NAME>"
      git clone --branch gh-pages "https://${GITHUB_PAT}@github.com/${GITHUB_REPO}.git" out
      rm -rf out/*
      cp -r _site/* out/
      cd out
      git add --all
      git commit -m "Update documentation"
      git push origin gh-pages
    displayName: Deploy to GitHub Pages
    env:
      GITHUB_PAT: $(GITHUB_PAT)
      GITHUB_REPO: <YOUR_ACCOUNT>/<YOUR_REPOSITORY>

This pipeline follows the structure of the official page; the differences are the pinned DocFX version, the --warningsAsErrors flag, the SDK version and the explicit PATH line (a global tool lives in ~/.dotnet/tools, which is not on the path of a fresh agent job unless the setup step added it). The variable group GitHubPAT holds a secret variable named GITHUB_PAT; never write the token in the YAML file. Azure Pipelines masks secret variables in the log, but the token is part of the clone URL, so do not add set -x to the script. This pipeline was not executed, because it needs an Azure DevOps organisation and a GitHub token. The official page assumes the gh-pages branch already exists (git clone --branch gh-pages fails otherwise), so create it once, for example with an empty commit, before the first run.

The rm -rf out/* line does not remove hidden files, so a CNAME or .nojekyll committed at the root of the branch survives each deployment. If you prefer to keep them in the source, add them to the build as resources, as described below.

DocFX itself has no Azure-specific features. The output is static, so the same _site folder can be copied to any other host: the build step is the part that is the same everywhere, and only the final copy changes.

Sitemap

A sitemap is generated only when build.sitemap.baseUrl is set. The key is described on docfx.json; this section shows the deployment-relevant part. The base URL must be the absolute address at which the site will be served, with a trailing slash, because the URLs written into sitemap.xml are this value followed by the page path:

{
  "metadata": [
    {
      "src": [{ "src": "src", "files": ["**/*.csproj"] }],
      "dest": "api"
    }
  ],
  "build": {
    "content": [
      { "files": ["api/**.yml", "api/index.md", "toc.yml", "*.md"] }
    ],
    "globalMetadata": {
      "_appName": "Pocket Units",
      "_appTitle": "Pocket Units",
      "_enableSearch": true
    },
    "output": "_site",
    "template": ["default", "modern"],
    "sitemap": {
      "baseUrl": "https://example.github.io/pocket-units/",
      "changefreq": "weekly"
    }
  }
}

The build printed Sitemap file is successfully exported to /w/_site/sitemap.xml and the file listed one entry per HTML page:

https://example.github.io/pocket-units/api/PocketUnits.Converter.html
https://example.github.io/pocket-units/api/PocketUnits.LengthUnit.html
https://example.github.io/pocket-units/api/PocketUnits.html
https://example.github.io/pocket-units/index.html

For a project site on GitHub Pages the base URL is https://<ACCOUNT>.github.io/<REPOSITORY>/; for a custom domain it is that domain, and it must match what search engines will crawl. lastmod defaults to the build time, so every deployment changes every date; set it explicitly or per file with fileOptions if that matters. A page set up as the 404 page also lands in the sitemap, as the next section shows, which is not useful to crawlers.

DocFX does not add a Sitemap: line to a robots.txt. If you want one, keep a robots.txt file in the project and copy it to the output with a resource mapping (see below), writing the absolute sitemap URL in it yourself.

Site search is a client-side feature: no server is involved, which is why it works on a static host. It is switched on by the template metadata _enableSearch, set in globalMetadata as in the example above. Two builds of the same project showed the difference:

Build _site content Home page

_enableSearch set to true

index.json (about 1.9 KB for the three API pages and the home page) is written.

A search form with an input is present.

_enableSearch not set

No index.json.

No search form.

The index.json file maps each page to its address, title and a text summary. The modern template’s script public/search-worker.min.js refers to this file, so the query runs in the browser against it. The template reference describes _enableSearch as the switch that shows the search box and _noindex as the switch that decides whether a page appears in the results, for both the modern and default templates. This page verified the behaviour only for the modern template. The 2.81.0 release notes also state that search.min.js of the modern template can be overridden, which is the extension point for changing the search script (see Templates and Customization).

Because the index is a single file that every visitor downloads on the first query, its size grows with the content. Large API sites should check the size of index.json after a build. The search box needs the files to be served over HTTP; opening _site/index.html from the disk is not the way to test it, and docfx docfx.json --serve publishes the folder on port 8080 for that purpose.

404 page and custom domain

GitHub Pages serves a file named 404.html from the root of the site for unknown addresses. DocFX produces one if the content contains a 404.md. The test file was:

# Page not found

Go back to the [home page](index.md).

and the content mapping of docfx.json already matched it, because its *.md pattern picks up every Markdown file of the project root. The page was built to _site/404.html. Its stylesheet link is the relative public/docfx.min.css, so a visitor who requests a missing page at a deeper address, such as /pocket-units/missing/page, receives HTML whose relative paths point to the wrong folder; the text of the page appears but the template styling may not load. Use a minimal 404 page, or one whose styling does not depend on relative paths, if that matters to you.

Files that are not content, such as a CNAME file for a custom domain or robots.txt, are copied through a resource mapping:

{
  "build": {
    "content": [
      { "files": ["api/**.yml", "api/index.md", "toc.yml", "*.md"] }
    ],
    "resource": [
      { "files": ["CNAME", "robots.txt"] }
    ],
    "output": "_site"
  }
}

In the test, a CNAME containing the custom host name was copied unchanged to _site/CNAME. For deployments through the Pages artifact route, GitHub takes the custom domain from the repository settings; a CNAME file matters for branch deployments.

Pitfalls and version notes

  • A sitemap needs baseUrl; without it there is no sitemap.xml and no log message.

  • Search needs _enableSearch; without it there is no index.json and no search box in the modern template.

  • The quick start example in the official documentation uses actions/checkout@v3, actions/setup-dotnet@v3, actions/upload-pages-artifact@v3 and actions/deploy-pages@v4, which are older than the versions in the table above. They are still published tags, but newer majors exist; the release notes of deploy-pages v5 and configure-pages v6 list an update to Node.js 24.

  • A broken link is only a warning unless --warningsAsErrors is passed; the exit code was 255 in the test.

  • upload-pages-artifact omits dotfiles since v4.0.0.

  • If docfx is reported as not found right after the install step, the global tools folder is not on the path of the job. The Azure DevOps example on this page adds it explicitly; this was not needed in the test container, where the same line was used.

  • The build output includes large bundles (public/ holds many Mermaid and Lunr language chunks in the modern template). They are loaded on demand, but they count towards the size of the deployed site.

  • DocFX 2.81.0 records its own version in manifest.json, which is handy when you need to see which version produced a deployed site.