Running JupyterLab in a tmux session works right up until the box reboots, the process dies overnight, or you need someone else to reach it. Making it a systemd service gives you restart-on-failure, logs in journald, resource limits, and a clean startup at boot β the same treatment any other daemon gets.
This walks through a production-shaped setup: dedicated user, venv, hardened unit, authentication, and a reverse proxy β plus the two errors everyone hits when proxying Jupyter.
Install in a Dedicated venv
Never install JupyterLab into the system Python. A dedicated user and venv keeps the service isolated and upgradeable:
# Dedicated service account with no login shell
$ sudo useradd -r -m -d /opt/jupyter -s /usr/sbin/nologin jupyter
$ sudo -u jupyter python3 -m venv /opt/jupyter/venv
$ sudo -u jupyter /opt/jupyter/venv/bin/pip install --upgrade pip
$ sudo -u jupyter /opt/jupyter/venv/bin/pip install jupyterlab
# Where notebooks will live
$ sudo -u jupyter mkdir -p /opt/jupyter/notebooks
Generate a Config and Set a Password
By default Jupyter prints a one-time token to its log β awkward for a service. Set a password hash instead:
$ sudo -u jupyter /opt/jupyter/venv/bin/jupyter server --generate-config
# Interactively set a password (writes a hash to the config)
$ sudo -u jupyter /opt/jupyter/venv/bin/jupyter server password
Then edit /opt/jupyter/.jupyter/jupyter_server_config.py:
c.ServerApp.ip = "127.0.0.1" # localhost only β proxy handles the outside
c.ServerApp.port = 8888
c.ServerApp.open_browser = False
c.ServerApp.root_dir = "/opt/jupyter/notebooks"
c.ServerApp.allow_remote_access = True # needed when behind a proxy
# Set by 'jupyter server password' β do not hand-edit
# c.ServerApp.password = "argon2:..."
Bind to 127.0.0.1 β a notebook server executes arbitrary code as its user. Exposing it directly on a network interface is handing out a remote shell. Let a proxy or SSH tunnel handle access.
The systemd Unit
# /etc/systemd/system/jupyterlab.service
[Unit]
Description=JupyterLab
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=jupyter
Group=jupyter
WorkingDirectory=/opt/jupyter/notebooks
Environment="JUPYTER_CONFIG_DIR=/opt/jupyter/.jupyter"
ExecStart=/opt/jupyter/venv/bin/jupyter lab --config=/opt/jupyter/.jupyter/jupyter_server_config.py
Restart=on-failure
RestartSec=10
# Resource caps β a runaway notebook should not take the box down
MemoryMax=8G
CPUQuota=400%
# Sandboxing
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/jupyter
[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now jupyterlab
$ systemctl status jupyterlab
$ journalctl -u jupyterlab -f
MemoryMax β the single most valuable line here. A notebook loading a dataset larger than RAM gets killed instead of triggering the OOM killer on the whole server.
ProtectSystem=strict β makes the filesystem read-only except paths listed in ReadWritePaths. If a kernel needs to write elsewhere, add that path explicitly rather than removing the protection.
CPUQuota=400% β four cores' worth. Set it to leave headroom for the rest of the system.
Access Option 1: SSH Tunnel
For a single user this is the best answer β zero exposure, no proxy, no certificates:
# From your laptop
$ ssh -L 8888:localhost:8888 user@server
# Then open http://localhost:8888 locally
Make it permanent in ~/.ssh/config so it happens automatically:
Host mlbox
HostName server.example.com
User admin
LocalForward 8888 localhost:8888
Access Option 2: nginx Reverse Proxy
For shared access, terminate TLS at nginx. Jupyter uses WebSockets heavily, so the upgrade headers are mandatory β without them the UI loads but kernels never connect:
server {
listen 443 ssl;
server_name jupyter.example.com;
ssl_certificate /etc/letsencrypt/live/jupyter.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jupyter.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for kernels β WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Notebooks can be slow; do not cut long requests
proxy_read_timeout 86400;
}
}
Our nginx config generator can scaffold the surrounding server block, and the SSH hardening guide covers locking down the tunnel option.
Security Essentials
Treat it as remote code execution β anyone who reaches the UI can run commands as the jupyter user. Every other control follows from this.
Always require authentication β password or token. Never run with authentication disabled, even briefly, even on a LAN.
TLS if it leaves the host β a Jupyter password over plain HTTP is a password on the wire.
Run as a non-privileged user β never as root. The service account should own only its own directories.
Limit the root_dir β the file browser can reach anything the user can read. Scope it to a notebooks directory.
Multiple users need JupyterHub β one Jupyter server shared between people means they share a Unix user and can read each other's files. JupyterHub spawns per-user servers instead.
Troubleshooting
UI loads but the kernel never connects β missing WebSocket upgrade headers in the proxy. This is the most common proxy failure by far.
"Blocking request with non-local Host" β Jupyter is rejecting the proxied Host header. Set c.ServerApp.allow_remote_access = True and ensure the proxy passes Host.
Service fails immediately at boot β check journalctl -u jupyterlab. Usually a path the sandbox forbids β add it to ReadWritePaths.
Kernel dies repeatedly β the notebook exceeded MemoryMax and was killed. Confirm with journalctl -k | grep -i kill, then raise the cap or reduce the workload.
404s on static assets behind a proxy β a sub-path deployment needs c.ServerApp.base_url set to match the proxy location.
pip install inside a notebook has no effect β it installed into a different environment. Use %pip install in the cell, which targets the running kernel.
GPU not visible in notebooks β the jupyter user needs GPU access too β see our NVIDIA guide for drivers and our ROCm guide for render/video group membership.
Frequently Asked Questions
Why run JupyterLab as a systemd service instead of in tmux?
A systemd service starts at boot, restarts on failure, logs to journald, and can enforce memory and CPU limits. A tmux session does none of that and dies with the machine or the terminal.
Is it safe to expose JupyterLab to the internet?
Only behind TLS and authentication, and even then cautiously β the notebook server executes arbitrary code as its user, so access is effectively a remote shell. Bind it to 127.0.0.1 and use an SSH tunnel or an authenticated reverse proxy.
Why does my notebook UI load but kernels never start behind nginx?
The proxy is not forwarding the WebSocket upgrade. Add proxy_http_version 1.1 plus the Upgrade and Connection headers to the location block. This is the single most common Jupyter proxy problem.
How do I stop a notebook from taking down the whole server?
Set MemoryMax and CPUQuota in the systemd unit. The kernel gets killed when it exceeds the memory cap instead of triggering the system OOM killer, and the CPU quota keeps other services responsive.
Can several people share one JupyterLab server?
Technically yes, but they share one Unix user and can read and modify each other's files. For multiple users, run JupyterHub, which spawns a separate server per user with proper isolation.