Skip to content

Application Configuration

The JupyterHub Outpost uses a configuration file outpost_config.py similar to jupyterhub_config.py of JupyterHub. The Spawner configuration for the Outpost is therefore similar to the Spawner configuration in JupyterHub. The easiest way is configure the Outpost's configuration file is via the outpostConfig key in the helm chart's values.yaml file.

Persistent database

To use a persistent database such as postgresql with JupyterHub Outpost, use extraEnvVarsSecrets in your values.yaml file. All possible values related to the database connection can be found in the source code itself.

Ensure that you have a database such postgres installed and that a JupyterHub Outpost user and database exists.

Example SQL commands for postgresql:

CREATE USER jupyterhuboutpost WITH ENCRYPTED PASSWORD '...';
CREATE DATABASE jupyterhuboutpost OWNER jupyterhuboutpost;

Create a secret in your Outpost namespace with the required values before installing JupyterHub Outpost:

kind: Secret
metadata:
  name: my-db-secret
...
stringData:
  SQL_TYPE: "postgresql"
  SQL_USER: "jupyterhuboutpost"
  SQL_PASSWORD: "..."
  SQL_HOST: "postgres.database.svc"
  SQL_PORT: "5432"
  SQL_DATABASE: "jupyterhuboutpost"

And add the database secret to your Outpost values.yaml file:

...
extraEnvVarsSecrets:
  - my-db-secret

Simple KubeSpawner

Jupyter4NFDI sends a profile to your Spawner. This is a small example which ignores the profile and always runs the Jupyter Server with the predefined setup.

values.yaml file:

outpostConfig: |
  from kubespawner import KubeSpawner
  c.JupyterHubOutpost.spawner_class = KubeSpawner

  c.KubeSpawner.start_timeout = 600

  async def profile_list(spawner):
      jupyterhub_name = spawner.jupyterhub_name
      spawner.log.info(f"{spawner._log_name} - Received these user_options from {jupyterhub_name}-JupyterHub: {spawner.user_options}")
      slug = spawner.user_options.get("profile", "default")
      default_image = "jupyter/minimal-notebook:notebook-7.0.3"
      return [
        {
            "display_name": slug,
            "slug": slug,
            "kubespawner_override": {
                "image": default_image
            }
        }
      ]

  c.KubeSpawner.profile_list = profile_list

Update or install JupyterHub Outpost with values.yaml file:

helm upgrade --install -f values.yaml --namespace outpost outpost jupyterhub-outpost/jupyterhub-outpost

In this example we use the KubeSpawner, but you can use any JupyterHub Spawner.

Customize Logging

For the logging configuration, the Outpost offers these options (corresponding to the logging options of JupyterHub):

c.JupyterHubOutpost.log_level = ...
c.JupyterHubOutpost.log_datafmt = ...
c.JupyterHubOutpost.log_format = ...

If more customization is required, one can do this directly in the outpost_config.py file itself (possible via the outpostConfig key of the helm chart).

# Suppress /ping loggings, created by k8s livenessprobe
uvicorn_access = logging.getLogger("uvicorn.access")
class UvicornFilter(logging.Filter):
    def filter(self, record):
        try:
            if "/ping" in record.args:
                return False
        except:
            pass
        return True

uvicorn_access.addFilter(UvicornFilter())

# Suppress missing static files by Tornado Logger
tornado_general = logging.getLogger("tornado.general")
class TornadoGeneralLoggingFilter(logging.Filter):
  def filter(self, record):
    # I don't want to see this log line generated by tornado
    if str(record.msg).startswith("Could not open static file"):
      return False
    return True

tornado_general.addFilter(TornadoGeneralLoggingFilter())

import os
logged_logger_name = os.environ.get("LOGGER_NAME", "MyOutpostInstance")
c.JupyterHubOutpost.log_format = f"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d {logged_logger_name} %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"

Sanitize Spawner.start response

JupyterHub Outpost relies on the start function of the configured SpawnerClass to determine where the single-user server will run. For instance, with KubeSpawner, the KubeSpawner.start() function might return a URL like http://jupyter-<id>-<user_id>:<port>, which Outpost will pass along to JupyterHub.

