A tutorial on managing Sphinx projects with Bazel#
This tutorial shows you how to manage core Sphinx workflows through Bazel. 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 The good, the bad, and the ugly of managing Sphinx projects with Bazel 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
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#
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#
First, let’s create a bare-bones Sphinx project.
Create a directory for your project:
$ mkdir sphazel
cd
into the directory.$ cd sphazel
Create
conf.py
and configure the Sphinx project:project = 'sphazel' author = 'sphazel' copyright = f'2025, Hank Venture' release = '0.0.0' exclude_patterns = [ '**/*bazel*', 'requirements.*', ] extensions = [] pygments_style = 'sphinx'
Create
.gitignore
and specify that Bazel output directories should be ignored:bazel-*
Create
index.rst
and add the following content to it:.. _sphazel: ======= sphazel ======= Sphinx + Bazel = sphazel
Create
requirements.txt
and declare your project’s direct dependencies there:sphinx==8.2.3
Freeze your direct and transitive dependencies into a new file called
requirements.lock
:$ 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#
Next, we set up the Bazel build system.
Create
MODULE.bazel
and add the following content to it: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.The call to bazel_dep tells Bazel to pull the rules_python third-party module into our project as a dependency.
rules_python
provides most of the mechanisms for managing our Sphinx project. Bazel fetchesrules_python
over the network via the Bazel Central Registry.The rest of the code sets up the project to be able to use pip to install third-party Python dependencies from the Python Package Index 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.rules_python
only installs the exact packages that you tell it about. This is different than howpip
usually works. For example, when you runpython3 -m pip install requests
usuallypip
will not only install therequests
package that you explicitly requested (pun intended) but also all the packages thatrequests
itself depends on. When usingpip
from Bazel there is no attempt to resolve transitive dependencies for you.Create
BUILD.bazel
and add the following content to it: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 tell Bazel how exactly it should build the project. The only allowed names for these files are
BUILD
orBUILD.bazel
.The
load
functions import the core mechanisms for building the Sphinx project:sphinx_build_binary
,sphinx_docs
, andsphinx_docs_library
. All of these come fromrules_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 binary. Note how third-party PyPI packages (such assphinx
) are passed as dependencies to this rule. This will come up again in Add an 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 that is produced elsewhere in the Bazel build.Create
.bazelversion
and add the following content to it: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#
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.
Download Bazelisk:
$ 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 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.Make the file executable:
$ 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#
That’s all you need to start using Bazel.
Build the docs:
$ ./bazelisk-linux-amd64 build //:docs
In plain English this command is saying “build the artifact named
docs
that is defined in theBUILD.bazel
(orBUILD
) file in the root directory of this Bazel project”.Example output from a successful build:
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#
When I need to inspect the generated HTML, I just do something like this:
$ vim bazel-bin/docs/_build/html/index.html
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:
$ ./bazelisk-linux-amd64 run //:docs.serve
It should output a localhost
URL where you can preview the docs:
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#
Extensions 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.
Update
requirements.txt
to indicate that you’re going to use sphinx-reredirects to generate client-side redirects.sphinx==8.2.3 sphinx-reredirects==0.1.5 # new
Update your lockfile again to capture the new direct and transitive dependencies:
$ python3 -m venv venv && \ . venv/bin/activate && \ python3 -m pip install -r requirements.txt && \ python3 -m pip freeze > requirements.lock && \ deactivate && \ rm -rf venv
Update
conf.py
to use the extension: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
Declare the dependency to Bazel by updating
BUILD.bazel
: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
is running on) you should get redirected to https://example.com
.
Deploy with GitHub Pages#
I’ll assume that you’re familiar with using a custom GitHub Action to publish a site. Here’s the 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#
Simple: technicalwriting.dev
Complex: pigweed.dev