# A single-host JupyterHub on Docker, and what it refuses to promise

> jupyterhub-deploy-docker is a reference deployment that puts JupyterHub, its authenticator and one notebook container per user on a single Docker host. It is explicit that it is not a production system, and the interesting parts of the design are the ones that follow from that single-host constraint.

**jupyterhub/jupyterhub-deploy-docker** — Reference deployment of JupyterHub with docker

- Repository: https://github.com/jupyterhub/jupyterhub-deploy-docker
- Stars: 767 · Forks: 381
- Language: Python
- License: BSD-3-Clause
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/jupyterhub-jupyterhub-deploy-docker

## The disclaimer comes before the features

The first substantive thing the README says about this project is a warning. The deployment is not intended for a production environment, because it is a reference implementation that does not meet traditional requirements in terms of availability, scalability, or security. For anything beyond that, the project points to zero-to-jupyterhub-k8s, which is a separate repository from the same organisation.

That framing matters more than it usually does, because this repository is genuinely well built for what it claims. It is a reference deployment of JupyterHub, the multi-user Jupyter Notebook environment, on a single host using Docker. The stated use cases are creating a JupyterHub demo environment that you can spin up relatively quickly, and providing a multi-user Jupyter Notebook environment for small classes, teams or departments.

The honest reading is that this is a teaching and prototyping deployment. It gives you the topology of a real JupyterHub installation, with the same components in the same relationships, on one machine, so that you can learn the shape of the system or show it to people without provisioning anything else. It also gives you a working baseline to fork when you need to add your own authenticator, your own spawner or your own storage.

The repository backs that reading in its layout. Besides the top level files, there is a basic-example/ directory and a ci/ directory, along with a .flake8 configuration, a .pre-commit-config.yaml and a CONTRIBUTING.md. The licence is BSD-3-Clause with the text in a LICENSE file, the repository is not archived, and the last push was on 2026-10-13. There are no GitHub releases, so you work from the branch rather than from a tag.

## Host, authenticator, spawner, two kinds of volume

The technical overview lists the components, and it is short enough to hold in your head, which is exactly what a reference deployment should do.

The host runs the JupyterHub components in a Docker container on the host itself. The authenticator is Native Authenticator, the component that authenticates users. The spawner is DockerSpawner, which spawns single-user Jupyter Notebook servers in separate Docker containers on the same host. Then there are two persistence layers, and they are kept distinct on purpose. JupyterHub data, the hub's own state, is persisted in a Docker volume on the host. User notebook directories are persisted in Docker volumes on the host too, one per user.

Two consequences follow directly from that layout. The first is that every user session is a container on the same machine, so a busy class multiplies container count on one host, and the host is the ceiling on how many users can be served at once. The second is that the two volume kinds have different lifetimes. The hub volume is what keeps sessions and cookies alive across restarts, while the notebook volumes are what keeps a user's files.

The spawner is also the isolation boundary. DockerSpawner gives each user a separate container, but the isolation is Docker's container boundary on a single host, not a dedicated machine, and not a security boundary you would defend a production environment with.

## From clone to a hub on localhost:8000

The whole bring-up is a handful of Docker Compose commands, and Docker is the only prerequisite. The README asks you to set up Docker for your environment using Docker's own installation instructions, and then notes that this deployment uses Docker via Docker Compose for everything.

The first step builds the JupyterHub image from the files in the repository:

```bash
   docker compose build
```

The notebook image that users will actually get is separate, and the default is quay.io/jupyter/base-notebook, built from the base-notebook Docker stacks. The README recommends pulling it yourself:

```bash
docker pull quay.io/jupyter/base-notebook:latest
```

That recommendation is not cosmetic. If the image is not on the host when a user first starts a server, Docker attempts to pull it at that moment, and the README warns that JupyterHub may time out if the image being pulled is large. Pulling first removes the race between the user clicking start and the hub waiting on a registry.

With the images in place, start the hub in detached mode:

```bash
docker compose up -d
```

Once the container is running, the JupyterHub console is reachable at http://localhost:8000. And when you are done:

```bash
docker compose down
```

That is the entire lifecycle. There is no helm chart, no cluster, no external database and no separate identity provider, which is exactly why it spins up quickly and exactly why it does not scale past one host.

## Native Authenticator with sign-up open to anyone

Authentication is handled by JupyterHub Native Authenticator, and the configuration is deliberately permissive. An admin user is enabled by default, and any user is allowed to sign up. The technical overview says the same thing in its own words: any user will be allowed to sign up.

For a demo on a laptop this is exactly what you want, because there is no step where you have to create accounts before you can show the thing working. For anything with a network path to it, it is the first thing to change. Open sign-up on a JupyterHub means any visitor can create an account, and each account can then consume a notebook container and disk on the shared host.

The authenticator is a component, and the README links to it as a separate project, which is the escape hatch. Swapping in a different authenticator, or configuring Native Authenticator to restrict who may register, is a configuration change rather than a rewrite, and this reference deployment gives you a working hub to make that change against.

The other identity-related detail is where the hub volume earns its keep. Cookies are persisted to a Docker volume on the hub's host, so restarting the JupyterHub container does not force everyone to log in again. The README is precise about the consequence: restarting might cause a temporary blip in user service, users do not have to log in again to their individual notebook servers, but they may need to refresh the browser to re-establish connections to running notebook kernels.

## Changing the notebook image means restarting the hub

