How to Run JupyterLab on Your Server (Python Notebooks in the Browser, with Your Data Next to Them)

JupyterLab on the server: notebooks in a browser tab, your data folders mounted, pandas and plots — and a scheduled notebook that emails itself.

Every so often you want to look at your data: how much the server's power draw changed, what Paperless has filed by month, the restic repository's growth, the Pi-hole query log by hour. A spreadsheet is clumsy; a Python notebook is right. JupyterLab on the server puts the notebooks next to the data — the same box that holds Postgres, the logs and the backups — in a browser tab from any device, with packages that stay installed.

Quick reference

TaskWhat
Folder~/docker/jupyter/compose.yaml
Webhttp://server-ip:8888 with a token or password
Datamount folders read-only into /home/jovyan/data
Persistent packagesa requirements.txt installed on start, or pip install --user into a mounted home
Databasespsycopg2 / sqlalchemy to the shared Postgres (161)
Schedule a notebookpapermill from cron or a timer

1. The compose file

mkdir -p ~/docker/jupyter/{work,home} && cd ~/docker/jupyter
sudo chown -R 1000:100 work home
nano compose.yaml

services:
jupyter:
image: quay.io/jupyter/scipy-notebook:latest
container_name: jupyter
user: "1000:100"
ports:
- "127.0.0.1:8888:8888"
environment:
JUPYTER_ENABLE_LAB: "yes"
JUPYTER_TOKEN: ${JUPYTER_TOKEN}
TZ: Europe/London
volumes:
- ./work:/home/jovyan/work
- ./home:/home/jovyan/.local
- /mnt/data:/home/jovyan/data:ro
- /var/log:/home/jovyan/logs:ro
networks: [default, shared]
restart: unless-stopped

networks:
shared:
external: true

scipy-notebook comes with pandas, matplotlib, numpy and friends. Mounting .local keeps pip install --user packages across restarts; shared is the Docker network with Postgres on it (161).

docker compose up -d

2. Reach it

Tunnel: ssh -L 8888:127.0.0.1:8888 john@server then http://localhost:8888/?token=…. Or proxy lab.example.com → 127.0.0.1:8888 with the WebSocket lines, HTTPS, and Authelia (95) in front — Jupyter can run code as the container user, so it's an admin tool.

Set a password instead of the token: inside a notebook terminal, jupyter server password, restart.

3. A first notebook

File → New → Notebook:

import pandas as pd, matplotlib.pyplot as plt
df = pd.read_csv("data/power/readings.csv", parse_dates=["time"])
df.set_index("time")["watts"].resample("D").mean().plot(title="Daily average power")
plt.show()

Run with Shift + Enter. The data folder is the server's real /mnt/data, read-only.

4. Query the databases

from sqlalchemy import create_engine
pg = create_engine("postgresql+psycopg2://readonly:pw@postgres:5432/paperless")
docs = pd.read_sql("select created, correspondent_id from documents_document", pg)
docs.groupby(docs.created.dt.to_period("M")).size().plot(kind="bar")

Make a read-only Postgres user for notebooks (GRANT SELECT ON ALL TABLES …) so a careless cell can't delete anything. Immich's assets table, Umami's website_event, Gitea's action — all readable the same way. SQLite files (Vaultwarden excepted) via sqlite3 on a copy.

5. Packages that persist

!pip install --user plotly in a cell lands in the mounted .local. For a reproducible set, a work/requirements.txt and a start command:

command: bash -c "pip install --user -q -r work/requirements.txt && start-notebook.py"

6. Schedule a notebook

A notebook that builds the weekly power/disk/queries report (156) as charts:

pip install --user papermill
papermill work/weekly.ipynb work/out/weekly-$(date +%F).ipynb
jupyter nbconvert --to html work/out/weekly-*.ipynb

From cron inside the container (docker compose exec jupyter …) or a host timer; email the HTML with mail -a, or drop the PNGs in a Nextcloud folder. Notebooks become scheduled reports with pictures.

7. Git

Notebooks are JSON; commit them to Gitea (45) with nbstripout installed so outputs don't bloat the history. The jupyterlab-git extension adds a sidebar.

Troubleshooting

"Permission denied" writing to work. Ownership: 1000:100 (the jovyan user and users group).

Kernel dies on a big DataFrame. Memory. mem_limit on the container, or read in chunks; the server isn't a workstation.

Can't connect to postgres. Not on the shared network, or the read-only user isn't created.

Token page keeps appearing. The token changed on restart because JUPYTER_TOKEN wasn't set; set it, or use a password.

Wrapping up

One container with your data folders mounted read-only, a tunnel or Authelia in front, a read-only database user, .local persisted. Questions about your own data get answered in a notebook next to the data, and the ones worth repeating become a scheduled report with charts.

Call us