4.9 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 |
file:// handling: The file is copied to JupyterHub's static directory at startup, then served via static_url(). The source file must be accessible inside the container (mount via compose volumes).
URL handling: External URLs are passed directly to templates for rendering.
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 - Two Pitfalls
Two 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.
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 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 - Tornado handler is injected once (guarded by
app._favicon_handler_injectedflag) by whichever path executes first - Stale 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.
Implementation Files
| File | Role |
|---|---|
config/jupyterhub_config.py |
Favicon file copy at startup, CHP route + Tornado handler 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:
- ./favicon.ico:/srv/jupyterhub/favicon.ico:ro
- ./logo.svg:/srv/jupyterhub/logo.svg:ro
environment:
- JUPYTERHUB_FAVICON_URI=file:///srv/jupyterhub/favicon.ico
- JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg