Files
stellars-jupyterhub-ds/docs/custom-branding.md
stellarshenson 3512ad1023 fix: register favicon CHP routes in extra_routes to prevent stale deletion
check_routes() runs every ~5min and deletes any CHP route not in its
good_routes set. Favicon routes added only via add_route() were treated
as stale and removed after ~5 minutes. Now also registered in
app.proxy.extra_routes so check_routes() recognizes them as legitimate.
Applied to both pre_spawn_hook and startup callback for surviving servers.
2026-02-09 17:17:51 +01:00

7.8 KiB

Custom Branding

Custom logo and favicon for the JupyterHub platform, controlled via environment variables. Both support local files (file:// prefix) and external URLs (http(s)://). Empty value uses stock JupyterHub assets.

Environment Variables

Variable Purpose Values
JUPYTERHUB_LOGO_URI Hub login and navigation logo file:///path/to/logo.svg or https://example.com/logo.svg
JUPYTERHUB_FAVICON_URI Browser tab favicon for hub and JupyterLab file:///path/to/favicon.ico or https://example.com/favicon.ico
JUPYTERHUB_LAB_MAIN_ICON_URI JupyterLab main logo (toolbar) file:///path/to/icon.svg or https://example.com/icon.svg
JUPYTERHUB_LAB_SPLASH_ICON_URI JupyterLab splash screen icon file:///path/to/splash.svg or https://example.com/splash.svg

file:// handling: The file is copied to JupyterHub's static directory at startup. The source file must be accessible inside the container (mount via compose volumes). Logo and favicon are served via static_url(). Lab icons are resolved to hub static URLs and passed to spawned containers as JUPYTERLAB_MAIN_ICON_URI and JUPYTERLAB_SPLASH_ICON_URI environment variables.

URL handling: External URLs are passed directly to templates (logo, favicon) or to spawned container env vars (lab icons).

Favicon in JupyterLab Sessions

Hub pages serve the custom favicon directly. JupyterLab sessions present a challenge because favicon requests (/user/{username}/static/favicons/favicon.ico) are routed by Configurable HTTP Proxy (CHP) to the user container, bypassing the hub entirely. The user container serves JupyterLab's default favicon.

Solution - CHP Proxy Route Injection

The platform uses CHP's trie-based longest-prefix-match routing to intercept favicon requests before they reach user containers.

How it works:

  1. pre_spawn_hook registers a per-user CHP route (/user/{username}/static/favicons/) pointing back to the hub
  2. CHP's longest-prefix-match selects this over the generic /user/{username}/ route
  3. A FaviconRedirectHandler (injected into the Tornado app's wildcard router) responds with a 302 redirect to the hub's static favicon

Request flow:

Browser: GET /user/alice/static/favicons/favicon.ico
  -> CHP: longest-prefix matches /user/alice/static/favicons/ -> hub (host:port)
  -> Hub: FaviconRedirectHandler -> 302 /hub/static/favicon.ico
  -> Browser follows redirect, hub serves custom favicon

Technical Details - Three Pitfalls

Three non-obvious issues required specific solutions during implementation:

CHP target must be host:port only (no path). app.hub.url returns http://jupyterhub:8080/hub/ which includes a /hub/ path. When a CHP target has a path component, CHP rewrites the forwarded request path - stripping the matched route prefix and prepending the target path. This causes the hub to receive a mangled path that no handler matches. The fix uses urlparse to extract just scheme://netloc from app.hub.url, matching how JupyterHub registers its own hub route (/ -> http://jupyterhub:8080).

Tornado handler must be inserted into the existing wildcard router. app.tornado_application.add_handlers(".*", ...) creates a new host group that Tornado checks after all existing host groups. Since JupyterHub's default handlers include catch-all patterns, the new group is never reached. The fix uses app.tornado_application.wildcard_router.rules.insert(0, rule) to prepend the handler rule into the existing host group, ensuring it's checked before any catch-all.

Routes must be registered in extra_routes to survive check_routes(). JupyterHub periodically (~5 min) calls check_routes() which fetches all CHP routes and deletes any not in its known good_routes set. Routes added only via add_route() are treated as stale and removed. The fix registers each favicon route in app.proxy.extra_routes[routespec] = hub_target so check_routes() includes it in good_routes and re-adds it if missing.

Why not extra_handlers?

JupyterHub auto-prefixes all extra_handlers routes with /hub/. CHP forwards the original path without /hub/, so the handler would never match. Instead, FaviconRedirectHandler extends tornado.web.RequestHandler (not BaseHandler) and is injected directly into the Tornado app's wildcard router.

Route Lifecycle

  • New spawns: pre_spawn_hook registers per-user CHP route and adds to app.proxy.extra_routes before each spawn (idempotent)
  • Surviving servers: A one-shot IOLoop.current().add_callback() startup callback iterates all active servers and registers their CHP routes immediately after the event loop starts - this covers servers that were already running when JupyterHub restarted
  • Persistence: Routes registered in extra_routes survive check_routes() periodic cleanup and are re-added automatically if missing
  • Tornado handler is injected once (guarded by app._favicon_handler_injected flag) by whichever path executes first
  • Leftover routes when servers stop are harmless (hub is always running to handle them)
  • No cleanup needed

Conditionality

The entire mechanism only activates when JUPYTERHUB_FAVICON_URI is non-empty. When empty, JupyterLab sessions display their own default favicon.

JupyterLab Icons

JUPYTERHUB_LAB_MAIN_ICON_URI and JUPYTERHUB_LAB_SPLASH_ICON_URI customize the JupyterLab main toolbar logo and splash screen icon. Unlike logo and favicon (which are served by the hub directly), lab icons are passed as environment variables to spawned JupyterLab containers so that extensions can reference them.

Resolution logic (config/jupyterhub_config.py):

  • file:// - copies to hub static dir as lab-main-icon{ext} or lab-splash-icon{ext} at startup, then resolved to a fully qualified http:// URL at spawn time using app.hub.url (e.g., http://jupyterhub:8080/hub/static/lab-main-icon.svg)
  • http(s):// - passed through as-is
  • Empty - env var not injected into containers

URIs must be fully qualified with protocol - bare paths like /hub/static/icon.svg would be interpreted as filesystem locations by JupyterLab. The pre_spawn_hook resolves the hub origin from app.hub.url at runtime, avoiding hardcoded hostnames.

Container env vars (only set when URI is non-empty):

Hub env var Container env var Resolved URL example
JUPYTERHUB_LAB_MAIN_ICON_URI JUPYTERLAB_MAIN_ICON_URI http://jupyterhub:8080/hub/static/lab-main-icon.svg
JUPYTERHUB_LAB_SPLASH_ICON_URI JUPYTERLAB_SPLASH_ICON_URI http://jupyterhub:8080/hub/static/lab-splash-icon.svg

Extensions running in JupyterLab can read JUPYTERLAB_MAIN_ICON_URI and JUPYTERLAB_SPLASH_ICON_URI from the container environment to fetch the icon URLs.

Implementation Files

File Role
config/jupyterhub_config.py File copy at startup, CHP route + Tornado handler injection in pre_spawn_hook, lab icon runtime URL resolution and env var injection in pre_spawn_hook
services/jupyterhub/conf/bin/custom_handlers.py FaviconRedirectHandler class (extends tornado.web.RequestHandler)
services/jupyterhub/templates/page.html Conditional favicon rendering in <head>

Deployment Example

# compose_override.yml
services:
  jupyterhub:
    volumes:
      - ./branding/favicon.ico:/srv/jupyterhub/favicon.ico:ro
      - ./branding/logo.svg:/srv/jupyterhub/logo.svg:ro
      - ./branding/lab-icon.svg:/srv/jupyterhub/lab-icon.svg:ro
      - ./branding/splash-icon.svg:/srv/jupyterhub/splash-icon.svg:ro
    environment:
      - JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg
      - JUPYTERHUB_FAVICON_URI=file:///srv/jupyterhub/favicon.ico
      - JUPYTERHUB_LAB_MAIN_ICON_URI=file:///srv/jupyterhub/lab-icon.svg
      - JUPYTERHUB_LAB_SPLASH_ICON_URI=file:///srv/jupyterhub/splash-icon.svg