The image that users spawn from is configurable, and the mechanism is worth understanding because it explains an operational wrinkle. You can configure JupyterHub to spawn notebook servers from any Docker image, as long as the image's ENTRYPOINT and/or CMD starts a single-user instance of the Jupyter Notebook server that is compatible with JupyterHub. That constraint on ENTRYPOINT and CMD is the real requirement, and it is stricter than having an image with Jupyter installed in it.

The setting is an environment variable, DOCKER_NOTEBOOK_IMAGE, and the wiring is visible in the configuration file:

```python
# DockerSpawner setting in jupyterhub_config.py
c.DockerSpawner.image = os.environ['DOCKER_NOTEBOOK_IMAGE']
```

JupyterHub reads the image name from jupyterhub_config.py, which reads it from the environment variable. That chain has a consequence the README spells out: yes, you need to restart JupyterHub. JupyterHub reads its configuration, including the container image name for DockerSpawner, and uses it to determine which notebook server image to spawn during startup. Change the variable while the hub is running and nothing happens, which surprises people who expect a config change to take effect live.

There is a further condition on the image. Whether you build a custom notebook image or pull one from a public or private registry, the image must reside on the host. That is a constraint on private registries in particular: a registry the host can reach is a requirement, and a hub that cannot pull the image is a hub that cannot start user servers.

## Backups are tarballs made by a throwaway container

Backing up a user's work is worth spelling out, because the notebook directories are volumes and volumes are invisible in a normal file listing. The user's notebook directories, at /home/jovyan/work, are backed by Docker volumes, and the README shows how to find the volume backing a given container:

```bash
    docker inspect -f '{{ .Mounts }}' jupyter-jtyberg
```

The output shows the volume name, its path on the host, and the mount point inside the container. With the volume name in hand, the backup is a separate container that mounts the volume read-write and writes a tarball:

```bash
docker run --rm \
  -u root \
  -v /tmp:/backups \
  -v jtyberg:/notebooks \
  quay.io/jupyter/minimal-notebook \
  tar cvf /backups/jtyberg-backup.tar /notebooks
```

The command creates the tarball in the /tmp directory on the host. Note that it is a plain tar rather than a tarball with compression, and note that the container is removed on exit while the archive stays. The tradeoff is that this is a manual, per-user procedure: there is no scheduled backup in this deployment, and no restore procedure is documented. For a class or a demo that is fine. For a team's working data, the absence of an automated path is a real gap rather than a missing convenience.

For logs, the answer is shorter. Use docker logs with the container name, for example docker logs jupyterhub to see the hub's output, and the same command works for a user's notebook server container.

## Where the single-host model stops

It is worth being blunt about the boundary, because the failure mode is quiet rather than loud. Everything lives on one host: the hub container, one notebook container per user, the hub's own state volume and a volume per user. When the host is busy, every user feels it, and there is no scheduler to add a second machine. When the host is patched or restarted, everyone is affected at once, with the brief blip the README describes.

The alternative the project points to is zero-to-jupyterhub-k8s, and the difference in approach is the whole point of it. That project deploys the same JupyterHub architecture on Kubernetes, which means users are scheduled onto a cluster that can scale, containers are placed on nodes that can fail and be rescheduled, and storage is a cluster concern rather than a directory of Docker volumes on one box. The trade-off is that the Kubernetes version needs a cluster, which is precisely the complexity the reference deployment exists to avoid.

For small classes, teams or departments, the single-host reference is the right size. Once you need horizontal scale, node-level failure tolerance, per-user storage that outlives a machine, or any of the availability guarantees the disclaimer rules out, the Kubernetes route is the one the README asks you to take instead.

## Conclusion

Adopt jupyterhub-deploy-docker for a demo, a class, a small team or a department that needs multi-user notebooks on one machine and can live with open sign-up and a single host. Do not adopt it where availability, scaling or isolation matter, because the project itself says so in the disclaimer at the top. Verify first that your host can run one container per user, that the notebook image is already pulled to the host, and that the Native Authenticator open sign-up is disabled before anything real reaches it.

## FAQ

### How do I run JupyterHub with jupyterhub-deploy-docker?

Build the image with docker compose build, pull the notebook image with docker pull quay.io/jupyter/base-notebook:latest, then start the hub with docker compose up -d and open http://localhost:8000. Use docker compose down to bring the container back down.

### Which authenticator and spawner does jupyterhub-deploy-docker use?

It uses JupyterHub Native Authenticator, where an admin user is enabled by default and any user is allowed to sign up, and DockerSpawner, which spawns single-user Jupyter Notebook servers in separate Docker containers on the same host. JupyterHub data and user notebook directories are persisted in Docker volumes on the host.

### How do I change the notebook image in jupyterhub-deploy-docker?

Set the DOCKER_NOTEBOOK_IMAGE environment variable to the image you want, which jupyterhub_config.py passes to c.DockerSpawner.image. You then need to restart the JupyterHub container, because the image name is read from configuration at startup.

### How do I back up a user's notebook directory in jupyterhub-deploy-docker?

User notebook directories at /home/jovyan/work are backed by Docker volumes, which you can identify with docker inspect -f '{{ .Mounts }}' on the container. The README then shows running a separate container that mounts the volume and writes a tarball into /tmp on the host.

## Sources

- [Issues](https://github.com/jupyterhub/jupyterhub-deploy-docker/issues)
- [jupyterhub/jupyterhub-deploy-docker on GitHub](https://github.com/jupyterhub/jupyterhub-deploy-docker)
- [License: BSD-3-Clause](https://github.com/jupyterhub/jupyterhub-deploy-docker/blob/main/LICENSE)
- [README](https://github.com/jupyterhub/jupyterhub-deploy-docker/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/jupyterhub-jupyterhub-deploy-docker
