# Orchestrate Sphinx projects with Bazel {#orchestrate-sphinx-projects-with-bazel}

This tutorial shows you how to manage core [Sphinx](https://www.sphinx-doc.org) workflows through [Bazel](https://bazel.build).
You’ll learn how to:

- Set up the Bazel build system
- Build the Sphinx docs
- Inspect the built docs
- Spin up a local server to preview the docs
- Add a Sphinx extension to the Bazel build
- Deploy to GitHub Pages

Check out [Sphinx + Bazel: the good, the bad, and the ugly](../context/index.html#sphazel-context) for help deciding whether or not
Bazel is worthwhile for your Sphinx project.

The final code is available as a template repository here:
[github.com/kaycebasques/sphazel](https://github.com/kaycebasques/sphazel)

## Assumptions {#assumptions}

The tutorial assumes you’re on a Linux machine and running commands
through a Bash shell. Everything should be supported on macOS and Windows
but you’ll need to tweak the instructions. It assumes you’re familiar with
Sphinx but not familiar with Bazel. I.e. Sphinx concepts aren’t explained but
Bazel ones are.

## A key Bazel concept {#a-key-bazel-concept}

[Hermeticity](https://bazel.build/basics/hermeticity). Bazel builds your Sphinx project in an isolated sandbox so that
it can guarantee that a certain set of inputs always produces the same
output(s). You must explicitly declare to Bazel all the inputs (i.e. source
files, tools, and third-party dependencies) that your Sphinx project needs.
Bazel basically symlinks all the inputs into a temporary directory, locks down
the directory from reading anything outside of it, resets all OS environment
variables, and then builds the project under those controlled conditions.

This is the most important concept to understand because you will inevitably
forget to declare an input to Bazel and you will see an `X not found` error
of one sort or another.

## Set up a Sphinx project {#set-up-a-sphinx-project}

First, let’s create a bare-bones Sphinx project.

1. Create a directory for your project:
   
   ```console
   $ mkdir sphazel
   ```
2. `cd` into the directory.
   
   ```console
   $ cd sphazel
   ```
3. Create `conf.py` and configure the Sphinx project:
   
   ```py
   project = 'sphazel'
   author = 'sphazel'
   copyright = f'2025, Hank Venture'
   release = '0.0.0'
   exclude_patterns = [
       '**/*bazel*',
       'requirements.*',
   ]
   extensions = []
   pygments_style = 'sphinx'
   ```
4. Create `.gitignore` and specify that Bazel output directories
   should be ignored:
   
   ```text
   bazel-*
   ```
5. Create `index.rst` and add the following content to it:
   
   ```rst
   .. _sphazel:
   
   =======
   sphazel
   =======
   
   Sphinx + Bazel = sphazel
   ```
6. Create `requirements.txt` and declare your project’s direct dependencies there:
   
   ```text
   sphinx==8.2.3
   ```
7. Freeze your [direct and transitive dependencies](https://fossa.com/blog/direct-dependencies-vs-transitive-dependencies/) into a new file called
   `requirements.lock`:
   
   ```console
   $ python3 -m venv venv && \
       . venv/bin/activate && \
       python3 -m pip install -r requirements.txt && \
       python3 -m pip freeze > requirements.lock && \
       deactivate && \
       rm -rf venv
   ```
   
   Here we spin up a temporary virtual environment, install the dependencies
   into the virtual environment, record the full list of dependencies into
   `requirements.lock`, and then delete the virtual environment because it’s
   no longer needed.
   
   The lockfile is not optional. You’ll learn why in the next section.

## Set up Bazel {#set-up-bazel}

Next, we set up the Bazel build system.

1. Create `MODULE.bazel` and add the following content to it:
   
   ```py
   bazel_dep(name = "rules_python", version = "1.2.0")
   
   pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
   pip.parse(
       hub_name = "pypi",
       python_version = "3.11",
       requirements_lock = "//:requirements.lock",
   )
   use_repo(pip, "pypi")
   ```
   
   `MODULE.bazel` is how we declare to the world that this is a Bazel project.
   `MODULE.bazel` is the only valid name for this file, which makes it easy to
   discover. See [Bazel modules](https://bazel.build/external/module).
   
   The call to [bazel_dep](https://bazel.build/rules/lib/globals/module#bazel_dep) tells Bazel to pull the [rules_python](https://github.com/bazel-contrib/rules_python) third-party module into
   our project as a dependency. `rules_python` provides most of the mechanisms
   for managing our Sphinx project. Bazel fetches `rules_python`
   over the network via the [Bazel Central Registry](https://registry.bazel.build/).
   
   The rest of the code sets up the project to be able to use [pip](https://en.wikipedia.org/wiki/Pip_(package_manager)) to
   install third-party Python dependencies from the [Python Package Index](https://pypi.org/)
   as needed.
   
   One important thing to note is that you must pass in `requirements.lock`,
   i.e. the full list of [direct and transitive dependencies](https://fossa.com/blog/direct-dependencies-vs-transitive-dependencies/).
   `rules_python` only installs the exact packages that you tell it about.
   This is different than how `pip` usually works. For example, when you run
   `python3 -m pip install requests`  usually `pip` will not only install
   the `requests` package that you explicitly *requested* (pun intended) but
   also all the packages that `requests` itself depends on. When using
   `pip` from Bazel there is no attempt to resolve transitive dependencies
   for you.
2. Create `BUILD.bazel` and add the following content to it:
   
   ```py
   load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
   load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library")
   
   sphinx_docs_library(
       name = "sources",
       srcs = [
           "index.rst",
       ],
   )
   
   sphinx_build_binary(
       name = "sphinx",
       deps = [
           "@pypi//sphinx",
       ]
   )
   
   sphinx_docs(
       name = "docs",
       config = "conf.py",
       formats = [
           "html",
       ],
       sphinx = ":sphinx",
       deps = [
           ":sources",
       ]
   )
   ```
   
   [BUILD files](https://bazel.build/concepts/build-files) tell Bazel how exactly it should build the project. The only
   allowed names for these files are `BUILD` or `BUILD.bazel`.
   
   The `load` functions import the core mechanisms for building the
   Sphinx project: `sphinx_build_binary`, `sphinx_docs`, and
   `sphinx_docs_library`. All of these come from `rules_python`.
   
   The `sphinx_docs_library` rule is where we declare all of the source files
   of the Sphinx project.
   
   `sphinx_build_binary` sets up the [sphinx-build](https://www.sphinx-doc.org/en/master/man/sphinx-build.html) binary. Note how
   third-party PyPI packages (such as `sphinx`) are passed as dependencies
   to this rule. This will come up again in [Add an extension](sphazel-tutorial-extension).
   
   `sphinx_docs` is where the Sphinx build actually happens. Note the colon
   (`:`) before `:sphinx` and `:sources`. This indicates that the thing
   you’re passing in is an [artifact](https://bazel.build/basics/artifact-based-builds) that is produced elsewhere in
   the Bazel build.
   
   See also [Real-world BUILD.bazel files for Sphinx projects](sphazel-tutorial-examples).
3. Create `.bazelversion` and add the following content to it:
   
   ```text
   8.1.1
   ```
   
   Bazel changes a lot from version to version. It’s important to specify
   exactly what version of Bazel should be used to build your project.

## Set up Bazelisk {#set-up-bazelisk}

[Bazelisk](https://bazel.build/install/bazelisk) is kinda hard to explain. It’s basically how you’re supposed to run
Bazel from the command line. It downloads the Bazel CLI executable that you
specify in `.bazelversion` but then you also use it to run all your
command-line Bazel workflows.

1. Download Bazelisk:
   
   ```console
   $ curl -L -O https://github.com/bazelbuild/bazelisk/releases/download/v1.25.0/bazelisk-linux-amd64
   ```
   
   This is the executable for Linux running on x86-64. See [v1.25.0](https://github.com/bazelbuild/bazelisk/releases/tag/v1.25.0) for links to other
   platforms. E.g. if you’re using macOS on Apple Silicon, then you need to download
   the `bazelisk-darwin-arm64` executable instead.
   
   It’s also possible to install via `apt`, `npm`, `homebrew`, etc. but in
   my experience you sometimes get a very old version of Bazelisk. Better to just
   directly download the latest release.
2. Make the file executable:
   
   ```console
   $ chmod +x bazelisk-linux-amd64
   ```

In my own projects I personally just check in the Bazelisk executables
alongside the rest of the code. The more common approach is to have teammates
download the relevant Bazelisk executable for their machine to a typical
location (e.g. `~/.local/bin`) and then set up an alias so that they can
invoke `bazelisk` from any directory. In my approach you have to specify the
path to the executable when you invoke it but you eliminate the need for each
teammate to manually set up Bazel on their own machine. And since Bazel is all about
tightly controlling inputs, it makes sense to me to have all teammates use the exact
same version of Bazelisk.

## Build the docs {#build-the-docs}

That’s all you need to start using Bazel.

1. Build the docs:
   
   ```console
   $ ./bazelisk-linux-amd64 build //:docs
   ```
   
   In plain English this command is saying “build the artifact named `docs` that
   is defined in the `BUILD.bazel` (or `BUILD`) file in the root directory of
   this Bazel project”.
   
   Example output from a successful build:
   
   ```text
   Starting local Bazel server (8.1.1) and connecting to it...
   INFO: Analyzed target //:docs (122 packages loaded, 6072 targets configured).
   INFO: Found 1 target...
   Target //:docs up-to-date:
     bazel-bin/docs/_build/html
   INFO: Elapsed time: 11.967s, Critical Path: 2.47s
   INFO: 8 processes: 7 internal, 1 linux-sandbox.
   INFO: Build completed successfully, 8 total actions
   ```

## Inspect the generated HTML {#inspect-the-generated-html}

When I need to inspect the generated HTML, I just do something like this:

```console
$ vim bazel-bin/docs/_build/html/index.html
```

## Locally preview the docs {#locally-preview-the-docs}

One very cool thing about `rules_python` is that it also has a
built-in local server for previewing the docs:

```console
$ ./bazelisk-linux-amd64 run //:docs.serve
```

It should output a `localhost` URL where you can preview the docs:

```text
INFO: Analyzed target //:docs.serve (0 packages loaded, 461 targets configured).
INFO: Found 1 target...
Target //:docs.serve up-to-date:
  bazel-bin/docs.serve
INFO: Elapsed time: 0.843s, Critical Path: 0.15s
INFO: 5 processes: 5 internal.
INFO: Build completed successfully, 5 total actions
INFO: Running command line: bazel-bin/docs.serve bazel-out/k8-fastbuild/bin/docs/_build/html
Serving...
  Address: http://0.0.0.0:8001
  Serving directory: /home/kayce/github/kaycebasques/sphazel/bazel-out/k8-fastbuild/bin/docs/_build/html
      url: file:///home/kayce/github/kaycebasques/sphazel/bazel-out/k8-fastbuild/bin/docs/_build/html
  Server CWD: /home/kayce/.cache/bazel/_bazel_kayce/74072e0325cb6dc49620a5c889c58931/execroot/_main/bazel-out/k8-fastbuild/bin/docs.serve.runfiles/_main

*** You do not need to restart this server to see changes ***
*** CTRL+C once to reprint this info ***
*** CTRL+C twice to exit ***
```

## Add an extension {#add-an-extension}

[Extensions](https://www.sphinx-doc.org/en/master/usage/extensions/index.html) are one of my favorite aspects of the Sphinx ecosystem.
My projects use them heavily. Here’s how to add one to the Bazel build.

1. Update `requirements.txt` to indicate that you’re going to use
   [sphinx-reredirects](https://pypi.org/project/sphinx-reredirects/) to generate client-side redirects.
   
   ```text
   sphinx==8.2.3
   sphinx-reredirects==0.1.5  # new
   ```
2. Update your lockfile again to capture the new direct and
   transitive dependencies:
   
   ```console
   $ python3 -m venv venv && \
       . venv/bin/activate && \
       python3 -m pip install -r requirements.txt && \
       python3 -m pip freeze > requirements.lock && \
       deactivate && \
       rm -rf venv
   ```
3. Update `conf.py` to use the extension:
   
   ```py
   project = 'sphazel'
   author = 'sphazel'
   copyright = f'2025, Hank Venture'
   release = '0.0.0'
   exclude_patterns = [
       '**/*bazel*',
       'requirements.*',
   ]
   extensions = ["sphinx_reredirects"]  # new
   pygments_style = 'sphinx'
   redirects = {'example': 'https://example.com'}  # new
   ```
4. Declare the dependency to Bazel by updating `BUILD.bazel`:
   
   ```py
   load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
   load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library")
   
   sphinx_docs_library(
       name = "sources",
       srcs = [
           "index.rst",
       ],
   )
   
   sphinx_build_binary(
       name = "sphinx",
       deps = [
           "@pypi//sphinx",
           "@pypi//sphinx_reredirects",  # new
       ]
   )
   
   sphinx_docs(
       name = "docs",
       config = "conf.py",
       formats = [
           "html",
       ],
       sphinx = ":sphinx",
       deps = [
           ":sources",
       ]
   )
   ```

If you navigate to `http://0.0.0.0:<port>/example.html` (where `<port>` is a
placeholder for whatever actual port your [local server](sphazel-tutorial-preview)
is running on) you should get redirected to `https://example.com`.

## Deploy with GitHub Pages {#deploy-with-github-pages}

I’ll assume that you’re familiar with [using a custom GitHub Action to publish a site](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site#creating-a-custom-github-actions-workflow-to-publish-your-site).
Here’s the YAML:

```yaml
name: deploy
on:
  push:
    branches: ['main']
  workflow_dispatch:
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{steps.deployment.outputs.page_url}}
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: configure
        uses: actions/configure-pages@v5
      - name: build
        run: ${{github.workspace}}/bazelisk-linux-amd64 build //:docs
      - name: upload
        uses: actions/upload-pages-artifact@v3
        with:
          path: ${{github.workspace}}/bazel-out/k8-fastbuild/bin/docs/_build/html
      - name: deploy
        id: deployment
        uses: actions/deploy-pages@v4
```

## Real-world BUILD.bazel files for Sphinx projects {#real-world-build-bazel-files-for-sphinx-projects}

- Simple: [technicalwriting.dev](https://github.com/technicalwriting/dev/blob/main/BUILD.bazel)
- Complex: [pigweed.dev](https://cs.opensource.google/pigweed/pigweed/+/main:docs/BUILD.bazel)
