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

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
| Task | What |
|---|---|
| Folder | ~/docker/jupyter/compose.yaml |
| Web | http://server-ip:8888 with a token or password |
| Data | mount folders read-only into /home/jovyan/data |
| Persistent packages | a requirements.txt installed on start, or pip install --user into a mounted home |
| Databases | psycopg2 / sqlalchemy to the shared Postgres (161) |
| Schedule a notebook | papermill 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.
