diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md
index 868a2ef..503f517 100644
--- a/.claude/JOURNAL.md
+++ b/.claude/JOURNAL.md
@@ -138,3 +138,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c
45. **Task - Selective notification recipients**: Enhanced notification broadcast to allow targeting specific servers
**Result**: Added ActiveServersHandler (`GET /api/notifications/active-servers`) to list active servers. Modified BroadcastNotificationHandler to accept optional `recipients` array - filters to selected users if provided, sends to all if omitted (backward compatible). Updated notifications.html with "Send to all active servers" checkbox, server selection list with Select All/Deselect All buttons, dynamic button text showing recipient count. Validation prevents sending with no recipients selected
+
+46. **Task - Idle server culler**: Implemented automatic shutdown of inactive servers
+ **Result**: Added jupyterhub-idle-culler package to Dockerfile. Added configuration with environment variables: IDLE_CULLER_ENABLED (default 0), IDLE_CULLER_TIMEOUT (default 86400s/24h), IDLE_CULLER_CULL_EVERY (default 600s/10min), IDLE_CULLER_MAX_AGE (default 0/unlimited). Service runs as managed JupyterHub service with role-based scopes. Disabled by default, opt-in via IDLE_CULLER_ENABLED=1. Bumped version to 3.6.0
diff --git a/README.md b/README.md
index f7c6ba3..497c350 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Multi-user JupyterHub 4 deployment platform with data science stack, GPU support
- **Native Authentication**: Built-in user management with NativeAuthenticator supporting optional self-registration (`ENABLE_SIGNUP`) and admin approval. Authorization page protects existing users from accidental discard - only pending signup requests can be discarded
- **Admin User Creation**: Batch user creation from admin panel with auto-generated mnemonic passwords (e.g., `storm-apple-ocean`). Credentials modal with copy/download options
- **Shared Storage**: Optional CIFS/NAS mount support for shared datasets across all users
+- **Idle Server Culler**: Automatic shutdown of inactive servers after configurable timeout (default: 24 hours). Frees resources when users leave servers running
- **Production Ready**: Traefik reverse proxy with TLS termination, automatic container updates via Watchtower
## User Interface
@@ -360,6 +361,24 @@ services:
- ENABLE_SIGNUP=0 # disable self-registration, admin creates users
```
+#### Idle Server Culler
+
+Automatically stop user servers after a period of inactivity to free up resources. Disabled by default.
+
+```yaml
+services:
+ jupyterhub:
+ environment:
+ - IDLE_CULLER_ENABLED=1 # enable idle culler
+ - IDLE_CULLER_TIMEOUT=86400 # 24 hours (default) - stop after this many seconds of inactivity
+ - IDLE_CULLER_CULL_EVERY=600 # 10 minutes (default) - how often to check for idle servers
+ - IDLE_CULLER_MAX_AGE=0 # 0 (default) - max server age regardless of activity (0=unlimited)
+```
+
+**Behavior**:
+- `IDLE_CULLER_TIMEOUT`: Server is stopped after this many seconds without activity. Active servers are never culled
+- `IDLE_CULLER_MAX_AGE`: Force stop servers older than this (useful to force image updates). Set to 0 to disable
+
#### Custom Branding
Replace the default JupyterHub logo with a custom logo. Mount your logo file and reference it via environment variable:
diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py
index c88e527..524a6d3 100644
--- a/config/jupyterhub_config.py
+++ b/config/jupyterhub_config.py
@@ -188,7 +188,11 @@ ENABLE_GPU_SUPPORT = int(os.environ.get("ENABLE_GPU_SUPPORT", 2))
ENABLE_SERVICE_MLFLOW = int(os.environ.get("ENABLE_SERVICE_MLFLOW", 1))
ENABLE_SERVICE_GLANCES = int(os.environ.get("ENABLE_SERVICE_GLANCES", 1))
ENABLE_SERVICE_TENSORBOARD = int(os.environ.get("ENABLE_SERVICE_TENSORBOARD", 1))
-ENABLE_SIGNUP = int(os.environ.get("ENABLE_SIGNUP", 1)) # 0 - disabled (admin creates users), 1 - enabled (self-registration)
+ENABLE_SIGNUP = int(os.environ.get("ENABLE_SIGNUP", 1)) # 0 - disabled (admin creates users), 1 - enabled (self-registration)
+IDLE_CULLER_ENABLED = int(os.environ.get("IDLE_CULLER_ENABLED", 0)) # 0 - disabled, 1 - enabled
+IDLE_CULLER_TIMEOUT = int(os.environ.get("IDLE_CULLER_TIMEOUT", 86400)) # idle timeout in seconds (default: 24 hours)
+IDLE_CULLER_CULL_EVERY = int(os.environ.get("IDLE_CULLER_CULL_EVERY", 600)) # check interval in seconds (default: 10 min)
+IDLE_CULLER_MAX_AGE = int(os.environ.get("IDLE_CULLER_MAX_AGE", 0)) # max server age in seconds (0 = unlimited)
TF_CPP_MIN_LOG_LEVEL = int(os.environ.get("TF_CPP_MIN_LOG_LEVEL", 3))
DOCKER_NOTEBOOK_DIR = "/home/lab/workspace"
JUPYTERHUB_BASE_URL = os.environ.get("JUPYTERHUB_BASE_URL")
@@ -421,4 +425,41 @@ if c is not None:
(r'/notifications', NotificationsPageHandler),
]
+ # Idle culler service - automatically stops servers after inactivity
+ if IDLE_CULLER_ENABLED == 1:
+ import sys
+
+ # Define role with required scopes for idle culler
+ c.JupyterHub.load_roles = [
+ {
+ "name": "jupyterhub-idle-culler-role",
+ "scopes": [
+ "list:users",
+ "read:users:activity",
+ "read:servers",
+ "delete:servers",
+ ],
+ "services": ["jupyterhub-idle-culler"],
+ }
+ ]
+
+ # Configure idle culler service
+ culler_cmd = [
+ sys.executable,
+ "-m", "jupyterhub_idle_culler",
+ f"--timeout={IDLE_CULLER_TIMEOUT}",
+ f"--cull-every={IDLE_CULLER_CULL_EVERY}",
+ ]
+ if IDLE_CULLER_MAX_AGE > 0:
+ culler_cmd.append(f"--max-age={IDLE_CULLER_MAX_AGE}")
+
+ c.JupyterHub.services = [
+ {
+ "name": "jupyterhub-idle-culler",
+ "command": culler_cmd,
+ }
+ ]
+
+ print(f"[Idle Culler] Enabled - timeout={IDLE_CULLER_TIMEOUT}s, check every={IDLE_CULLER_CULL_EVERY}s, max_age={IDLE_CULLER_MAX_AGE}s")
+
# EOF
diff --git a/project.env b/project.env
index 1ae8a52..9846073 100644
--- a/project.env
+++ b/project.env
@@ -3,8 +3,8 @@ PROJECT_NAME="stellars-jupyterhub-ds"
PROJECT_DESCRIPTION="Multi-user JupyterHub 4 deployment platform with data science stack, GPU auto-detection, NativeAuthenticator, and isolated per-user environments spawned via DockerSpawner"
# Version
-VERSION="3.5.46_cuda-12.9.1_jh-5.4.2"
-VERSION_COMMENT="Admin user creation with auto-generated passwords, NativeAuth sync, custom templates"
+VERSION="3.6.0_cuda-12.9.1_jh-5.4.2"
+VERSION_COMMENT="Idle server culler, selective notifications, volume encoding fix"
RELEASE_TAG="RELEASE_3.2.11"
RELEASE_DATE="2025-11-09"
diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub
index d5147da..a155003 100644
--- a/services/jupyterhub/Dockerfile.jupyterhub
+++ b/services/jupyterhub/Dockerfile.jupyterhub
@@ -43,11 +43,12 @@ COPY --chmod=644 services/jupyterhub/templates_enhanced/*.html /srv/jupyterhub/
COPY --chmod=644 services/jupyterhub/templates_enhanced/static/custom.css /tmp/custom.css
COPY --chmod=644 config/jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
-## install dockerspawner, nativeauthenticator
+## install dockerspawner, nativeauthenticator, idle-culler
RUN pip install -U --no-cache-dir \
docker \
dockerspawner \
- jupyterhub-nativeauthenticator
+ jupyterhub-nativeauthenticator \
+ jupyterhub-idle-culler
## copy custom.css to JupyterHub's static directory
RUN <<-EOF