jupyterlab-cuda-sandboxed¶
Modules
A NixOS module that runs a GPU/CUDA JupyterLab as a native systemd service under a strict systemd sandbox — the full lockdown treatment, minus exactly the two knobs CUDA cannot live without.
Applies to any PyTorch / Triton / torch.compile / hand-written-CUDA-extension
workload, not just notebooks. The sandbox pattern is the reusable part.
The problem¶
You want a long-lived GPU service (a notebook, an inference server, a training
box) locked down like any other systemd unit: ProtectSystem=strict,
PrivateDevices, MemoryDenyWriteExecute, minimal capabilities. But CUDA
breaks under a naive lockdown in two non-obvious ways, and the failures are
opaque — an import error deep in torch, or a mmap that returns EPERM.
The two traps¶
1. CUDA forces two sandbox knobs open¶
MemoryDenyWriteExecute = false— Triton, TorchInductor, and any JIT-compiled kernel map memory that is writable and executable at the same time.MemoryDenyWriteExecute=true(the hardened default) kills that withEPERM. It must be off.PrivateDevices = false— the framework needs the raw/dev/nvidia*device nodes.PrivateDevices=truehides them.
Turning both off sounds like giving up on the sandbox. It isn't — you claw the device exposure back:
DevicePolicy = "closed"denies every device node, then a per-index NVIDIA whitelist hands back exactly the control/UVM/modeset/caps nodes plus one/dev/nvidiaNper GPU you asked for.cudaDevices = "0"sees GPU 0 and nothing else;CUDA_VISIBLE_DEVICESmirrors it as belt-and-suspenders.- Everything else stays on:
NoNewPrivileges,ProtectSystem=strict,ProtectHome,RestrictNamespaces,ProtectKernelTunables/Modules/Logs,RestrictSUIDSGID,ProtectProc=invisible,UMask=0077, and a capability set pared to justCAP_SYS_NICE.
2. ProtectSystem=strict makes every framework cache blow up¶
With a read-only root, the only writable location is ReadWritePaths
(the single dataDir). But every ML framework wants to write a cache
somewhere, and by default those somewheres are scattered across $HOME and
/tmp in ways that trip the sandbox on first import or first compile.
The fix is to redirect all of them under dataDir via env vars, and
pre-create the tree with tmpfiles so nothing races a missing directory:
| Cache | Env var |
|---|---|
| HuggingFace home | HF_HOME |
| Transformers | TRANSFORMERS_CACHE |
| HF hub | HF_HUB_CACHE |
| XDG (catch-all) | XDG_CACHE_HOME |
| Triton | TRITON_CACHE_DIR |
| TorchInductor | TORCHINDUCTOR_CACHE_DIR |
Miss one and you get an obscure write-permission error the first time a cell touches that framework.
Bonus: compiling from inside a cell¶
For torch.compile / Triton / custom CUDA extensions to build at notebook
runtime, the compiler toolchain has to be reachable: gcc, ninja,
pybind11 headers (CPLUS_INCLUDE_PATH), the cudatoolkit (CUDA_HOME), and
the userspace driver (LD_LIBRARY_PATH=/run/opengl-driver/lib, brought into the
strict mount namespace via BindReadOnlyPaths). All are wired up here.
Access control — read this¶
The notebook runs with no token and no password, bound to loopback
(127.0.0.1). That is deliberate: the nginx TLS vhost is the sole
authenticator. The module asserts that domain and acmeHost are set
before it will enable, precisely so you can't accidentally stand up an
unauthenticated notebook.
The loopback bind means the raw tokenless port is unreachable off-box no matter what the firewall does — a stranger cannot hit it directly even on a multi-homed or cloud host with the NixOS firewall disabled. Never proxy or port-forward it onto an untrusted network. Authorization is the TLS proxy — nothing else. Put the vhost behind whatever real auth (mTLS, SSO, VPN/tailnet) your environment uses.
Security notes¶
- Cross-origin defenses stay on. The server runs with the XSRF check
enabled and
--ServerApp.allow_originscoped tohttps://<domain>— on a token-less notebook these are the last line of defense against a malicious page in the operator's browser driving the loopback port via CSRF or DNS-rebinding. Don't add--ServerApp.disable_check_xsrf=Trueor widenallow_originto*; if a non-browser client trips the XSRF check, have it carry the_xsrfcookie/token instead.
The proxy also sets proxy_read_timeout 86400 so a single cell can run for a
full day without nginx tearing the connection down, and proxyWebsockets to
carry the kernel comm channel.
Usage¶
{
imports = [ ./jupyterlab-cuda-sandboxed ];
services.jupyterlabCuda = {
enable = true;
domain = "notebooks.example.com";
acmeHost = "notebooks.example.com";
# Recommended: build the Python env / torch / cudatoolkit from an unstable
# channel so CUDA wheels stay current — and so torch resolves to the SAME
# store closure any other CUDA services on the host use (build it once).
cudaPkgs = inputs.nixpkgs-unstable.legacyPackages.${pkgs.system};
cudaDevices = "0"; # or "0 1" for two GPUs
dataDir = "/var/lib/jupyter";
};
}
Options¶
| Option | Default | Notes |
|---|---|---|
enable |
false |
|
domain |
null |
Required. nginx vhost — the only front-door auth. |
acmeHost |
null |
Required. ACME cert host for the vhost. |
cudaPkgs |
pkgs |
nixpkgs instance for python/torch/cudatoolkit. Point at unstable for fresh CUDA wheels + a shared closure. |
cudaDevices |
"0" |
Space-separated GPU indices; drives both the device whitelist and CUDA_VISIBLE_DEVICES. |
dataDir |
/var/lib/jupyter |
HOME, WorkingDirectory, sole writable path, cache root. |
port |
8888 |
Loopback port behind nginx. |
bindIp |
"127.0.0.1" |
Address JupyterLab binds. Loopback so the tokenless port is unreachable off-box; only widen if you understand the exposure. |
user / group |
jupyter |
|
uid / gid |
1328 |
Pin these if you use impermanence / stable ownership. |
extraPythonPackages |
ps: [ ] |
Function of the python package set, appended to the notebook env (e.g. ps: [ ps.geopandas ]). |
extraPath |
[ ] |
Extra packages appended to the service PATH (e.g. more jupyterlab-lsp language servers). |
Caveats¶
cudaPkgsis not optional in spirit. The default (pkgs) works only if your pinned nixpkgs already has CUDA-enabled torch you're happy with. Otherwise point it at an unstable channel. Sharing onecudaPkgsacross every CUDA service on the host avoids compiling PyTorch once per service.- NVIDIA driver +
/run/opengl-drivermust exist on the host (hardware.nvidia/hardware.graphics); the module binds it read-only but doesn't configure the driver. - The device whitelist assumes a standard
/dev/nvidia*layout. Exotic setups (MIG,nvidia-capsbeyond cap1/cap2) may need the list extended. - The Python package set is opinionated (a broad data-science + LLM toolkit).
Trim it to taste — it's a plain
withPackageslist indefault.nix; add to it without editing the module viaextraPythonPackages.
Source¶
modules/jupyterlab-cuda-sandboxed/default.nix
# jupyterlab-cuda-sandboxed
#
# Run a GPU/CUDA JupyterLab (or any PyTorch / Triton / torch.compile JIT
# workload) as a native systemd service under a strict sandbox.
#
# The two traps this module solves:
# 1. CUDA JIT (Triton, TorchInductor, hand-written extensions) needs to map
# W+X memory pages, so MemoryDenyWriteExecute MUST be false. And the
# framework needs raw /dev/nvidia* nodes, so PrivateDevices MUST be false.
# Everything else stays locked down; GPU access is narrowed back with
# DevicePolicy=closed + an explicit per-index NVIDIA device whitelist.
# 2. ProtectSystem=strict makes the whole filesystem read-only except
# ReadWritePaths. Every ML framework cache (HuggingFace, XDG, Triton,
# TorchInductor) is therefore redirected under the one writable dataDir,
# or the first import/compile blows up trying to write outside it.
#
# The notebook itself has NO token and NO password. It binds loopback
# (127.0.0.1) so the raw port is unreachable off-box regardless of firewall
# state; the nginx TLS vhost is the sole authenticator and front door. Never
# proxy it onto an untrusted network. Enabling asserts a domain + ACME host.
#
# Usage:
# imports = [ ./jupyterlab-cuda-sandboxed ];
# services.jupyterlabCuda = {
# enable = true;
# domain = "notebooks.example.com";
# acmeHost = "notebooks.example.com";
# # For recent CUDA wheels, point this at an unstable nixpkgs instance:
# # cudaPkgs = inputs.nixpkgs-unstable.legacyPackages.${pkgs.system};
# };
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.jupyterlabCuda;
# Which nixpkgs instance provides python / torch / cudatoolkit. Defaults to
# the host pkgs, but you almost certainly want to point this at an unstable
# channel so the ML wheels track recent CUDA builds — and so torch resolves
# to the SAME store closure your other CUDA services use (one shared
# PyTorch build instead of compiling it once per service).
cpkgs = cfg.cudaPkgs;
pythonEnv = cpkgs.python313.withPackages (
ps:
(with ps; [
# notebook stack
jupyter
jupyterlab
notebook
ipykernel
ipywidgets
nbconvert
# ML / data
numpy
pandas
matplotlib
seaborn
plotly
scipy
scikit-learn
statsmodels
sympy
torch
torchvision
transformers
scikit-image
xgboost
shap
optuna
# utility / io
requests
beautifulsoup4
lxml
sqlalchemy
psycopg2
openpyxl
h5py
pillow
bokeh
altair
holoviews
pytest
black
tqdm
click
joblib
dask
# language-server support for jupyterlab-lsp (Python side)
jupyterlab-lsp
python-lsp-server
python-lsp-ruff
# misc web / graph
flask
fastapi
uvicorn
pydantic
httpx
poetry-core
networkx
nltk
])
++ (cfg.extraPythonPackages ps)
);
# --- NVIDIA device whitelist ------------------------------------------------
# DevicePolicy=closed denies every device node; this hands back exactly the
# NVIDIA control/UVM/modeset/caps nodes plus one /dev/nvidiaN per GPU index.
# So gpuIndices = [ "0" ] exposes precisely GPU 0 and nothing else.
mkNvidiaDeviceAllow =
{ gpuIndices }:
[
"/dev/null rw"
"/dev/urandom r"
"/dev/tty rw"
"/dev/nvidiactl rw"
"/dev/nvidia-modeset rw"
"/dev/nvidia-uvm rw"
"/dev/nvidia-uvm-tools rw"
]
++ map (i: "/dev/nvidia${i} rw") gpuIndices
++ [
"/dev/nvidia-caps/nvidia-cap1 r"
"/dev/nvidia-caps/nvidia-cap2 r"
];
in
{
options.services.jupyterlabCuda = {
enable = lib.mkEnableOption "GPU/CUDA JupyterLab development server";
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "notebooks.example.com";
description = ''
nginx virtual-host name that fronts the notebook. Required when
enabled — it is the only authentication in front of a tokenless
JupyterLab.
'';
};
acmeHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "notebooks.example.com";
description = "ACME certificate host name (passed to nginx useACMEHost).";
};
cudaPkgs = lib.mkOption {
type = lib.types.raw;
default = pkgs;
defaultText = lib.literalExpression "pkgs";
description = ''
nixpkgs instance used to build the Python env, torch and cudatoolkit.
Point it at an unstable channel for recent CUDA wheels, and share it
with any other CUDA services so the PyTorch closure is built once.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "jupyter";
description = "System user the service runs as.";
};
group = lib.mkOption {
type = lib.types.str;
default = "jupyter";
description = "System group the service runs as.";
};
uid = lib.mkOption {
type = lib.types.int;
default = 3400;
description = "Numeric uid for the service user.";
};
gid = lib.mkOption {
type = lib.types.int;
default = 3400;
description = "Numeric gid for the service group.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8888;
description = "Loopback port JupyterLab listens on behind nginx.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/jupyter";
description = ''
HOME, WorkingDirectory and the single writable path under
ProtectSystem=strict. All framework caches live under it.
'';
};
cudaDevices = lib.mkOption {
type = lib.types.str;
default = "0";
example = "0 1";
description = ''
Space-separated GPU indices to expose. Drives both the systemd device
whitelist and CUDA_VISIBLE_DEVICES.
'';
};
bindIp = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Address JupyterLab binds. Defaults to loopback so the tokenless port is
unreachable off-box regardless of firewall state; nginx is the sole
front door. Only widen this if you understand the exposure.
'';
};
extraPythonPackages = lib.mkOption {
type = lib.types.functionTo (lib.types.listOf lib.types.package);
default = _ps: [ ];
defaultText = lib.literalExpression "ps: [ ]";
description = ''
Extra packages appended to the notebook Python environment, as a
function of the python package set (e.g. ps: [ ps.geopandas ]).
'';
};
extraPath = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
Extra packages appended to the service PATH (e.g. additional
jupyterlab-lsp language servers).
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.domain != null;
message = "services.jupyterlabCuda: domain must be set when enabled (it is the only front-door auth).";
}
{
assertion = cfg.acmeHost != null;
message = "services.jupyterlabCuda: acmeHost must be set when enabled.";
}
];
users.users.${cfg.user} = {
inherit (cfg) uid;
group = cfg.group;
isSystemUser = true;
home = cfg.dataDir;
createHome = true;
};
users.groups.${cfg.group} = {
inherit (cfg) gid;
};
# Pre-create the cache tree so the first import doesn't race against a
# missing directory under the read-only root.
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/notebooks 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache/huggingface 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache/transformers 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache/hub 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache/triton 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/.cache/torchinductor 0755 ${toString cfg.uid} ${toString cfg.gid} - -"
];
systemd.services.jupyter = {
description = "GPU/CUDA JupyterLab development server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
# These reach the notebook runtime so torch.compile / Triton / hand-written
# CUDA extensions can actually compile from inside a cell. jupyterlab-lsp
# also shells out to these language servers for non-Python buffers.
path =
(with pkgs; [
gcc
glibc.dev
cpkgs.python313Packages.pybind11
ninja
cpkgs.cudaPackages.cudatoolkit
pyright
gopls
rust-analyzer
clang-tools
typescript-language-server
nodejs
deno
])
++ cfg.extraPath;
environment = {
HOME = cfg.dataDir;
# Userspace NVIDIA driver, brought into the strict mount namespace by
# BindReadOnlyPaths below.
LD_LIBRARY_PATH = "/run/opengl-driver/lib";
CC = "${pkgs.gcc}/bin/gcc";
CUDA_VISIBLE_DEVICES = cfg.cudaDevices;
CUDA_HOME = "${cpkgs.cudaPackages.cudatoolkit}";
NCCL_P2P_DISABLE = "1";
CPLUS_INCLUDE_PATH = "${cpkgs.python313Packages.pybind11}/include";
PYTORCH_CUDA_ALLOC_CONF = "expandable_segments:True";
# Every framework cache MUST live under dataDir (the sole writable path).
HF_HOME = "${cfg.dataDir}/.cache/huggingface";
TRANSFORMERS_CACHE = "${cfg.dataDir}/.cache/transformers";
HF_HUB_CACHE = "${cfg.dataDir}/.cache/hub";
XDG_CACHE_HOME = "${cfg.dataDir}/.cache";
TRITON_CACHE_DIR = "${cfg.dataDir}/.cache/triton";
TORCHINDUCTOR_CACHE_DIR = "${cfg.dataDir}/.cache/torchinductor";
};
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
Restart = "always";
RestartSec = 3;
# Idempotent; --force keeps the Deno JS/TS kernel current across bumps.
ExecStartPre = "${pkgs.deno}/bin/deno jupyter --install --force";
# No token, no password: auth is entirely the nginx TLS vhost.
ExecStart = ''
${pythonEnv}/bin/jupyter lab \
--ip=${cfg.bindIp} \
--port=${toString cfg.port} \
--no-browser \
--notebook-dir=${cfg.dataDir}/notebooks \
--ServerApp.token="" \
--ServerApp.password="" \
--ServerApp.allow_origin="https://${cfg.domain}" \
--ServerApp.trust_xheaders=True \
--ServerApp.base_url="/" \
--ServerApp.allow_remote_access=True
'';
# ---- sandbox --------------------------------------------------------
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
BindReadOnlyPaths = [ "/run/opengl-driver" ];
# THE TWO KNOBS CUDA FORCES OPEN:
# JIT kernels need writable+executable pages.
MemoryDenyWriteExecute = false;
# torch needs raw /dev/nvidia* nodes.
PrivateDevices = false;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
# ...but hand back only the NVIDIA nodes for the selected GPU indices.
DevicePolicy = "closed";
DeviceAllow = mkNvidiaDeviceAllow {
gpuIndices = lib.splitString " " cfg.cudaDevices;
};
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
LockPersonality = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
PrivateMounts = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectProc = "invisible";
UMask = "0077";
# Only capability kept: renice / thread-pin.
CapabilityBoundingSet = [ "CAP_SYS_NICE" ];
AmbientCapabilities = [ "CAP_SYS_NICE" ];
SystemCallArchitectures = "native";
};
};
services.nginx.virtualHosts.${cfg.domain} = {
forceSSL = true;
useACMEHost = cfg.acmeHost;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
proxyWebsockets = true; # kernel comm channel
recommendedProxySettings = true;
extraConfig = ''
proxy_set_header Accept-Encoding "";
proxy_read_timeout 86400; # let a single cell run for a full day
'';
};
};
};
}