Deployment guide

JupyterLab

Overview

JupyterLab is the interactive development environment widely used for 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. 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, so an exposed or weakly protected one is a real risk. Automated scanners hunt for open Jupyter servers, and this SecureAnyCloud image is built around that fact.

JupyterLab 4.6.2 runs as a dedicated unprivileged account, bound to the loopback interface only. It 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 first boot has not run, the server refuses to start rather than come up unauthenticated.

What you get:

  • JupyterLab 4.6.2 (BSD-3-Clause) in a Python 3.12 virtual environment at /opt/jupyterlab/venv, bound to loopback 127.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 created 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.service and nginx.service as systemd units, enabled and active, with the notebook server confined by NoNewPrivileges, ProtectSystem=strict, ProtectHome and PrivateTmp
  • 24/7 SecureAnyCloud support

Connecting to your VM

Connect over SSH as azureuser, the administrator account you configured when you created the VM. Use the SSH key pair you selected at launch:

ssh -i /path/to/your-key azureuser@<public-ip>

Prerequisites

You need 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 size. For heavier data work, such as loading large frames into memory, choose a larger or memory-optimised size, for example Standard_D2s_v5 or Standard_E2s_v5.

Allow inbound 22/tcp from your management network and 443/tcp from the people who need the workspace in your network security group. Nothing else needs to be open, because the notebook server itself is never exposed directly.

Anyone who signs in can execute code on the VM, so 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

In the Azure Marketplace, find JupyterLab on Ubuntu 24.04 by SecureAnyCloud. Choose Get It Now, then Create.

Pick your subscription, resource group and region, and a VM size (Standard_B2s or larger). Provide your SSH public key for the azureuser account. Select a network security group that allows 22/tcp and 443/tcp, then review and create.

Step 2 - Launch from the Azure CLI

# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:jupyterlab-ubuntu-24-04:default:latest
az vm create \
  --resource-group <your-rg> \
  --name jupyterlab \
  --image sac1647283583153:jupyterlab-ubuntu-24-04:default:latest \
  --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 and 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 start automatically once first boot has completed. Confirm that both services are active:

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 only route to the workspace. Your workspace lives on its own data disk, separate from the operating system disk.

Step 4 - Confirm first boot created this VM's own certificate

Every VM creates 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/sac/data/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 ships with no certificate and no key. 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 has mode 0600 root:root and contains this VM's URL and its password. Store the password somewhere safe.

The check below confirms that 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 created 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 created for your VM, so your browser warns on the first visit. Accept the warning, or install your own certificate (Step 12).

The JupyterLab sign-in page opens. There is no username: enter the password from Step 5.

After you sign 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

In the Launcher, select Python 3 (ipykernel) under Notebook. 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 the data. Plots render inline in the notebook, and the notebook file is saved to 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 - Verify authentication 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
  • a wrong password is refused and yields no usable session
  • this VM's own password signs in
  • a real kernel starts and executes code over the encrypted connection

Run the probe:

/opt/jupyterlab/venv/bin/python /usr/local/lib/sac/jupyterlab-kernel-probe.py \
  || { echo "FATAL: the security and kernel round-trip probe did not pass"; exit 1; }

The probe 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. 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. The disk is 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 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. 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, such as a second Python environment or a different language, create it as its own virtual environment and register its kernel spec in 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. The helper prompts for the password twice and never echoes it. Write the hash 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. This is intended behaviour, not a fault.

The image serves HTTPS immediately, using the self-signed certificate created 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

Check the installed versions:

/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 snapshots 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 proxy configuration in /etc/nginx/sites-available/sac-jupyterlab. After any change, restart with sudo systemctl restart jupyterlab nginx. 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 SecureAnyCloud deployment includes 24/7 support. For questions about this image or help with your deployment, contact us through secureanycloud.com.

Deploy on Azure

Launch JupyterLab on Ubuntu 24.04 with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure