Skip to content

JupyterHub notebook server

Template

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#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist)No default
flavor_nameInstance size (hub plus one scipy notebook on 4 vCPU / 4 GiB)s1a.medium
image_nameOperating system imageUbuntu-24.04
app_nameDisplay name prefix for resourcesjupyterhub
volume_sizeBlock volume size in GiB, mounted at /var/lib/docker30
external_networkExternal network for floating IP allocationPublicStatic
private_cidrCIDR for the private subnet10.40.0.0/24
hub_allowed_cidrCIDR allowed to reach the hub on port 800010.40.0.0/24
notebook_imageCPU-only Docker image for per-user notebook serversquay.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 open http://localhost:8000.
  • Set hub_allowed_cidr to YOUR_IP/32 to 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.

Starting template$32.80/mo

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.

$33.00/mo

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

$33.00

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

$4.80

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

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 more
$0.00

Private 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.

$0.00

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.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

7 files. Download the zip or expand to copy any file.Download jupyterhub.zip
Show source (7 files)
main.tfHCL
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
}
variables.tfHCL
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"
}
outputs.tfHCL
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"
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
  }
}

provider "openstack" {}
terraform.tfvars.exampleHCL
# 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-init/jupyterhub.yaml.tftpl
#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
README.mdMarkdown
# 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
Provisions
Parameterized by
Variables
  • key_namerequired
  • 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"
  • hub_allowed_cidr="10.40.0.0/24"
  • notebook_image="quay.io/jupyter/scipy-notebook:2024-10-14"

Customize this pattern#

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

Was this page helpful?