Each Colab-ready notebook in this repository is also published as a self-contained container image on the GitHub Container Registry. The image bundles the notebook, any helper files it fetches, a JupyterLab server, and the exact pinned dependency set from the notebook's install cell, preinstalled. Unlike the Colab path, the image does not depend on Colab's current Python version or on PyPI still serving the pinned packages, so it remains runnable long after the hosted environments have moved on.
docker run --rm -p 127.0.0.1:8888:8888 ghcr.io/dandi/example-notebooks/001550-paganlab:latest
Then open the http://127.0.0.1:8888/lab?token=... URL printed in the
terminal. If you already have a Jupyter server running on port 8888 (common),
Docker will refuse to start with an "address already in use" error; pick
another host port, e.g. -p 127.0.0.1:8890:8888, and open
http://127.0.0.1:8890/lab?token=... instead. The explicit 127.0.0.1: in
the port mapping matters: it keeps the server off your network interfaces,
and it makes a port collision fail loudly instead of silently routing your
browser to the other Jupyter server. JupyterLab opens on the notebook with its dependencies already
installed; the install cell at the top is a no-op and can be skipped. The
notebooks stream data from the DANDI Archive, so network access is still
required at run time.
Images are multi-arch (linux/amd64 and linux/arm64), so Apple Silicon
machines run them natively rather than under emulation. Emulated execution is
not just slow: Rosetta can deadlock on subprocess spawns inside the kernel,
which shows up as cells hanging forever. If you previously pulled an
amd64-only version of an image, run docker pull again to pick up the
multi-arch manifest.
Images are named after the notebook directory (lowercased, with / replaced
by -). When notebooks in the same directory pin different dependency sets,
the notebook name is appended, e.g.
ghcr.io/dandi/example-notebooks/000971-lernerlab-seiler-2024-optogenetics-example-notebook.
| Tag | Meaning |
|---|---|
latest |
most recent verified build |
YYYY-MM-DD |
date-stamped snapshot, useful as a citable reference |
sha-<short> |
git commit the image was built from |
hash-<12 hex> |
content hash of the build inputs (pins, notebooks, helpers, Dockerfile); used by CI to skip unchanged rebuilds |
The Build notebook images workflow
(.github/workflows/build-notebook-images.yml) groups notebooks by directory
and pin set (.github/scripts/build_notebook_image.py), builds one image per
group from the parameterized Dockerfile in this directory, and then runs
every notebook in the group inside the candidate image using the same
harness the test workflows use (.github/scripts/run_notebook.py). An image
is pushed only if all of its notebooks execute successfully, so latest is
always a verified snapshot.
Inside the image the kernel environment is the system Python, with pins
installed via uv pip install --system, exactly matching the Colab bootstrap
cell and the CI harness. JupyterLab runs from an isolated uv tool
environment so its own dependencies cannot perturb the pinned kernel
environment. ipykernel and nbformat are the only additions to the pinned
set; they resolve jointly with the pins so a conflict fails the build rather
than silently changing a pinned version. The matplotlib font cache is built
at image-build time so the first import in a fresh container doesn't stall
on it.
Each architecture is built and verified on its own native runner
(ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) — no emulation
anywhere in the pipeline — then the per-arch images are merged into one
multi-arch manifest carrying the user-facing tags.
- The workflow runs on every push to
masterthat touches notebooks,requirements.infiles, or the image tooling, building only the affected groups (a tooling change rebuilds all of them). It can also be dispatched by hand: thefilterinput is a regex on the notebook directory or path;push: falsegives a dry run (build + verify, no publish);forcerebuilds even when the build-hash check says the published image is current. When a run completes, the index workflow re-renders so new docker badges appear. - Rebuilds are skipped when a
hash-<...>tag matching the current build inputs already exists on the registry. Changing a notebook, its pins, its helpers, or the Dockerfile changes the hash and triggers a rebuild. Pins are refreshed with.github/scripts/lock_notebook.pyfrom therequirements.incommitted next to the notebook (seedocs/adding-notebooks.md). - The base image is digest-pinned in the Dockerfile (
ARG BASE_IMAGE). To bump it, update the digest, which changes every group's build hash, and dispatch a full rebuild. TheBASE_IMAGEarg is also the knob for a future variant based on the official Colab runtime image. - The server runs as the non-root user
jovyan(uid 1000) with Jupyter's token auth enabled. CI's verification step runs the container with--user rootso the harness's transient installs can write to the system Python; the published default stays non-root. The documented port mapping binds the host side to127.0.0.1explicitly; a bare-p 8888:8888would bind all interfaces, and on macOS it also loses silently to any local Jupyter server already listening on127.0.0.1:8888. - The
hash-<12 hex>-amd64/-arm64tags are the per-arch build artifacts the merge step assembles into the multi-archhash-<12 hex>manifest; they also serve as the per-arch skip markers. - Image eligibility follows
.github/notebook-test-exclusions.txt, except that notebooks listed in.github/notebook-image-inclusions.txtare built anyway: the image ships system libraries (libgl1, libglib2.0-0, libxcb1) that the slim CI runner lacks, so some CI-excluded notebooks run fine in the image. They still must pass in-image verification to publish.