JupyterHub notebook server
JupyterHub notebook server
This pattern composes Compute, Network, and Block Storage into a self-hosted multi-user notebook server you run on infrastructure you control.
What this template does#
Provisions a single instance running JupyterHub, a multi-user notebook server (a self-hosted alternative to Google Colab or Deepnote at the CPU tier). Each user gets a CPU-only scipy notebook container with persistent storage:
- Compute instance that runs JupyterHub in Docker with DockerSpawner, sized for the hub plus one concurrent scipy notebook (4 vCPU and 4 GiB RAM)
- Private network, subnet, router, port, and security group; a floating IP for public access
- A block volume mounted at
/var/lib/docker, so the hub database and per-user notebook volumes live on a volume you can grow rather than on the boot disk - cloud-init installs Docker Engine, builds the hub image, and starts JupyterHub from a compose file on first boot
JupyterHub is the analyst and data-scientist front door for notebooks on the platform. It runs multi-user notebooks on a VM you own, which keeps hub state and user data on your infrastructure.
No credential ships with this template. You register the admin account through JupyterHub's signup page on first visit, and each additional user registers through the same flow.
Parameters#
| Parameter | Description | Default |
|---|---|---|
key_name | SSH keypair name (must already exist) | No default |
flavor_name | Instance size (hub plus one scipy notebook on 4 vCPU / 4 GiB) | s1a.medium |
image_name | Operating system image | Ubuntu-24.04 |
app_name | Display name prefix for resources | jupyterhub |
volume_size | Block volume size in GiB, mounted at /var/lib/docker | 30 |
external_network | External network for floating IP allocation | PublicStatic |
private_cidr | CIDR for the private subnet | 10.40.0.0/24 |
hub_allowed_cidr | CIDR allowed to reach the hub on port 8000 | 10.40.0.0/24 |
notebook_image | CPU-only Docker image for per-user notebook servers | quay.io/jupyter/scipy-notebook:2024-10-14 |
Hub access and security#
The hub listens on port 8000 over plain HTTP. The security group restricts 8000 to hub_allowed_cidr, which defaults to the private network only, so the raw hub stays off the public internet. JupyterHub's NativeAuthenticator gates access through its signup page. Reach the hub one of three ways:
- Put a reverse proxy (Caddy or Nginx) in front of JupyterHub and serve the hub over HTTPS on 443. Point the domain's DNS A record at the floating IP. This is the recommended path for routine access.
- Tunnel over SSH:
ssh -L 8000:localhost:8000 ubuntu@FLOATING_IP, then openhttp://localhost:8000. - Set
hub_allowed_cidrtoYOUR_IP/32to reach port 8000 directly from one address.
Ports 80 and 443 stay open for the reverse proxy you put in front; they carry no traffic until you add one.
CPU-only boundary#
This template spawns CPU-only scipy notebook containers. It does not provision GPUs, CUDA drivers, or GPU-backed notebook images. For GPU inference, RAG pipelines, or model serving, use the inference gateway template or the AI inference and RAG deployment walkthrough.
When to use this pattern#
Run a multi-user notebook server for analysts and data scientists on a VM you operate. JupyterHub suits exploratory analysis, shared CPU notebooks, and teaching environments where each user needs an isolated scipy stack.
For a single-user notebook or a lightweight data app, consider Streamlit or a dev environment. For the warehouse and experiment-tracking layers that pair with notebooks, see self-managed PostgreSQL and the Notebook workbench architecture brief.
Estimated cost#
Monthly cost estimate
Pricing calculator ↗Sized as a custom package on shared vCPU.
Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.
What each resource is for
JupyterHub host
s1a.medium · 4 shared vCPU, 4 GiB RAM, 0.5 Gbps
Runs JupyterHub in Docker with DockerSpawner, spawning CPU-only scipy notebook containers per user. Hub state and notebook volumes live on an attached block volume.
JupyterHub plus one concurrent scipy notebook runs on 4 vCPU and 4 GiB RAM (s1a.medium). Size up as more users run notebooks at the same time.
Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.
Included in baseline
s1a.medium
4 shared vCPU, 4 GiB RAM, 0.5 Gbps
Compute + RAM rate basis
4 vCPU + 4 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.
Block storage (60 GiB)
60 GiB at $0.08/GiB/mo
Public IP (included)
1 included with the custom package
Package promotional discount
Flat −$5.00/mo on the custom package (same promotion as named plans).
Included at no charge
These line items are zero on Quake AI. Many other providers meter them separately.
Data transfer (inbound and outbound)
Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.
AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.
Learn morePrivate networking
Private networks, subnets, Neutron routers, and security groups are included with the plan.
VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.
Control-plane API requests
OpenStack API calls for provisioning and management are included.
Some managed services on other clouds meter API calls or charge for premium control-plane features.
Pricing data last validated: . For current rates, check quake.ai/pricing.
Template source#
Show source (7 files)Hide source
data "openstack_images_image_v2" "os" {
name = var.image_name
most_recent = true
}
data "openstack_networking_network_v2" "external" {
name = var.external_network
}
resource "openstack_networking_network_v2" "private" {
name = "${var.app_name}-net"
admin_state_up = true
}
resource "openstack_networking_subnet_v2" "private" {
name = "${var.app_name}-subnet"
network_id = openstack_networking_network_v2.private.id
cidr = var.private_cidr
ip_version = 4
dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}
resource "openstack_networking_router_v2" "main" {
name = "${var.app_name}-router"
external_network_id = data.openstack_networking_network_v2.external.id
}
resource "openstack_networking_router_interface_v2" "private" {
router_id = openstack_networking_router_v2.main.id
subnet_id = openstack_networking_subnet_v2.private.id
}
resource "openstack_networking_secgroup_v2" "jupyterhub" {
name = "${var.app_name}-sg"
description = "SSH and HTTP/HTTPS for a reverse proxy; JupyterHub port 8000 restricted"
}
resource "openstack_networking_secgroup_rule_v2" "ssh" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 22
port_range_max = 22
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.jupyterhub.id
}
# 80 and 443 carry the hub when it is served over a domain with automatic TLS
# through a reverse proxy (Caddy or Nginx). They are not used until you put a
# proxy in front of JupyterHub; see the reference page.
resource "openstack_networking_secgroup_rule_v2" "http" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 80
port_range_max = 80
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.jupyterhub.id
}
resource "openstack_networking_secgroup_rule_v2" "https" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 443
port_range_max = 443
remote_ip_prefix = "0.0.0.0/0"
security_group_id = openstack_networking_secgroup_v2.jupyterhub.id
}
# Raw hub HTTP on 8000 is restricted to hub_allowed_cidr (the private network by
# default). Accounts are created through JupyterHub's signup flow on first
# visit, but the port stays off the public internet by default. Prefer a domain
# with TLS on 443 for routine access.
resource "openstack_networking_secgroup_rule_v2" "hub" {
direction = "ingress"
ethertype = "IPv4"
protocol = "tcp"
port_range_min = 8000
port_range_max = 8000
remote_ip_prefix = var.hub_allowed_cidr
security_group_id = openstack_networking_secgroup_v2.jupyterhub.id
}
resource "openstack_networking_port_v2" "jupyterhub" {
name = "${var.app_name}-port"
network_id = openstack_networking_network_v2.private.id
security_group_ids = [openstack_networking_secgroup_v2.jupyterhub.id]
fixed_ip {
subnet_id = openstack_networking_subnet_v2.private.id
}
depends_on = [openstack_networking_router_interface_v2.private]
}
resource "openstack_blockstorage_volume_v3" "data" {
name = "${var.app_name}-data"
size = var.volume_size
}
resource "openstack_compute_instance_v2" "jupyterhub" {
name = var.app_name
flavor_name = var.flavor_name
key_pair = var.key_name
user_data = templatefile("${path.module}/cloud-init/jupyterhub.yaml.tftpl", {
app_name = var.app_name
notebook_image = var.notebook_image
})
block_device {
uuid = data.openstack_images_image_v2.os.id
source_type = "image"
destination_type = "volume"
volume_size = 30
boot_index = 0
delete_on_termination = true
}
network {
port = openstack_networking_port_v2.jupyterhub.id
}
}
resource "openstack_compute_volume_attach_v2" "data" {
instance_id = openstack_compute_instance_v2.jupyterhub.id
volume_id = openstack_blockstorage_volume_v3.data.id
}
resource "openstack_networking_floatingip_v2" "jupyterhub" {
pool = var.external_network
}
resource "openstack_networking_floatingip_associate_v2" "jupyterhub" {
floating_ip = openstack_networking_floatingip_v2.jupyterhub.address
port_id = openstack_networking_port_v2.jupyterhub.id
}
variable "key_name" {
description = "SSH keypair name (must already exist in your project)"
type = string
}
variable "flavor_name" {
description = "Instance size. JupyterHub plus one scipy notebook container runs comfortably on 4 vCPU and 4 GiB RAM (s1a.medium). Size up as more users run notebooks concurrently."
type = string
default = "s1a.medium"
}
variable "image_name" {
description = "Operating system image. Ubuntu 24.04 is the recommended base."
type = string
default = "Ubuntu-24.04"
}
variable "app_name" {
description = "Display name prefix for compute and network resources"
type = string
default = "jupyterhub"
}
variable "volume_size" {
description = "Block volume size in GiB, mounted at /var/lib/docker so the hub database and per-user notebook volumes live on a volume you can grow rather than on the boot disk."
type = number
default = 30
}
variable "external_network" {
description = "Shared external network for router gateway and floating IPs; defaults to PublicStatic (persisted FIP / production pattern). Override with PublicEphemeral for ephemeral demos."
type = string
default = "PublicStatic"
}
variable "private_cidr" {
description = "CIDR for the private tenant network the instance lives in"
type = string
default = "10.40.0.0/24"
}
variable "hub_allowed_cidr" {
description = "CIDR allowed to reach the JupyterHub login on port 8000. Defaults to the private network only, so the raw hub stays off the public internet. Reach it over an SSH tunnel, or (recommended) serve it over a domain with HTTPS on 443 behind a reverse proxy. To allow direct access from your workstation, set this to YOUR_IP/32."
type = string
default = "10.40.0.0/24"
}
variable "notebook_image" {
description = "Docker image for per-user notebook servers spawned by JupyterHub (CPU-only scipy stack by default)."
type = string
default = "quay.io/jupyter/scipy-notebook:2024-10-14"
}
output "instance_id" {
description = "ID of the compute instance running JupyterHub"
value = openstack_compute_instance_v2.jupyterhub.id
}
output "floating_ip" {
description = "Public floating IP address of the JupyterHub host"
value = openstack_networking_floatingip_v2.jupyterhub.address
}
output "private_ip" {
description = "Private IP address of the instance"
value = openstack_compute_instance_v2.jupyterhub.access_ip_v4
}
output "hub_url" {
description = "JupyterHub login URL on port 8000. Reachable from hub_allowed_cidr (the private network by default; tunnel over SSH, or put a reverse proxy in front and use HTTPS on 443)."
value = "http://${openstack_networking_floatingip_v2.jupyterhub.address}:8000"
}
terraform {
required_version = ">= 1.6.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 2.0"
}
}
}
provider "openstack" {}
# Required: SSH keypair must already exist in your project
key_name = "YOUR_KEY_NAME"
# Recommended: restrict the hub (port 8000) to your workstation IP.
# Leave unset to keep 8000 reachable only from the private network and tunnel
# over SSH, or put a reverse proxy in front and use HTTPS on 443.
# hub_allowed_cidr = "203.0.113.10/32"
# flavor_name = "s1a.medium"
# image_name = "Ubuntu-24.04"
# app_name = "jupyterhub"
# volume_size = 30
# external_network = "PublicStatic"
# private_cidr = "10.40.0.0/24"
# notebook_image = "quay.io/jupyter/scipy-notebook:2024-10-14"
#cloud-config
package_update: true
packages:
- ca-certificates
- curl
write_files:
- path: /opt/jupyterhub/Dockerfile
permissions: "0644"
content: |
FROM jupyterhub/jupyterhub:5.2.1
RUN pip install --no-cache-dir \
dockerspawner \
jupyterhub-nativeauthenticator
- path: /opt/jupyterhub/jupyterhub_config.py
permissions: "0644"
content: |
import os
c.JupyterHub.spawner_class = "dockerspawner.DockerSpawner"
c.DockerSpawner.image = os.environ.get(
"DOCKER_JUPYTER_IMAGE", "${notebook_image}"
)
c.DockerSpawner.notebook_dir = "/home/jovyan/work"
c.DockerSpawner.volumes = {
"jupyterhub-user-{username}": "/home/jovyan/work"
}
c.DockerSpawner.remove_containers = True
c.DockerSpawner.use_internal_ip = True
c.DockerSpawner.network_name = "jupyterhub-net"
c.JupyterHub.hub_ip = "jupyterhub"
c.JupyterHub.hub_port = 8081
c.JupyterHub.bind_url = "http://:8000"
c.JupyterHub.cookie_secret_file = "/srv/jupyterhub/jupyterhub_cookie_secret"
c.JupyterHub.db_url = "sqlite:////srv/jupyterhub/jupyterhub.sqlite"
c.JupyterHub.authenticator_class = "nativeauthenticator.NativeAuthenticator"
c.NativeAuthenticator.open_signup = True
c.NativeAuthenticator.ask_email_on_signup = True
c.NativeAuthenticator.minimum_password_length = 10
c.NativeAuthenticator.check_common_password = True
c.NativeAuthenticator.enable_signup_without_email = False
- path: /opt/jupyterhub/docker-compose.yml
permissions: "0644"
content: |
# JupyterHub for ${app_name}. Multi-user CPU notebooks via DockerSpawner.
# Register the admin account on first visit; no credential ships with this
# template. Hub state and per-user notebook volumes live under /var/lib/docker
# on the attached data volume.
services:
jupyterhub:
build: .
container_name: jupyterhub
restart: unless-stopped
command: ["jupyterhub", "-f", "/etc/jupyterhub/jupyterhub_config.py"]
ports:
- "8000:8000"
volumes:
- jupyterhub-hub-data:/srv/jupyterhub
- /var/run/docker.sock:/var/run/docker.sock
- ./jupyterhub_config.py:/etc/jupyterhub/jupyterhub_config.py:ro
environment:
DOCKER_JUPYTER_IMAGE: ${notebook_image}
networks:
- jupyterhub-net
networks:
jupyterhub-net:
name: jupyterhub-net
volumes:
jupyterhub-hub-data:
runcmd:
- |
set -e
# The data volume attaches as /dev/sdb on this platform (not /dev/vdb).
# Mount it at /var/lib/docker before Docker is installed so the hub database
# and per-user notebook volumes live on the resizable volume rather than the
# boot disk.
DEV=/dev/sdb
for i in $(seq 1 30); do [ -b "$DEV" ] && break; sleep 5; done
if ! blkid "$DEV" >/dev/null 2>&1; then mkfs.ext4 -F -L jupyterdata "$DEV"; fi
mkdir -p /var/lib/docker
mount "$DEV" /var/lib/docker
grep -q "$DEV" /etc/fstab || echo "$DEV /var/lib/docker ext4 defaults,nofail 0 2" >> /etc/fstab
# Install Docker Engine plus the compose plugin from Docker's convenience
# script, build the hub image, and bring JupyterHub up from the compose file.
curl -fsSL https://get.docker.com | sh
cd /opt/jupyterhub
docker compose build
docker compose up -d
# JupyterHub notebook server
Single compute instance running [JupyterHub](https://jupyterhub.readthedocs.io), a multi-user notebook server, on infrastructure you control. After apply, you open the hub, register the admin account, and spawn CPU-only scipy notebook containers for each user.
**Network class:** production — `external_network` defaults to `PublicStatic` for persisted floating IPs and multi-tier stacks; override with `PublicEphemeral` for ephemeral demos.
The instance provisions a private network, a floating IP, and a block volume mounted at `/var/lib/docker` so the hub database and per-user notebook volumes live on a resizable volume. cloud-init installs Docker Engine, builds a JupyterHub image with DockerSpawner, and starts the hub from a compose file on first boot.
## Where this fits
JupyterHub is the analyst and data-scientist front door for notebooks on the platform: a self-hosted alternative to Google Colab or Deepnote at the CPU tier. It runs multi-user notebooks on a VM you own, which keeps notebooks and hub state on your infrastructure.
This template is CPU-only. It does not provision GPUs or run GPU training workloads. For GPU inference or RAG pipelines, see the [inference gateway template](/resources/iac-templates/inference-gateway) or the [AI inference and RAG deployment walkthrough](/resources/deployments/deploy-inference-gateway-template).
## Prerequisites
- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- An existing SSH keypair in your project (the value of `key_name` must match that keypair)
## Resource baseline
JupyterHub plus one concurrent scipy notebook container runs on 4 vCPU and 4 GiB RAM. The default `s1a.medium` flavor leaves headroom for the hub and one active user. Size up as more users run notebooks at the same time.
## Usage
1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`
After apply, cloud-init takes several minutes to install Docker, build the hub image, and start JupyterHub on first boot. Then reach `hub_url` from the outputs and register the admin account on the signup page. No credential ships with this template.
## Hub access and security
The hub listens on port 8000 over plain HTTP. The security group restricts 8000 to `hub_allowed_cidr`, which defaults to the private network only, so the raw hub is not exposed to the public internet. JupyterHub's NativeAuthenticator gates access: users register accounts through the signup page. Reach the hub one of three ways:
- **Recommended:** put a reverse proxy (Caddy or Nginx) in front of JupyterHub and serve the hub over HTTPS on 443. Point the domain's DNS A record at `floating_ip`.
- **SSH tunnel:** `ssh -L 8000:localhost:8000 ubuntu@<floating_ip>`, then open `http://localhost:8000`.
- **Direct, scoped:** set `hub_allowed_cidr` to your workstation IP (`YOUR_IP/32`) to reach 8000 directly from one address.
Ports 80 and 443 stay open for the reverse proxy you put in front; they carry no traffic until you add one.
## Notebook servers
JupyterHub spawns per-user notebook containers from `notebook_image` (default: `quay.io/jupyter/scipy-notebook:2024-10-14`, a CPU-only scipy stack). Each user's home directory and notebooks persist in a Docker volume on the data volume. This template does not include GPU images or CUDA drivers.
## How the instance is provisioned
cloud-init:
1. Mounts the data volume at `/var/lib/docker` (formatting it on first boot) and adds an `/etc/fstab` entry so it persists across reboots.
2. Writes `/opt/jupyterhub/Dockerfile`, `/opt/jupyterhub/jupyterhub_config.py`, and `/opt/jupyterhub/docker-compose.yml`.
3. Installs Docker Engine from `https://get.docker.com`, builds the hub image, and runs `docker compose up -d`, which starts JupyterHub on port 8000.
## Variables
| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | SSH keypair name (must already exist in your project) |
| `flavor_name` | string | no | `s1a.medium` | Instance size (hub plus one scipy notebook on 4 vCPU / 4 GiB) |
| `image_name` | string | no | `Ubuntu-24.04` | Operating system image |
| `app_name` | string | no | `jupyterhub` | Display name prefix for resources |
| `volume_size` | number | no | `30` | Block volume size in GiB, mounted at `/var/lib/docker` |
| `external_network` | string | no | `PublicStatic` | Persisted FIP / production default; override with `PublicEphemeral` for demos |
| `private_cidr` | string | no | `10.40.0.0/24` | CIDR for the private subnet |
| `hub_allowed_cidr` | string | no | `10.40.0.0/24` | CIDR allowed to reach the hub on port 8000 |
| `notebook_image` | string | no | `quay.io/jupyter/scipy-notebook:2024-10-14` | CPU-only notebook image for spawned servers |
## Outputs
| Name | Description |
| --- | --- |
| `floating_ip` | Public floating IP assigned to the instance |
| `private_ip` | Private IP address of the instance |
| `hub_url` | JupyterHub login URL on port 8000 |
| `instance_id` | Compute instance ID |
## Scope
This is a single-VM JupyterHub host that you operate, not a managed notebook cloud. It is CPU-only and runs in one region. For GPU training or large-scale distributed notebooks, use an external GPU backend or a dedicated cluster; this template does not provision GPUs.
## Documentation
See also: [self-managed PostgreSQL](/resources/iac-templates/self-managed-postgres), [S3-compatible object storage](/resources/iac-templates/s3-storage-acl)
Resources, parameters, and variables
key_namerequiredflavor_name="s1a.medium"image_name="Ubuntu-24.04"app_name="jupyterhub"volume_size=30external_network="PublicStatic"private_cidr="10.40.0.0/24"hub_allowed_cidr="10.40.0.0/24"notebook_image="quay.io/jupyter/scipy-notebook:2024-10-14"
Customize this pattern#
- Customize a template's image and flavor
- Add a block volume to a template
- Parameterize a template with a tfvars file
See also#
Usage Guidelines
The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.
For the full policy, see Usage Guidelines.
Quick answers
- Why does `openstack image save` write a 0-byte file for my boot-from-volume instance?CLI
- Why does `openstack server create` fail with "Only volume-backed servers are allowed for flavors with zero disk"?CLIAPITerraform
- Why does my project still have a 10 GiB Cinder volume after I deleted my instance?CLIAPI
See Also
Terraform and OpenTofu on Quake AI
Prerequisite
Networks
Prerequisite
Authoring IaC templates for Quake AI
Shares: Volumes, Security Groups
Deploy an API gateway with the api-gateway template
Shares: Volumes, Security Groups
Deploy a regional edge cache with the edge-cache template
Shares: Volumes, Security Groups