The OutpostSpawner then uses this information to set up an SSH port-forwarding process with the command -L 0.0.0.0:<local_jhub_port>:jupyter-<id>-<user_id>:<port>. Afterward, JupyterHub will access the single-user server at http://localhost:<local_jhub_port>.

If the response from the start function isn't correct, the OutpostSpawner and Outpost won't work together properly. However, to support most Spawners, you can customize the response that is sent to the OutpostSpawner.

# In the `outpostConfig` key of your helm values.yaml file or your outpost_config.py file:

# This may be a coroutine
def sanitize_start_response(spawner, original_response):
  # ... determine the correct location for the new single-user server
  return "<...>"

c.JupyterHubOutpost.sanitize_start_response = sanitize_start_response

If you don't know where your single-user server will be running at the end of the start function, you have to return an empty string. In that case, JupyterHub OutpostSpawner won't create a ssh port-forwarding process. Instead, the start process of the single-user server has to send a POST request to the $JUPYTERHUB_SETUPTUNNEL_URL url. Have a look at the API Endpoints of the OutpostSpawner (https://jupyterhub-outpostspawner.readthedocs.io/en/latest/apiendpoints.html) for more information.

Disable configuration overwrite

By default, Jupyter4NFDI can overwrite the JupyterHub Outpost configuration with the OutpostSpawner's custom_misc function. As an administrator of the JupyterHub Outpost service, you can prevent this.

# In the `outpostConfig` key of your helm values.yaml file or your outpost_config.py file:

async def allow_override(jupyterhub_name, misc):
    if jupyterhub_name == "trustedhub":
        return True
    if list(misc.keys()) != ["image"]:
        return False
    return misc.get("image", "None") in ["allowed_image1", "allowed_image2"]

c.JupyterHubOutpost.allow_override = allow_override

The above example leads to the following behaviour:
- JupyterHub with credential username "trustedhub" can overwrite anything. - If a JupyterHub (other than trustedhub) tries to overwrite anything except the image key, it will not be allowed. - The given image must be allowed_image1 or allowed_image2.

If custom_misc in the POST request is empty, the allow_override function will not be called. If allow_override returns False, the JupyterLab will not be started. An error message will be returned to the JupyterHub OutpostSpawner and shown to the user.

Recreate ssh tunnels at startup

If your JupyterHub Outpost is used as a ssh node in the JupyterHub OutpostSpawner, all port-forwarding processes have to be recreated if the JupyterHub Outpost service was restarted. While restarting, existing ssh port-forwarding processes will fail after a few seconds and the user's single-user server would be unreachable.

By default tunnels will be recreated at JupyterHub Outpost restarts. You can disable this behaviour with the ssh_recreate_at_start key.

# In the `outpostConfig` key of your helm values.yaml file or your outpost_config.py file:

async def restart_tunnels(wrapper, jupyterhub_credential):
    if jupyterhub_credential == "local_jupyterhub":
        return False
    return True

c.JupyterHubOutpost.ssh_recreate_at_start = restart_tunnels
# c.JupyterHubOutpost.ssh_recreate_at_start = False

JupyterHub Outpost will use the stored JupyterHub API token to recreate the port-forwarding process. If the API token is no longer valid, this will fail. The single-user server would then be unreachable and must be restarted by the user.

Flavors

Overview

Flavors define preconfigured resource templates for User sessions. They specify runtime limits and user constraints, helping you manage system load and provide differentiated access based on user groups or hub configurations.

Flavors are mandatory and part of the default configuration of the Outpost. All users will have access to the default flavors unless specified otherwise.


Basic Flavor Structure

A flavor configuration typically looks like this:

flavors:
  flavors:
    default:
      max: 20
      maxPerUser: 3
      weight: 11
      display_name: "Default"
      description: "Service will run with normal resources for max 5 days"
      runtime:
        hours: 120
      resources:
        cpu_guarantee: 0.1
        cpu_limit: 1
        mem_guarantee: "256M"
        mem_limit: "2048M"

The flavors default and minimal are always active. Set them to {} to deactivate them. Or add a hub specific configuration (see below) and don't include default and minimal in the supported flavors for this hub.

Resources must be used by your Spawner Configuration.

Parameters

Key Description
display_name User-facing name of the flavor
description Description of resources or limitations
runtime Maximum lifetime of a session using this flavor. Supported keys: days, hours, minutes
resources Can be used to define allowed resources
max Maximum total number of concurrent sessions using this flavor
maxPerUser Maximum number of sessions per user using this flavor
weight Controls the ordering in the flavor list; higher weights appear first

Per-User Flavor Control

You can assign flavors to users based on their authentication attributes (like username, email). This allows differentiated access control. Ask the JupyterHub authenticator for the attributes, or set the log level to trace and check the Outpost logs.

Example 1: Allow minimal flavor for non-company users

users:
  publicUsers:
    negate_authentication: true 
    authentication:
      username: ".*@mycompany.org"
    flavors: ["minimal"]
    weight: 10
  • All users not ending with @mycompany.org are only allowed to use the minimal flavor.

Example 2: Block all Gmail users

users:
  googleUsers:
    authentication:
      username: ".*@g(oogle)*mail.com"
    forbidden: true
    weight: 20
  • Users with Gmail or Google Mail addresses will be denied access.

Each field in authentication (like username, email, etc.) supports multiple match types:

  • A regular expression (e.g., "^user[0-9]+@example.com$")
  • A glob-style pattern (e.g., "*@example.com")
  • A literal value (e.g., "admin@example.com")
  • Or a list of allowed literal values.

The system will try to match in this order: Regex → Glob → Exact match.

This gives you flexibility in how users are grouped and access is granted.


Per-Hub Flavor Control

In environments with multiple JupyterHub instances, you can configure flavors per hub using the hubs section:

hubs:
  minimalhub:
    weight: 15
    jupyterhub_name:
      - hubmini
    flavors:
      - minimal

  normalhubs:
    weight: 10
    jupyterhub_name:
      - huba
      - hubb
    flavors:
      - default
  • hubmini will only offer the minimal flavor.
  • huba and hubb will offer the default flavor.

The weight controls the priority if multiple groups match — the one with the highest weight takes precedence.


Default Behavior

If no user or hub restrictions are configured:

  • All defined flavors will be available to all users.
  • This behavior can be overridden by defining users or hubs.

Recommendations

  • Start with a base set of flavors (minimal, default) and refine access over time.
  • Use negate_authentication for simple allow/deny matching logic based on patterns.
  • Always test your regex for username carefully to avoid unintentional matches.
  • Use weight wisely to control precedence in overlapping rules.

Use Cases

  • Blocked Users: Administrators can configure flavors for specific users that deny access, effectively blocking them from launching any jupyter servers.
  • Prioritized Users: For power users, administrators can assign more resources (e.g., higher CPU, additional memory) to ensure they have the performance needed for demanding tasks.
  • External Users: For guest or external users, administrators may provide a default, minimal resource allocation to prevent overuse of the system’s resources.

Benefits

  • Resource Management: Fine-grained control over resource allocation ensures efficient use of system resources.
  • User Control: Administrators can easily adjust resource access based on user needs or status (e.g., external user vs internal user).
  • Scalability: As your user base grows, you can easily apply different flavors to new users without major changes to the overall configuration.

Update Tokens

The JupyterHub OutpostSpawner offers an APIEndpoint, which receives or offers the current Flavors of all connected JupyterHub Outposts. With this function the Outpost will inform the connected JupyterHubs at each start/stop of a notebook server, about the current flavor situation. The corresponding URL will be given by the OutpostSpawner.

import os
async def flavors_update_token(jupyterhub_name):
    token = os.environ.get(f"FLAVOR_{jupyterhub_name.upper()}_AUTH_TOKEN", "")
    if not token:
        raise Exception(f"Flavor auth token for {jupyterhub_name} not configured.")
    return token
c.JupyterHubOutpost.flavors_update_token = flavors_update_token

In case of an exception the update is not send to JupyterHub. This will not interfere with the start of the notebook server.
Each connected JupyterHub must provide a service token with scope custom:outpostflavors:set to the Outpost administrator.