JupyterLab on AWS 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 instance generates a long password unique to that instance, 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 instance at first boot - A dedicated 64 GiB EBS workspace volume mounted at
/var/lib/jupyter, holding the notebook root, kernel state and any packages you add - A password unique to this instance, 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 instance
Connect over SSH as the default login user for your AMI's operating system, using the EC2 key pair you selected at launch:
| OS variant | SSH login user |
|---|---|
| Ubuntu 24.04 | ubuntu |
ssh -i /path/to/your-key.pem ubuntu@<instance-public-ip>
Prerequisites
An AWS account, an EC2 key pair, and a VPC subnet in your target region. m5.large (2 vCPU / 8 GiB RAM) is a good starting point; choose a larger instance for heavier data work, and a memory-optimised type if you load large frames into memory. 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 instance, 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 AWS Marketplace
Find JupyterLab by cloudimg in the AWS Marketplace, choose Continue to Subscribe, accept the terms, then Continue to Configuration and Continue to Launch. Pick your region, an instance type (m5.large or larger), your VPC subnet, your key pair, and a security group that allows 22/tcp and 443/tcp, then launch.
Step 2 — Launch from the AWS CLI
aws ec2 run-instances \
--image-id <ami-id> \
--instance-type m5.large \
--key-name <your-key-pair> \
--security-group-ids <sg-with-22-and-443> \
--subnet-id <your-subnet> \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=jupyterlab}]'
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.
Step 4 — Confirm first boot minted this instance's own certificate
Every instance 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 instance"; exit 1; }
openssl x509 -noout -subject -dates -ext subjectAltName -in /etc/nginx/tls/cert.pem \
|| { echo "FATAL: no per-instance 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 instance has booted.
Step 5 — Retrieve the password generated for your instance
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 instance'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-instance password recorded: " length($2) " characters"; found=1 } }
END { if (!found) { print "FATAL: no per-instance password recorded"; exit 1 } }' \
/root/jupyterlab-credentials.txt
Step 6 — Sign in over HTTPS
Browse to https://<instance-public-ip>/. The certificate is self-signed and minted for your instance, 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 volume.

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 volume:
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 instance. It proves that unauthenticated access is refused, that a wrong password is refused and yields no usable session, that this instance'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 instance 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 volume, /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 10 for the supported way to add packages.
Step 10 — The dedicated workspace volume
Notebooks, datasets, kernel state and any packages you add live on their own EBS volume, mounted by filesystem UUID so it reproduces identically on every instance launched from this image:
findmnt -no SOURCE,SIZE,TARGET /var/lib/jupyter \
|| { echo "FATAL: the workspace volume 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 volume is not recorded in /etc/fstab"; exit 1; }
echo "workspace volume 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 volume:

To grow the workspace, expand the EBS volume in the AWS console, then extend the filesystem on the instance 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 volume instead, at /var/lib/jupyter/site-packages. That directory is already on the kernel's import path, and because it lives on the workspace volume 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 instance. For any real deployment, install a certificate for your instance'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 volume, so an EBS snapshot of that volume is a complete backup of your work. Take one on a schedule, 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.