diff --git a/stack/lab/Dockerfile b/stack/lab/Dockerfile
index 22335e05..f0ac77b3 100644
--- a/stack/lab/Dockerfile
+++ b/stack/lab/Dockerfile
@@ -47,6 +47,11 @@ RUN jupyter nbextension enable --py --sys-prefix appmode && \
# Swap appmode icon for AiiDAlab gears icon, shown during app load
COPY --chown=${NB_UID}:${NB_GID} gears.svg ${CONDA_DIR}/share/jupyter/nbextensions/appmode/gears.svg
+# Set up opt-in countdown feature
+ARG PYTHON_MINOR_VERSION
+ENV PYTHON_MINOR_VERSION=${PYTHON_MINOR_VERSION}
+COPY --chown=${NB_UID}:${NB_GID} countdown/ ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom/
+
# Copy start-up scripts for AiiDAlab.
COPY before-notebook.d/* /usr/local/bin/before-notebook.d/
@@ -99,5 +104,4 @@ ENV NOTEBOOK_ARGS=\
"--TerminalManager.cull_interval=300"
# Set up the logo of notebook interface
-ARG PYTHON_MINOR_VERSION
COPY --chown=${NB_UID}:${NB_GID} aiidalab-wide-logo.png ${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/base/images/logo.png
diff --git a/stack/lab/before-notebook.d/70_prepare_countdown_config.sh b/stack/lab/before-notebook.d/70_prepare_countdown_config.sh
new file mode 100644
index 00000000..98065ccb
--- /dev/null
+++ b/stack/lab/before-notebook.d/70_prepare_countdown_config.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -e
+
+CUSTOM_DIR="${CONDA_DIR}/lib/python${PYTHON_MINOR_VERSION}/site-packages/notebook/static/custom"
+
+if [ "$LIFETIME" ]; then
+ EPHEMERAL=true
+
+ # Convert LIFETIME from HH:MM:SS to seconds
+ IFS=: read -r H M S <<<"$LIFETIME"
+ LIFETIME_SEC=$((10#$H * 3600 + 10#$M * 60 + 10#$S))
+
+ # Calculate expiry timestamp in UTC
+ EXPIRY=$(date -u -d "+${LIFETIME_SEC} seconds" +"%Y-%m-%dT%H:%M:%SZ")
+ export EXPIRY
+else
+ EPHEMERAL=false
+fi
+
+export EPHEMERAL
+envsubst <"${CUSTOM_DIR}/config.json.template" >"$CUSTOM_DIR/config.json"
+rm "${CUSTOM_DIR}/config.json.template"
diff --git a/stack/lab/countdown/config.json.template b/stack/lab/countdown/config.json.template
new file mode 100644
index 00000000..cf9a674b
--- /dev/null
+++ b/stack/lab/countdown/config.json.template
@@ -0,0 +1,4 @@
+{
+ "ephemeral": ${EPHEMERAL},
+ "expiry": "${EXPIRY}"
+}
diff --git a/stack/lab/countdown/custom.css b/stack/lab/countdown/custom.css
new file mode 100644
index 00000000..cd8e2a02
--- /dev/null
+++ b/stack/lab/countdown/custom.css
@@ -0,0 +1,33 @@
+#culling-countdown {
+ position: sticky;
+ top: 0;
+ left: 0;
+ width: 100%;
+ background-color: #0078d4;
+ color: white;
+ text-align: center;
+ padding: 8px;
+ font-size: 18px;
+ font-weight: bold;
+ z-index: 9999;
+}
+
+#shutdown-warning,
+#save-info {
+ display: none;
+}
+
+#shutdown-warning {
+ font-size: 20px;
+}
+
+#save-info {
+ font-size: 16px;
+ text-align: center;
+ font-weight: normal;
+ font-size: 18px;
+}
+
+#culling-timer {
+ margin-left: 5px;
+}
diff --git a/stack/lab/countdown/custom.js b/stack/lab/countdown/custom.js
new file mode 100644
index 00000000..46af9277
--- /dev/null
+++ b/stack/lab/countdown/custom.js
@@ -0,0 +1,103 @@
+require(["base/js/namespace", "base/js/events"], (Jupyter, events) => {
+ const parseLifetimeToMs = (str) => {
+ const parts = str.split(":").map(Number);
+ if (parts.length !== 3 || parts.some(isNaN)) {
+ return null;
+ }
+ const [h, m, s] = parts;
+ return ((h * 60 + m) * 60 + s) * 1000;
+ };
+
+ const insertCountdown = (remainingMs) => {
+ if (document.getElementById("culling-countdown")) {
+ return;
+ }
+
+ const banner = document.createElement("div");
+ banner.id = "culling-countdown";
+
+ const shutdownWarning = document.createElement("div");
+ shutdownWarning.id = "shutdown-warning";
+ shutdownWarning.innerHTML = "⚠️ Shutdown imminent! ⚠️";
+ banner.appendChild(shutdownWarning);
+
+ const countdown = document.createElement("div");
+ countdown.id = "countdown";
+ countdown.innerHTML = `Session time remaining: `;
+ const timer = document.createElement("span");
+ timer.id = "culling-timer";
+ timer.innerHTML = "Calculating...";
+ countdown.appendChild(timer);
+ banner.appendChild(countdown);
+
+ const saveInfo = document.createElement("div");
+ saveInfo.id = "save-info";
+ saveInfo.innerHTML = `
+ Consider saving your work using the File Manager or the Terminal
+ `;
+ banner.appendChild(saveInfo);
+
+ const endTime = new Date(Date.now() + remainingMs);
+
+ const formatTime = (seconds) => {
+ const hrs = `${Math.floor(seconds / 3600)}`.padStart(2, "0");
+ const mins = `${Math.floor((seconds % 3600) / 60)}`.padStart(2, "0");
+ const secs = `${Math.floor(seconds % 60)}`.padStart(2, "0");
+ return `${hrs}:${mins}:${secs}`;
+ };
+
+ const updateTimer = () => {
+ const now = new Date();
+ const timeLeft = (endTime - now) / 1000;
+ if (timeLeft < 0) {
+ clearInterval(interval);
+ return;
+ }
+ if (timeLeft < 1800) {
+ banner.style.backgroundColor = "#DAA801";
+ saveInfo.style.display = "block";
+ }
+ if (timeLeft < 300) {
+ banner.style.backgroundColor = "red";
+ shutdownWarning.style.display = "block";
+ shutdownWarning.innerHTML = "⚠️ Shutdown imminent ⚠️";
+ }
+ timer.innerHTML = formatTime(timeLeft);
+ };
+
+ updateTimer();
+ const interval = setInterval(updateTimer, 1000);
+
+ const container = document.getElementById("header");
+ if (container) {
+ container.parentNode.insertBefore(banner, container);
+ }
+ };
+
+ loadCountdown = async () => {
+ try {
+ const response = await fetch("/static/custom/config.json");
+ const config = await response.json();
+
+ // Opt-in point for deployments
+ if (!config.ephemeral) {
+ return;
+ }
+
+ if (!config.expiry) {
+ console.warn("Missing `expiry` in config file");
+ return;
+ }
+
+ const expiry = new Date(config.expiry).getTime();
+ const remaining = expiry - Date.now();
+
+ insertCountdown(Math.max(0, remaining));
+ } catch (err) {
+ console.error("Countdown init failed:", err);
+ }
+ };
+
+ events.on("app_initialized.NotebookApp", loadCountdown);
+ loadCountdown();
+});