How to make jupyter-server-proxy work in Z2JH?

There have been many, many posts about this and I just don’t understand if it’s a configuration issue or whatever, but as much as I try to get my nonvc extraContainer which is exposed as localhost:8080 natively on my single-user, I can access this novnc simply by using hub/user/user/user-name/proxy/8080. Please, I need urgent help to be able to make this work.

As much as I have been researching and researching, I have not been able to come up with a good source of knowledge or a didactic example to help set this up right. Thank you very much.

Hub custom image:

FROM quay.io/jupyterhub/k8s-hub:3.1.0

USER root

# Instala el cliente de Kubernetes
RUN pip install kubernetes

# Instalamos 
RUN pip install jupyter-server-proxy

# Copia tu FastAPI (si lo necesitas)
COPY ./service-fastapi /usr/src/fastapi
RUN python3 -m pip install -r /usr/src/fastapi/requirements.txt

USER ${NB_USER}

Single-user image:

FROM ros:jazzy

ENV DEBIAN_FRONTEND=noninteractive
ENV PATH="/opt/venv/bin:$PATH"
ENV DISPLAY=novnc:0.0

# Instalar dependencias del sistema y entorno Python
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      python3-pip \
      python3-venv \
      nodejs \
      npm \
      curl \
      wget \
      httpie \
      sudo \
      bash \
 && apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    python3 -m venv /opt/venv && \
    /opt/venv/bin/pip install --upgrade pip && \
    /opt/venv/bin/pip install \
      jupyterhub==4.* \
      notebook==7.* \
      jupyter-server-proxy

# Crear usuario jovyan con permisos sudo sin contraseña
RUN useradd -m -s /bin/bash jovyan && \
    echo "jovyan ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# Usar bash como shell por defecto para jovyan
SHELL ["/bin/bash", "-c"]

# Configuración de entorno para jovyan
USER jovyan
WORKDIR /home/jovyan

# Activar entorno ROS en bashrc
RUN echo "source /opt/ros/jazzy/setup.bash" >> ~/.bashrc

CMD ["jupyterhub-singleuser"]

config.yaml:

proxy:
  service:
    type: LoadBalancer

singleuser:
  image:
    name: gcr.io/isis25-03-load-balancer/singleuser-custom
    tag: latest
  cmd: null
  cpu:
    limit: 1
    guarantee: 0.5
  memory:
    limit: 1G
    guarantee: 512M
  storage:
    capacity: 1Gi
  nodeSelector:
    hub.jupyter.org/node-purpose: user

  extraTolerations:
    - key: "hub.jupyter.org/dedicated"
      operator: "Equal"
      value: "user"
      effect: "NoSchedule"

  extraContainers:
    - name: novnc
      image: theasp/novnc:latest
      ports:
        - containerPort: 8080
      env:
        - name: DISPLAY_WIDTH
          value: "500"
        - name: DISPLAY_HEIGHT
          value: "500"
        - name: RUN_XTERM
          value: "no"

  networkPolicy:
    egress:
      - to:
          - podSelector:
              matchLabels:
                app.kubernetes.io/name: jupyterhub
                component: singleuser-server
        ports:
          - port: 8080
scheduling:
  userScheduler:
    enabled: true

hub:
  image:
    name: gcr.io/isis25-03-load-balancer/jupyterhub-custom
    tag: latest

  services:
    fastapi:
      url: http://hub:8181
      command:
        - /usr/local/bin/uvicorn
        - app:app
        - --port
        - "8181"
        - --host
        - "0.0.0.0"
        - --app-dir
        - /usr/src/fastapi
      oauth_redirect_uri: http://35.232.148.6/services/fastapi/oauth_callback
      environment:
        PUBLIC_HOST: http://35.232.148.6

  networkPolicy:
    ingress:
      - ports:
          - port: 8181
        from:
          - podSelector:
              matchLabels:
                hub.jupyter.org/network-access-hub: "true"

  service:
    extraPorts:
      - port: 8181
        targetPort: 8181
        name: fastapi

  config:
    JupyterHub:
      allow_named_servers: true
      services:
        - name: fastapi
          admin: true
      load_roles:
        - name: "service-with-admin-users"
          services: ["fastapi"]
          scopes: ["servers", "admin:users"]
        - name: "user"
          scopes: ["servers", "self", "access:services"]
        - name: "admin-role-for-testuser"
          users: ["test-user"]
          scopes: ["servers", "admin:users"]
    KubeSpawner:
      modify_pod_hook: null  # Esto se sobrescribirá con extraConfig

  extraConfig:
    00-add-novnc-values: |
      import os
      # Obtiene variables de entorno definidas por JupyterHub
      hub_host = os.environ.get("JUPYTERHUB_HOST", "localhost")
      hub_user = os.environ.get("JUPYTERHUB_USER", "unknown")
      # Construye la URL para acceder a noVNC a través del proxy
      novnc_url = "http://{}/user/{}/proxy/novnc/".format(hub_host, hub_user)
      print("NOVNC_URL configured as:", novnc_url)
      # Establece la variable en el entorno del singleuser
      c.KubeSpawner.environment.setdefault("NOVNC_URL", novnc_url)

    20-novnc-proxy-config: |
      # Registra el servicio noVNC en el proxy de JupyterHub
      c.ServerProxy.servers = {
          'novnc': {
              'command': [],  # Se asume que el contenedor extra ya está corriendo noVNC
              'port': 8080,
              'absolute_url': True,
              'timeout': 30,
              'launcher_entry': {
                  'title': 'noVNC',
                  'icon_path': '/usr/share/icons/hicolor/48x48/apps/novnc.png'  # Opcional
              }
          }
      }
      
proxy:
  chp:
    networkPolicy:
      egress:
        - to:
            - podSelector:
                matchLabels:
                  app.kubernetes.io/name: jupyterhub
                  app.kubernetes.io/component: hub
          ports:
            - port: 8181

Some logs:

