# Deploy JupyterHub with the jupyterhub template

Source: https://docs.quake.ai/resources/deployments/deploy-jupyterhub-template
Markdown: https://docs.quake.ai/resources/deployments/deploy-jupyterhub-template.md

---

# Deploy JupyterHub with the jupyterhub template

Stand up [JupyterHub](https://jupyterhub.readthedocs.io), a multi-user notebook server, on a single Quake AI instance using the [validated OpenTofu template](/docs/platform/validation#how-infrastructure-templates-are-checked) `jupyterhub`. You apply the template, reach the hub over the floating IP, register the admin account, spawn a CPU-only scipy notebook, and put a reverse proxy in front so the hub runs over HTTPS.

JupyterHub is the analyst and data-scientist front door for notebooks on the platform. You run it yourself; this is a self-hosted tool you operate, not a managed service.

<Figure size="md" caption="What you'll build: a JupyterHub host on a single instance, spawning per-user scipy notebook containers, reached over HTTPS through a Caddy reverse proxy">

```d2
direction: right

dev: You {shape: person}
domain: Your domain\n(DNS A record)
fip: Floating IP
instance: Ubuntu instance {
  caddy: Caddy\nreverse proxy
  hub: JupyterHub\nlogin + spawner
  notebook: scipy notebook\nper user
  hub -> notebook: DockerSpawner
  caddy -> hub: proxies 443 to 8000
}

dev -> domain: HTTPS hub
domain -> fip
fip -> instance.caddy
```

</Figure>

<PricingCompanion
  components={[
    { kind: "template", slug: "jupyterhub", required: true },
  ]}
/>

## Prerequisites

You need:

- OpenTofu 1.6.0 or later (or Terraform 1.6.0 or later) installed locally.
- Your OpenStack credentials sourced into the shell (`source openrc.sh`). See [the OpenStack CLI guide](/docs/tools/openstack-cli).
- An SSH keypair that already exists in your project. Record its name for the `key_name` variable.
- A copy of the `jupyterhub` template directory from [the template reference page](/resources/iac-templates/jupyterhub).
- Your workstation's public IP address, so you can open the hub port to it for first-boot setup. Find it with `curl -sS https://api.ipify.org`.

A domain is optional for first boot. You add it in step 4 to serve the hub over HTTPS.

## Step 1: Set the variables and apply the template

The hub listens on port 8000 over plain HTTP. The template's security group restricts port 8000 to `hub_allowed_cidr`, which defaults to the private network only, so the raw hub stays off the public internet. To reach the hub from your workstation for first-boot setup, set `hub_allowed_cidr` to your own address.

Copy the template's example variables file and open it:

```bash
cp terraform.tfvars.example terraform.tfvars
```

Set `key_name` to the SSH keypair already in your project, and `hub_allowed_cidr` to your workstation's public IP with a `/32` suffix:

```hcl
key_name          = "YOUR_KEY_NAME"
hub_allowed_cidr  = "YOUR_IP/32"
```



If you would rather not expose port 8000 at all, leave `hub_allowed_cidr` at its default and reach the hub over an SSH tunnel instead: `ssh -L 8000:localhost:8000 ubuntu@YOUR_FLOATING_IP`, then open `http://localhost:8000`. Once you add a domain in step 4, Caddy serves the hub over HTTPS on port 443 and you no longer need port 8000 open.



Initialize the working directory, preview the plan, and apply:

```bash
tofu init
tofu plan
tofu apply
```

OpenTofu provisions a private network, a router, a security group, a block volume mounted at `/var/lib/docker`, an instance, and a floating IP. On first boot, cloud-init mounts the data volume, installs Docker Engine, builds the JupyterHub image, and starts the hub on port 8000.

When the apply finishes, read the outputs:

```bash
tofu output
```

Record `floating_ip` and `hub_url`.

## Step 2: Register the admin account

JupyterHub does not ship a default password. You register the admin account through the signup page on first visit.

cloud-init takes several minutes after the instance reaches `ACTIVE` (Docker image build included). Open `hub_url` (for example `http://YOUR_FLOATING_IP:8000`) in your browser. If the page does not load yet, wait and retry; you can watch the hub container start over SSH:

```bash
ssh ubuntu@YOUR_FLOATING_IP "sudo docker ps --filter name=jupyterhub"
```

When the signup page appears, enter a username, email, and strong password. This account administers the hub. JupyterHub signs you in and opens the control panel where you can spawn a notebook server.



Until you attach a domain in step 4, the hub is served over unencrypted HTTP on port 8000, reachable only from `hub_allowed_cidr`. Avoid sending production credentials over it from a shared or public network. Adding a domain (step 4) moves the hub to HTTPS on port 443.



## Step 3: Spawn a notebook and run code

From the JupyterHub control panel, select **Start My Server**. JupyterHub pulls the scipy notebook image (if not already cached) and starts a CPU-only notebook container for your user.

When the server is ready, JupyterHub opens JupyterLab. Create a new Python notebook and run a quick check:

```python
import numpy as np
import pandas as pd

df = pd.DataFrame({"x": np.arange(5), "y": np.arange(5) ** 2})
df
```

Confirm the cell returns a small table. Your notebooks and files persist in a Docker volume on the data volume, so they survive hub restarts.



This template spawns CPU-only scipy notebook containers. It does not provision GPUs or run GPU training workloads. For GPU inference or RAG pipelines, see the [inference gateway deployment walkthrough](/resources/deployments/deploy-inference-gateway-template).



## Step 4: Serve the hub over HTTPS with Caddy

The template leaves ports 80 and 443 open for a reverse proxy. [Caddy](https://caddyserver.com) obtains and renews a TLS certificate automatically once a domain resolves to the instance.

1. Create a DNS **A record** for your domain (for example `notebooks.example.com`) pointing at `YOUR_FLOATING_IP`. Follow [How to point a domain at a Quake AI resource](/docs/network/how-to/point-domain-to-quake-ai). Wait until the record resolves:

```bash
dig +short notebooks.example.com
```

The command returns your floating IP once the record propagates.

2. SSH to the instance and add a Caddy service that proxies HTTPS to JupyterHub on port 8000. Create `/opt/jupyterhub/Caddyfile`:

```text
notebooks.example.com {
  reverse_proxy 127.0.0.1:8000
}
```

3. Add Caddy to the compose file at `/opt/jupyterhub/docker-compose.yml` so it runs alongside JupyterHub:

```yaml
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    network_mode: host
    volumes:
      - /opt/jupyterhub/Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
volumes:
  caddy_data:
```

4. Apply the changes and confirm both containers run:

```bash
cd /opt/jupyterhub
sudo docker compose up -d
sudo docker compose ps
```

Open `https://notebooks.example.com` and confirm the padlock. For background on certificate issuance and renewal, see [How to issue and auto-renew a TLS certificate with Let's Encrypt](/docs/network/how-to/lets-encrypt-certificate). Once HTTPS works, close direct access to port 8000 by setting `hub_allowed_cidr` back to the private network in `terraform.tfvars` and running `tofu apply`.

## What you built

- **Applied the `jupyterhub` template** to provision a network, security group, data volume, instance, and floating IP, and let cloud-init install Docker and start JupyterHub
- **Registered the admin account** on the hub's signup page
- **Spawned a CPU-only scipy notebook** and ran Python code in JupyterLab
- **Served the hub over HTTPS** by pointing a domain at the floating IP and routing it through a Caddy reverse proxy

## Scope of this deployment

This template runs a single-VM JupyterHub host, not a managed notebook cloud. The instance is CPU-only and runs in one region. You operate the instance, Docker, JupyterHub, and the data volume yourself: back them up, patch them, and watch resource use as more users spawn notebooks concurrently. For GPU training or large-scale distributed notebooks, use an external GPU backend; this template does not provision GPUs.

## Next steps

- [JupyterHub template](/resources/iac-templates/jupyterhub): the template reference, parameters, and resource map
- [self-managed PostgreSQL template](/resources/iac-templates/self-managed-postgres): a warehouse to query from notebooks
- [Deploy an inference gateway with OpenTofu](/resources/deployments/deploy-inference-gateway-template): GPU inference and RAG behind an OpenAI-compatible endpoint
- [How to store application secrets and inject them at runtime](/docs/security/how-to/inject-app-secrets): move database and API credentials out of notebook cells
- [Security hardening checklist](/docs/security/hardening-checklist): tighten SSH access and exposure before you serve real traffic

## Clean up

When you no longer need the deployment, destroy everything the template created:

```bash
tofu destroy
```

Then remove the DNS A record you created in step 4. Because JupyterHub, user notebooks, and the hub database all live on the instance and its attached volume, `tofu destroy` removes them along with the infrastructure. Export any notebooks you want to keep before you destroy.
