diff --git a/.azure-pipelines-gh-pages.yml b/.azure-pipelines-gh-pages.yml
deleted file mode 100644
index daacaf33df08..000000000000
--- a/.azure-pipelines-gh-pages.yml
+++ /dev/null
@@ -1,126 +0,0 @@
-trigger:
-  batch: true
-  branches:
-    include:
-      - main
-      - "refs/tags/ccf-*"
-
-jobs:
-  - job: build_and_publish_docs
-    displayName: "Build and publish docs"
-    variables:
-      Codeql.SkipTaskAutoInjection: true
-      skipComponentGovernanceDetection: true
-    container: ghcr.io/microsoft/ccf/ci/default:latest
-    pool:
-      vmImage: ubuntu-20.04
-
-    steps:
-      - checkout: self
-        clean: true
-
-      - script: |
-          set -ex
-          env
-          git status
-          git rev-parse HEAD
-          git checkout -b main $(git rev-parse HEAD)
-        displayName: Prepare repo
-
-      # Used to generate version.py
-      - template: .azure-pipelines-templates/cmake.yml
-        parameters:
-          cmake_args: "-DCOMPILE_TARGET=virtual"
-
-      # Note: Link checks are disabled for now as often lead to intermittent
-      # false positives (e.g. a third-party website being down) that may block
-      # releases
-      # - script: |
-      #     set -ex
-      #     python3.8 -m venv env
-      #     source env/bin/activate
-      #     pip install wheel
-      #     pip install -U -r doc/requirements.txt
-      #     pip install -U -e ./python
-      #     sphinx-build -b linkcheck doc build/html
-      #   displayName: Link checks
-
-      # Only the main branch builds and publishes the versioned documentation.
-      # This is because the configuration of the docs (e.g. theme) will change
-      # over time and only the main branch should be source of truth and be
-      # kept up to date.
-      #
-      # Only on main and not on PRs
-      # Note: override remote whitelist to detect release branches on remote
-      - script: |
-          set -ex
-          python3.8 -m venv env
-          source env/bin/activate
-          pip install -U pip
-          pip install -U -e ./python
-          pip install -U -r doc/requirements.txt
-          pip install -U -r doc/historical_ccf_requirements.txt
-          sphinx-multiversion -D smv_remote_whitelist=origin doc build/html
-        displayName: Sphinx Multi-version
-        condition: |
-          and(
-            not(eq(variables['Build.Reason'], 'PullRequest')),
-            eq(variables['Build.SourceBranch'], 'refs/heads/main')
-          )
-
-      # Only on PRs and not on main (e.g. release branch/tag)
-      - script: |
-          set -ex
-          python3.8 -m venv env
-          source env/bin/activate
-          pip install -U pip
-          pip install -U -e ./python
-          pip install -U -r doc/requirements.txt
-          sphinx-build doc build/html
-        displayName: Sphinx Single-version
-        condition: |
-          or(
-            eq(variables['Build.Reason'], 'PullRequest'),
-            not(eq(variables['Build.SourceBranch'], 'refs/heads/main'))
-          )
-
-      - script: |
-          set -ex
-          git init
-          git config --local user.name "Azure Pipelines"
-          git config --local user.email "azuredevops@microsoft.com"
-          git add .
-          touch .nojekyll
-          git add .nojekyll
-          cp ../../doc/index.html .
-          git add index.html
-          git commit -m "[ci skip] commit generated documentation"
-        displayName: "Commit pages"
-        workingDirectory: build/html
-
-      - task: DownloadSecureFile@1
-        inputs:
-          secureFile: ccf
-        displayName: "Get the deploy key"
-
-      - script: |
-          set -ex
-          mv $DOWNLOADSECUREFILE_SECUREFILEPATH deploy_key
-          chmod 600 deploy_key
-          mkdir ~/.ssh
-          chmod 700 ~/.ssh
-          ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts
-          git remote add origin git@github.com:microsoft/CCF.git
-          GIT_SSH_COMMAND="ssh -i deploy_key" git push -f origin HEAD:gh-pages
-        displayName: "Publish GitHub Pages"
-        condition: |
-          and(
-            not(eq(variables['Build.Reason'], 'PullRequest')),
-            eq(variables['Build.SourceBranch'], 'refs/heads/main')
-          )
-        workingDirectory: build/html
-
-      - script: rm deploy_key || true
-        displayName: "Make sure key is removed"
-        workingDirectory: build/html
-        condition: always()
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index e0e1afd10cbc..e831e59bb442 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -81,6 +81,13 @@ Publishes ccf Python package from a GitHub release to PyPI. Triggered on release
 File: `pypi.yml`
 3rd party dependencies: None
 
+# Documentation
+
+Builds and publishes documentation to GitHub Pages. Triggered on pushes to main, and manually. Note that special permissions (Settings > Environment) are configured.
+
+File: `doc.yml`
+3rd party dependencies: None
+
 # Deprecated
 
 The following pipelines are still here to support 4.x, but will be removed when it reaches EOL.
diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml
new file mode 100644
index 000000000000..bf68f73efe52
--- /dev/null
+++ b/.github/workflows/doc.yml
@@ -0,0 +1,62 @@
+name: "Doc"
+
+on:
+  push:
+    branches:
+      - main
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build
+    runs-on: ubuntu-latest
+    container:
+      image: ghcr.io/microsoft/ccf/ci/default:latest
+    steps:
+      - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Setup Pages
+        id: pages
+        uses: actions/configure-pages@v5
+      - name: Build Documentation
+        run: |
+          set -x
+          python3.8 -m venv env
+          source env/bin/activate
+          pip install -U pip
+          pip install -U -e ./python
+          pip install -U -r doc/requirements.txt
+          pip install -U -r doc/historical_ccf_requirements.txt
+          sphinx-multiversion -D smv_remote_whitelist=origin doc build/html
+        shell: bash
+      - name: Set up top-level directory
+        run: |
+          set -x
+          cd build/html
+          touch .nojekyll
+          cp ../../doc/index.html .
+        shell: bash
+      - name: Upload pages
+        uses: actions/upload-pages-artifact@v3
+        with:
+          path: build/html
+
+  # This is purposefully separate to keep the scope with
+  # with the permissions to deploy to a minimum.
+  deploy:
+    name: Deploy
+    needs: build
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pages: write
+      id-token: write
+    environment:
+      name: github-pages
+      url: ${{steps.deployment.outputs.page_url}}
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v4