No config at /usr/local/etc/jupyterhub/existing-secret/values.yaml
Loading extra config: 00-add-novnc-values
NOVNC_URL configured as: http://localhost/user/unknown/proxy/novnc/
Loading extra config: 20-novnc-proxy-config
Loading extra config: 25-novnc-debug
Registered proxy servers: <LazyConfigValue {'update': {'novnc': {'command': [], 'port': 8080, 'absolute_url': True, 'timeout': 30, 'launcher_entry': {'title': 'noVNC', 'icon_path': '/usr/share/icons/hicolor/48x48/apps/novnc.png'}}}}>
[I 2025-04-05 02:36:14.037 JupyterHub app:2859] Running JupyterHub version 4.0.2
[I 2025-04-05 02:36:14.037 JupyterHub app:2889] Using Authenticator: jupyterhub.auth.DummyAuthenticator-4.0.2
[I 2025-04-05 02:36:14.037 JupyterHub app:2889] Using Spawner: kubespawner.spawner.KubeSpawner-6.1.0
[I 2025-04-05 02:36:14.037 JupyterHub app:2889] Using Proxy: jupyterhub.proxy.ConfigurableHTTPProxy-4.0.2
[I 2025-04-05 02:36:14.136 JupyterHub app:1984] Not using allowed_users. Any authenticated user will be allowed.
[I 2025-04-05 02:36:14.175 JupyterHub provider:661] Updating oauth client service-fastapi
[I 2025-04-05 02:36:14.260 JupyterHub reflector:282] watching for pods with label selector='component=singleuser-server' in namespace jhub
[W 2025-04-05 02:36:14.267 JupyterHub _version:37] Single-user server has no version header, which means it is likely < 0.8. Expected 4.0.2
[I 2025-04-05 02:36:14.268 JupyterHub app:2573] hola still running
[I 2025-04-05 02:36:14.268 JupyterHub app:2928] Initialized 1 spawners in 0.036 seconds
[I 2025-04-05 02:36:14.275 JupyterHub metrics:278] Found 1 active users in the last ActiveUserPeriods.twenty_four_hours
[I 2025-04-05 02:36:14.276 JupyterHub metrics:278] Found 1 active users in the last ActiveUserPeriods.seven_days
[I 2025-04-05 02:36:14.277 JupyterHub metrics:278] Found 1 active users in the last ActiveUserPeriods.thirty_days
[I 2025-04-05 02:36:14.277 JupyterHub app:3142] Not starting proxy
[I 2025-04-05 02:36:14.282 JupyterHub app:3178] Hub API listening on http://:8081/hub/
[I 2025-04-05 02:36:14.283 JupyterHub app:3180] Private Hub API connect url http://hub:8081/hub/
[I 2025-04-05 02:36:14.283 JupyterHub app:3189] Starting managed service jupyterhub-idle-culler
[I 2025-04-05 02:36:14.283 JupyterHub service:385] Starting service 'jupyterhub-idle-culler': ['python3', '-m', 'jupyterhub_idle_culler', '--url=http://localhost:8081/hub/api', '--timeout=3600', '--cull-every=600', '--concurrency=10']
[I 2025-04-05 02:36:14.284 JupyterHub service:133] Spawning python3 -m jupyterhub_idle_culler --url=http://localhost:8081/hub/api --timeout=3600 --cull-every=600 --concurrency=10
[I 2025-04-05 02:36:14.285 JupyterHub app:3189] Starting managed service fastapi at http://hub:8181
[I 2025-04-05 02:36:14.285 JupyterHub service:385] Starting service 'fastapi': ['/usr/local/bin/uvicorn', 'app:app', '--port', '8181', '--host', '0.0.0.0', '--app-dir', '/usr/src/fastapi']
[I 2025-04-05 02:36:14.288 JupyterHub service:133] Spawning /usr/local/bin/uvicorn app:app --port 8181 --host 0.0.0.0 --app-dir /usr/src/fastapi
[I 2025-04-05 02:36:15.515 JupyterHub log:191] 200 GET /hub/api/ (jupyterhub-idle-culler@127.0.0.1) 10.22ms
[W 2025-04-05 02:36:15.522 JupyterHub scopes:881] Not authorizing access to /hub/api/users. Requires any of [list:users], not derived from scopes []
[W 2025-04-05 02:36:15.522 JupyterHub web:1869] 403 GET /hub/api/users?state=ready (127.0.0.1): Action is not authorized with current scopes; requires any of [list:users]
[W 2025-04-05 02:36:15.523 JupyterHub log:191] 403 GET /hub/api/users?state=[secret] (jupyterhub-idle-culler@127.0.0.1) 4.12ms
[E 250405 02:36:15 ioloop:758] Exception in callback functools.partial(<bound method IOLoop._discard_future_result of <tornado.platform.asyncio.AsyncIOMainLoop object at 0x7a03f9564910>>, <Task finished name='Task-1' coro=<cull_idle() done, defined at /usr/local/lib/python3.11/site-packages/jupyterhub_idle_culler/__init__.py:73> exception=HTTP 403: Forbidden>)
    Traceback (most recent call last):
      File "/usr/local/lib/python3.11/site-packages/tornado/ioloop.py", line 738, in _run_callback
        ret = callback()
              ^^^^^^^^^^
      File "/usr/local/lib/python3.11/site-packages/tornado/ioloop.py", line 762, in _discard_future_result
        future.result()
      File "/usr/local/lib/python3.11/site-packages/jupyterhub_idle_culler/__init__.py", line 422, in cull_idle
        async for user in fetch_paginated(req):
      File "/usr/local/lib/python3.11/site-packages/jupyterhub_idle_culler/__init__.py", line 135, in fetch_paginated
        response = await resp_future
                   ^^^^^^^^^^^^^^^^^
      File "/usr/local/lib/python3.11/site-packages/jupyterhub_idle_culler/__init__.py", line 117, in fetch
        return await client.fetch(req)
               ^^^^^^^^^^^^^^^^^^^^^^^
    tornado.httpclient.HTTPClientError: HTTP 403: Forbidden
