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.
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:
pre_spawn_hookregisters a per-user CHP route (/user/{username}/static/favicons/) pointing back to the hub- CHP's longest-prefix-match selects this over the generic
/user/{username}/route - 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_hookregisters per-user CHP route and adds toapp.proxy.extra_routesbefore 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_routessurvivecheck_routes()periodic cleanup and are re-added automatically if missing - Tornado handler is injected once (guarded by
app._favicon_handler_injectedflag) 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 aslab-main-icon{ext}orlab-splash-icon{ext}at startup, then resolved to a fully qualifiedhttp://URL at spawn time usingapp.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