JupyterLab on Ubuntu 24.04 (Azure) User Guide
Overview
JupyterLab is the interactive development environment behind most of the world's data science and research computing. It gives you a browser-based workspace for notebooks, code, data and visualisations — an editor, a file browser, a terminal, rich output and live charts, all in one window — so you can load a dataset, explore it, chart it and share the result without installing anything locally.
A notebook server executes arbitrary code as a feature, which is exactly why an exposed or weakly protected one is a real risk: automated scanners hunt for open Jupyter servers. This cloudimg image is built around that fact. JupyterLab 4.6.2 runs as a dedicated unprivileged account, bound to the loopback interface only, and is published solely through an nginx TLS reverse proxy on :443. Token credentials that travel in the address bar are switched off; instead the first boot of every VM generates a long password unique to that VM, stores only its hash, and records the password in a root-only file. If that first boot has not run, the server refuses to start rather than come up unauthenticated.
What is included:
- JupyterLab 4.6.2 (BSD-3-Clause) in a Python 3.12 virtual environment at
/opt/jupyterlab/venv, bound to loopback127.0.0.1:8888 - The standard scientific kernel stack already installed —
ipykernel,ipywidgets,numpy,pandas,matplotlib,requests - nginx TLS reverse proxy on
:443, using a certificate minted for your own VM at first boot - A dedicated 64 GiB Azure managed data disk mounted at
/var/lib/jupyter, holding the notebook root, kernel state and any packages you add - A password unique to this VM, generated at first boot and written to
/root/jupyterlab-credentials.txt jupyterlab.serviceandnginx.serviceas systemd units, enabled and active, with the notebook server confined byNoNewPrivileges,ProtectSystem=strict,ProtectHomeandPrivateTmp- 24/7 cloudimg support
Connecting to your VM
Connect over SSH as azureuser (the administrator account you configured when creating the VM), using the SSH key pair you selected at launch:
ssh -i /path/to/your-key azureuser@<public-ip>
Prerequisites
An Azure subscription, an SSH key pair, and a virtual network subnet in your target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a good starting point; choose a larger or memory-optimised size (for example Standard_D2s_v5 or Standard_E2s_v5) for heavier data work where you load large frames into memory. Network security group inbound: allow 22/tcp from your management network and 443/tcp from the people who need the workspace. Nothing else needs to be open — the notebook server itself is never exposed directly.
Because anyone who signs in can execute code on the VM, treat 443/tcp as an administrative port: restrict it to your own networks or reach it over a VPN or SSH tunnel rather than publishing it to the internet.
Step 1 — Launch from the Azure Marketplace
Find JupyterLab on Ubuntu 24.04 by cloudimg in the Azure Marketplace, choose Get It Now, then Create. Pick your subscription, resource group and region, a VM size (Standard_B2s or larger), your SSH public key for the azureuser account, and a network security group that allows 22/tcp and 443/tcp, then review and create.
Step 2 — Launch from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name jupyterlab \
--image <marketplace-image-urn> \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_rsa.pub \
--public-ip-sku Standard
az vm open-port --resource-group <your-rg> --name jupyterlab --port 443 --priority 900
The dedicated workspace data disk is captured inside the image, so it is re-provisioned automatically on every VM you launch — there is no separate disk to attach.
Step 3 — Confirm the services are running
JupyterLab and nginx come up automatically once first boot has completed.
systemctl is-active jupyterlab.service nginx.service | tr '\n' ' ' | grep -q 'active active' \
|| { echo "FATAL: jupyterlab and nginx are not both active"; exit 1; }
ss -tlnH | awk '{print $4}' | grep -qx '127.0.0.1:8888' \
|| { echo "FATAL: the notebook server is not bound to loopback"; exit 1; }
ss -tlnH | awk '{print $4}' | grep -q ':443$' \
|| { echo "FATAL: nginx is not listening on 443"; exit 1; }
echo "jupyterlab + nginx active; notebook server on loopback 127.0.0.1:8888; TLS on :443"
The notebook server on 8888 is bound to 127.0.0.1 and is never reachable from the network. Only nginx on :443 is exposed, and it is the sole route to the workspace. Your workspace lives on its own data disk, separate from the operating system disk:

