Install JupyterLab and PyTorch on a GPU server
To run JupyterLab on a GPU server, install JupyterLab and a CUDA or ROCm build of PyTorch in a Python virtual environment, start JupyterLab as a systemd service bound to 127.0.0.1:8888 with a password, and open it in your browser through an SSH tunnel. Notebooks then run on the server’s GPUs, and no port is exposed to the internet. If you picked JupyterLab in the Software step of your order, it is already installed; this guide covers installing and running it yourself.
Run the commands as root on Ubuntu 22.04 or 24.04, except where a step switches to alice, the normal user from Your first hour on a new GPU server. Never run JupyterLab as root: its notebooks and terminal have the rights of the user running it. Install the GPU driver first (NVIDIA drivers and CUDA; on an AMD MI355X, ROCm on MI355X). On NVIDIA, the nvidia-smi header must show CUDA Version 13.0 or higher: the PyTorch wheels bring their own CUDA libraries, and CUDA 13.x programs run on drivers from the 580 series on (CUDA release notes).
adduser alice # skip if alice exists
usermod -aG render,video alice # AMD MI355X only
su - alice
# as alice: uv, then Python 3.13 in its own environment
curl -LsSf https://astral.sh/uv/install.sh | sh
~/.local/bin/uv venv --python 3.13 --seed --managed-python ~/jupyter-env
source ~/jupyter-env/bin/activate
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu132 # NVIDIA
# pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm7.14 # AMD MI355X
pip install jupyterlab
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.device_count())"
The PyTorch lines are those pytorch.org gives for version 2.14.1 (checked on 20 October 2026). Use the CUDA 13.2 build on every NVIDIA GPU, not the CUDA 12.6 one the selector also lists: PyTorch 2.7 added Blackwell support with its CUDA 12.8 builds. pip install jupyterlab, from the JupyterLab installation guide, also installs ipykernel, so the default Python 3 kernel runs in this environment, with this PyTorch. For suitable GPUs, see the research and fine-tuning pages.
Run JupyterLab as a systemd service
A service starts at boot and comes back after a crash. Set a password first, still as alice. Jupyter Server stores only its hash, in ~/.jupyter/jupyter_server_config.json:
jupyter server password # asks twice, writes the hash
exit # back to root
As root, keep notebooks and the Hugging Face cache on the NVMe data disk at /data (Storage and data), then create the unit:
mkdir -p /data/notebooks /data/hf
chown alice:alice /data/notebooks /data/hf
tee /etc/systemd/system/jupyterlab.service >/dev/null <<'EOF'
[Unit]
Description=JupyterLab
After=network.target
[Service]
User=alice
WorkingDirectory=/data/notebooks
Environment=HF_HOME=/data/hf
ExecStart=/home/alice/jupyter-env/bin/jupyter-lab --no-browser --ip=127.0.0.1 --port=8888 --port-retries=0 --notebook-dir=/data/notebooks --MappingKernelManager.cull_idle_timeout=7200
NoNewPrivileges=yes
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now jupyterlab
ss -tln | grep 8888 # 127.0.0.1:8888 and nothing else
journalctl -u jupyterlab -f # the log; Ctrl-C stops following
--ip=127.0.0.1keeps the server on the loopback address.--port-retries=0makes it fail if port 8888 is taken, instead of moving to one of the next 50 ports, so your tunnel never points at the wrong one.cull_idle_timeout=7200shuts down kernels idle for two hours with no browser connected, freeing the GPU memory of forgotten notebooks; busy kernels are spared (Jupyter Server options).NoNewPrivileges=yesstops anything started from JupyterLab from gaining privileges through setuid programs such assudo: do admin work in your SSH session.
Without a password, Jupyter Server creates a new random token at each start and logs the login URL with it; as root, su - alice -c '~/jupyter-env/bin/jupyter server list' also prints it.
Open JupyterLab through an SSH tunnel
On your computer, forward a local port to the remote server’s loopback address, then open http://localhost:8888 and sign in with your password:
ssh -N -L 8888:127.0.0.1:8888 gpu1
# port 8888 already taken on your computer, by a local Jupyter for example
ssh -N -L 8889:127.0.0.1:8888 gpu1 # then open http://localhost:8889
gpu1 is the host entry from SSH access, where a LocalForward line can open the tunnel on every connection. The same command works in PowerShell on Windows. Closing the browser or the tunnel does not stop your kernels.
Never start JupyterLab with --ip=0.0.0.0, and never open port 8888 in the firewall: anyone who gets in can run code and open a terminal as alice. For a team, use JupyterHub (below). In Docker, publish the port as -p 127.0.0.1:8888:8888, because Docker’s port rules bypass ufw (see Containers).
Check that PyTorch sees the GPUs in a notebook
Open a notebook with the Python 3 (ipykernel) kernel and run:
import torch
print(torch.__version__, "CUDA:", torch.version.cuda, "HIP:", torch.version.hip)
print("GPUs:", torch.cuda.device_count(), torch.cuda.is_available())
for i in range(torch.cuda.device_count()):
free, total = torch.cuda.mem_get_info(i)
print(i, torch.cuda.get_device_name(i), f"{free / 2**30:.0f} of {total / 2**30:.0f} GiB free")
print(torch.cuda.get_arch_list()) # MI355X: must include gfx950
x = torch.randn(8192, 8192, device="cuda")
print((x @ x).sum().item()) # a matrix multiply on GPU 0
Expect a version ending in +cu132 or +rocm7.14, True, and one line per GPU with its name and memory. The ROCm build reuses the torch.cuda interfaces, so the same code runs on the MI355X (HIP semantics). If is_available() returns False, run nvidia-smi or amd-smi list in a terminal and go back to the driver guide.
Install packages from a notebook with %pip install <package>, which runs pip within the current kernel (IPython magics), so they land in ~/jupyter-env.
Choose GPUs per notebook and watch GPU memory
A kernel sees every GPU unless you limit it. Set CUDA_VISIBLE_DEVICES in the first cell, before import torch, and restart the kernel to change it. Or register one kernel per GPU, which sets the variable before the kernel starts:
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "1" # this kernel sees GPU 1 only, as cuda:0
import torch
# as alice: adds a Python (GPU 1) kernel to the list
source ~/jupyter-env/bin/activate
python -m ipykernel install --user --name gpu1 --display-name "Python (GPU 1)" --env CUDA_VISIBLE_DEVICES 1
Numbers follow the order of nvidia-smi, and the visible GPUs are renumbered from 0 (CUDA environment variables). On the MI355X, use HIP_VISIBLE_DEVICES (ROCm on MI355X). Run from another environment, the same command adds that environment as a kernel (kernel install).
To find which kernel holds GPU memory, from a terminal:
nvidia-smi # the process table lists each PID and its GPU memory
amd-smi process -G # AMD MI355X: PID, process name, memory
ps -o pid,etime,args -p <pid> # notebook kernels run ipykernel_launcher
Inside a notebook:
print(torch.cuda.memory_allocated() / 2**30, torch.cuda.memory_reserved() / 2**30) # GiB in tensors, GiB reserved
print(torch.cuda.memory_summary())
del model # drop what you no longer need
torch.cuda.empty_cache() # return unused cached memory
PyTorch’s caching allocator keeps freed memory for reuse, and nvidia-smi and amd-smi show it as used. empty_cache() releases that unused part, never memory still held by tensors (memory management). To free everything, restart the kernel. Closing a notebook leaves its kernel running, memory included: shut kernels down in the Running panel of the left sidebar (JupyterLab docs).
Keep long training runs alive
Kernels survive a closed browser, but not JupyterLab itself. Jupyter Server shuts its kernels down when it stops, and systemd kills every process left in the service’s control group (KillMode). A service restart, an upgrade, a reboot or a crash therefore loses the variables and models in memory; notebook files keep their last save.
Run anything that takes hours as a script, outside JupyterLab, and save checkpoints to /data. jupyter nbconvert --to script turns a notebook into a Python script; remove magics such as %pip, which only run in Jupyter (nbconvert). Then start the script as a transient systemd service, as root:
su - alice -c 'cd /data/notebooks && ~/jupyter-env/bin/jupyter nbconvert --to script train.ipynb'
systemd-run --unit=train --uid=alice --working-directory=/data/notebooks \
-E CUDA_VISIBLE_DEVICES=0 -E HF_HOME=/data/hf \
/home/alice/jupyter-env/bin/python -u train.py
journalctl -u train -f # follow the output; Ctrl-C leaves the run going
systemctl stop train # stop the run
systemctl reset-failed train # after a failed run, frees the name
The run survives your SSH session and JupyterLab restarts, though not a reboot, and -u stops Python from buffering its output, so the log stays current. tmux works too (SSH access). To continue after an interruption, resume from the last checkpoint, as in Fine-tune an LLM with LoRA and QLoRA.
Alternative: JupyterLab in Docker
Install Docker and the NVIDIA Container Toolkit first (Containers). The official Jupyter Docker Stacks publish CUDA variants of pytorch-notebook; the cuda13- tags need a driver from the 580 series or newer. Stop the service above first, or publish another host port, such as 127.0.0.1:8890:8888:
mkdir -p /data/work && chown 1000 /data/work # UID 1000 is jovyan, the user in the image
docker run -d --name jupyter --gpus all --restart unless-stopped \
-p 127.0.0.1:8888:8888 \
-v /data/work:/home/jovyan/work \
quay.io/jupyter/pytorch-notebook:cuda13-2026-10-19
docker logs jupyter 2>&1 | grep token # the login URL
The dated tag pins every version; cuda13-latest follows new builds. Only /home/jovyan/work survives when you replace the container. NVIDIA’s NGC PyTorch image also contains JupyterLab (4.6.3 in release 26.09): run it with the same --gpus all and -p 127.0.0.1:8888:8888, and the command jupyter lab --ip=0.0.0.0 --no-browser --allow-root. A port published on 127.0.0.1 is reachable only from the host (Docker port publishing), so 0.0.0.0 inside the container stays private. The Docker Stacks GPU variants are built for CUDA: on the MI355X, use the native install above.
Multiple users: JupyterHub, not a shared JupyterLab
One JupyterLab server is one user. Everyone who has the password works as alice, with the same files, rights and GPUs, and Jupyter Server’s documentation warns that the commands of several people on one server may collide and overwrite each other (running a public server).
For separate accounts, use JupyterHub: a multi-user hub that starts, manages and proxies one single-user server per person. By default, people sign in with their local Unix accounts. The Littlest JupyterHub packages it for up to 100 users on a single server. Follow its documentation: it says every deployment should enable HTTPS, which needs a domain name pointing at the server. The users of one server still share its GPUs: agree on who uses which, or give each person a server.
Back up notebooks before the term ends
If the term is not renewed, the server stops at the end of the paid month and its disks are wiped. A few days before the end date, copy your notebooks, the data you produced and your checkpoints off the server, with the package list that rebuilds the environment:
su - alice -c '~/jupyter-env/bin/pip freeze > /data/notebooks/requirements.txt'
# from your computer
rsync -avhP gpu1:/data/notebooks/ ./notebooks/
On the next server, pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu132 (rocm7.14 on the MI355X) rebuilds it. Send large datasets and checkpoints to object storage with rclone, as shown in Storage and data.
Need help with this guide?
Tell us your GPU, the commands you ran and the output you got through the contact form.