INFO:     Started server process [13]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8181 (Press CTRL+C to quit)
INFO:     10.28.1.1:49175 - "GET /services/fastapi/ HTTP/1.1" 200 OK
[W 2025-04-05 02:36:15.654 JupyterHub proxy:445] Adding missing route for fastapi (Server(url=http://hub:8181/services/fastapi/, bind_url=http://hub:8181/services/fastapi/))
[I 2025-04-05 02:36:15.656 JupyterHub proxy:311] Adding service fastapi to proxy /services/fastapi/ => http://hub:8181
[I 2025-04-05 02:36:15.662 JupyterHub app:3247] JupyterHub is now running, internal Hub API at http://hub:8081/hub/

jupyter-server-proxy only runs as a jupyter-server/JupyterLab extension. It doesn’t have any interactions with JupyterHub, so everything needs to be installed and configured in your singleuser-server/pod.

For development you should be able to run jupyter-server/JupyterLab as a standalone pod without JupyterHub.

Can you explain exactly what isn’t working?

might be helpful.

1 Like

Thank you very much for your help, the truth is that documenting myself a little and helping me from this forum, I realized that I needed to configure the following in the Dockerfile of the singleuser:

COPY config/jupyter/jupyter_notebook_config.py /etc/jupyter/jupyter_notebook_config.py

and the novnc configuration found in jupyter_notebook_config.py, is as follows:

import os

c.ServerProxy.servers = {
  'novnc': {
    # Indica que se debe usar la URL absoluta en lugar de arrancar un comando.
    'absolute_url': True,
    # La URL donde noVNC ya está corriendo, asumiendo que el contenedor se llama "novnc" y escucha en el puerto 8080
    'url': 'http://localhost:8080/',
    'launcher_entry': {
      'path_info': 'novnc',
      'title': 'noVNC',
    }
  }
}

c.ServerApp.preferred_dir = os.getcwd()
c.FileContentsManager.allow_hidden = True

with this and based on the config.yaml configuration that I sent previously where I configure an extraContainer with novnc, I can already have, apart from the notebook, a novnc in the route:
http://{IP}/user/{username}/proxy/8080/vnc_auto.html

The problem is that apparently vnc does not work because of a websockets handling issue that I guess has to do with the proxy:

this are the logs in the extraContainer of one test user (kubectl logs jupyter-hola -c novnc -n jhub)

+ RUN_FLUXBOX=yes
+ RUN_XTERM=no
+ case $RUN_FLUXBOX in
+ case $RUN_XTERM in
+ rm -f /app/conf.d/xterm.conf
+ exec supervisord -c /app/supervisord.conf
2025-04-05 16:10:59,439 CRIT Supervisor is running as root.  Privileges were not dropped because no user is specified in the config file.  If you intend to run as root, you can set user=root in the config file to avoid this message.
2025-04-05 16:10:59,440 INFO Included extra file "/app/conf.d/fluxbox.conf" during parsing
2025-04-05 16:10:59,440 INFO Included extra file "/app/conf.d/websockify.conf" during parsing
2025-04-05 16:10:59,440 INFO Included extra file "/app/conf.d/x11vnc.conf" during parsing
2025-04-05 16:10:59,440 INFO Included extra file "/app/conf.d/xvfb.conf" during parsing
2025-04-05 16:10:59,444 INFO supervisord started with pid 1
2025-04-05 16:11:00,448 INFO spawned: 'fluxbox' with pid 8
2025-04-05 16:11:00,452 INFO spawned: 'websockify' with pid 9
2025-04-05 16:11:00,455 INFO spawned: 'x11vnc' with pid 10
2025-04-05 16:11:00,459 INFO spawned: 'xvfb' with pid 11
2025-04-05 16:11:01,508 INFO success: fluxbox entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2025-04-05 16:11:01,508 INFO success: websockify entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2025-04-05 16:11:01,508 INFO success: x11vnc entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2025-04-05 16:11:01,508 INFO success: xvfb entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

As you can see, the novnc server is running normally.

Regarding the link you gave me, I really do not understand very well the step by step to configure it in my system or in this same use case, if you could give me an example or a step by step I would be more than grateful, since I suppose that using jupyter-remote-desktop-proxy this issue of websockets has already been solved.

Thank you in advance!