Step 4 — Confirm first boot minted this VM's own certificate
Every VM mints its own TLS certificate on first boot, with its own address in the subject alternative name. Nothing is shared between deployments:
test -f /var/lib/cloudimg/jupyterlab-firstboot.done \
|| { echo "FATAL: first boot has not completed on this VM"; exit 1; }
openssl x509 -noout -subject -dates -ext subjectAltName -in /etc/nginx/tls/cert.pem \
|| { echo "FATAL: no per-VM TLS certificate found"; exit 1; }
The certificate and key live in /etc/nginx/tls, readable by root only. The image itself ships with no certificate and no key at all — they exist only after your VM has booted.
Step 5 — Retrieve the password generated for your VM
There is no shared default password. Read the root-only credentials file over SSH:
sudo cat /root/jupyterlab-credentials.txt
The file is mode 0600 root:root and contains this VM's URL and its password. Note the password somewhere safe. The check below confirms the file is present, root-only and populated without printing the secret:
test -f /root/jupyterlab-credentials.txt \
|| { echo "FATAL: the credentials file is missing"; exit 1; }
stat -c 'credentials file: mode %a owner %U:%G' /root/jupyterlab-credentials.txt
awk -F= '/^jupyterlab\.password=/ { if (length($2) >= 28) { print "per-VM password recorded: " length($2) " characters"; found=1 } }
END { if (!found) { print "FATAL: no per-VM password recorded"; exit 1 } }' \
/root/jupyterlab-credentials.txt
The certificate is minted for this VM and the credentials file is root-only — no key material or password is baked into the image:

Step 6 — Sign in over HTTPS
Browse to https://<public-ip>/. The certificate is self-signed and minted for your VM, so your browser warns on the first visit — accept it, or install your own certificate (Step 12). You are presented with the JupyterLab sign-in page. There is no username: enter the password from Step 5.

After signing in you land in the JupyterLab workspace. The Launcher offers a Python 3 notebook, a console, a terminal and plain text or Markdown files; the left-hand panel is the file browser for /var/lib/jupyter/notebooks on your dedicated workspace disk.

Step 7 — Run your first notebook
Select Python 3 (ipykernel) under Notebook in the Launcher. A new notebook opens with an empty cell and an idle kernel. Paste the following into the first cell and press Shift + Enter to run it — the scientific stack is already installed, so nothing needs downloading:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(7)
quarters = pd.period_range("2024Q1", periods=8, freq="Q")
regions = ["North", "South", "East", "West"]
revenue = pd.DataFrame(
rng.integers(180, 460, size=(len(quarters), len(regions))),
index=quarters.astype(str),
columns=regions,
)
revenue["Total"] = revenue.sum(axis=1)
revenue
The DataFrame renders as a formatted table directly under the cell.

Add a second cell to chart it. Plots render inline in the notebook, and the notebook file is saved onto the workspace disk:
fig, ax = plt.subplots(figsize=(9, 4.2))
revenue[regions].plot(kind="bar", stacked=True, ax=ax, width=0.75)
ax.set_title("Revenue by region and quarter (thousands)")
ax.set_xlabel("Quarter")
ax.set_ylabel("Revenue (k)")
ax.legend(title="Region", ncols=4, loc="upper left")
ax.grid(axis="y", alpha=0.3)
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

Step 8 — Prove the security contract end to end
The image ships a probe that exercises the whole authentication and execution chain against the live HTTPS endpoint on this VM. It proves that unauthenticated access is refused, that a wrong password is refused and yields no usable session, that this VM's own password signs in, and that a real kernel starts and executes code over the encrypted connection:
/opt/jupyterlab/venv/bin/python /usr/local/lib/cloudimg/jupyterlab-kernel-probe.py \
|| { echo "FATAL: the security and kernel round-trip probe did not pass"; exit 1; }
It reads the password from the root-only credentials file itself and never prints it. Run it any time you want to confirm the VM is healthy — after a certificate change, after a password change, or as a smoke test following an upgrade.

Step 9 — Confirm the confined runtime
The notebook server runs as the unprivileged jupyter account with no sudo rights, and systemd confines it: privilege escalation is blocked, the filesystem is read-only apart from the workspace disk, /home is hidden and /tmp is private to the service.
MAINPID="$(systemctl show -p MainPID --value jupyterlab.service)"
ps -o user= -p "$MAINPID" | tr -d ' ' | grep -qx jupyter \
|| { echo "FATAL: the notebook server is not running as the jupyter account"; exit 1; }
tr '\0' ' ' < "/proc/${MAINPID}/cmdline" | grep -q -- '--allow-root' \
&& { echo "FATAL: the notebook server is running with --allow-root"; exit 1; }
grep -rq '^jupyter[[:space:]]' /etc/sudoers /etc/sudoers.d/ 2>/dev/null \
&& { echo "FATAL: the jupyter account has sudo rights"; exit 1; }
systemctl show jupyterlab.service -p NoNewPrivileges -p ProtectSystem -p ProtectHome -p PrivateTmp -p ReadWritePaths
echo "notebook server runs unprivileged and confined"
Because the application tree is read-only at runtime, code you run in a notebook cannot modify the interpreter it runs on. That is deliberate — see Step 11 for the supported way to add packages.
Step 10 — The dedicated workspace disk
Notebooks, datasets, kernel state and any packages you add live on their own Azure managed data disk, mounted by filesystem UUID so it reproduces identically on every VM launched from this image:
findmnt -no SOURCE,SIZE,TARGET /var/lib/jupyter \
|| { echo "FATAL: the workspace disk is not mounted"; exit 1; }
[ "$(findmnt -no SOURCE /)" != "$(findmnt -no SOURCE /var/lib/jupyter)" ] \
|| { echo "FATAL: the workspace shares a device with the operating system disk"; exit 1; }
grep -q ' /var/lib/jupyter ' /etc/fstab \
|| { echo "FATAL: the workspace disk is not recorded in /etc/fstab"; exit 1; }
echo "workspace disk mounted separately from the OS disk and persisted in /etc/fstab"
You can confirm the same thing from inside JupyterLab. Open Terminal from the Launcher — it gives you a shell as the unprivileged jupyter account, in the notebook directory on the workspace disk.
To grow the workspace, expand the managed data disk in the Azure portal (or with az disk update --size-gb), then extend the filesystem on the VM with sudo resize2fs $(findmnt -no SOURCE /var/lib/jupyter).
Step 11 — Add Python packages and kernels
The application virtual environment is read-only to the service at runtime, so packages you add go onto the workspace disk instead, at /var/lib/jupyter/site-packages. That directory is already on the kernel's import path, and because it lives on the workspace disk your additions survive independently of the operating system disk.
Install a package over SSH, then restart the kernel from the notebook's Kernel menu:
sudo /opt/jupyterlab/venv/bin/pip install --target=/var/lib/jupyter/site-packages scikit-learn
sudo chown -R jupyter:jupyter /var/lib/jupyter/site-packages
To add a whole extra kernel — a second Python environment, or a different language — create it as its own virtual environment and register its kernel spec into the workspace data directory:
sudo python3 -m venv /var/lib/jupyter/envs/myenv
sudo /var/lib/jupyter/envs/myenv/bin/pip install ipykernel
sudo /var/lib/jupyter/envs/myenv/bin/python -m ipykernel install \
--prefix=/var/lib/jupyter/.local --name myenv --display-name "Python (myenv)"
sudo chown -R jupyter:jupyter /var/lib/jupyter/envs /var/lib/jupyter/.local
sudo systemctl restart jupyterlab
The new kernel appears in the Launcher and in the notebook kernel picker.
Step 12 — Change the password and replace the certificate
To set your own password, generate a hash with JupyterLab's own hashing helper (it prompts for the password twice and never echoes it), write it to the hash file and restart:
sudo /opt/jupyterlab/venv/bin/python -c "from jupyter_server.auth import passwd; print(passwd())" \
| sudo tee /etc/jupyterlab/hashed_password
sudo chown root:jupyter /etc/jupyterlab/hashed_password
sudo chmod 640 /etc/jupyterlab/hashed_password
sudo systemctl restart jupyterlab
Only the hash is stored. If the hash file is ever emptied, jupyterlab.service refuses to start rather than serve an unauthenticated workspace — that is the intended behaviour, not a fault.
The image serves HTTPS immediately using the self-signed certificate minted for your VM. For any real deployment, install a certificate for your VM's DNS name from your internal CA or a public issuer, and point nginx at it:
sudo cp your-fullchain.pem /etc/nginx/tls/cert.pem
sudo cp your-private-key.pem /etc/nginx/tls/key.pem
sudo chmod 600 /etc/nginx/tls/key.pem
sudo nginx -t && sudo systemctl reload nginx
Step 13 — Versions, backup and maintenance
Confirm what is installed:
/opt/jupyterlab/venv/bin/python -c "import importlib.metadata as m; [print(p, m.version(p)) for p in ('jupyterlab','jupyter-server','ipykernel','numpy','pandas','matplotlib')]" \
|| { echo "FATAL: could not read the installed package versions"; exit 1; }
/opt/jupyterlab/venv/bin/python -c "import importlib.metadata as m; md=m.metadata('jupyterlab'); lic=(md.get('License') or '')+' '+' '.join(c for c in (md.get_all('Classifier') or []) if c.startswith('License')); print('JupyterLab licence:', 'BSD' in lic and 'BSD 3-Clause' or lic)" \
| grep -q 'BSD 3-Clause' || { echo "FATAL: unexpected JupyterLab licence metadata"; exit 1; }
echo "JupyterLab is BSD-3-Clause, Copyright (c) Project Jupyter Contributors"
Everything you create lives under /var/lib/jupyter on the dedicated disk, so a snapshot of that managed disk is a complete backup of your work. Take one on a schedule from the Azure portal or the CLI, or copy the notebook tree elsewhere:
sudo tar -czf /var/backups/jupyter-notebooks-$(date +%F).tar.gz -C /var/lib/jupyter notebooks
The image keeps unattended security upgrades enabled, so the operating system receives patches automatically. Server configuration lives in /etc/jupyterlab/jupyter_server_config.py and the proxy configuration in /etc/nginx/sites-available/cloudimg-jupyterlab; restart with sudo systemctl restart jupyterlab nginx after any change. Service logs are in the journal:
sudo journalctl -u jupyterlab.service -n 50 --no-pager
sudo journalctl -u nginx.service -n 50 --no-pager
Support
Every cloudimg deployment includes 24/7 support. If you have any questions about this image, contact us through cloudimg.co.uk.