---
title: Multi-hub gateway
description: Run several hubs behind one chat app, one sign-in and one Console with hubzoid gateway, including its flags, URL layout, data directory and bridges.
canonical_url: https://hubzoid.com/docs/deploy/gateway
last_updated: 2026-09-27
---

# Multi-hub gateway

Run several hubs behind one chat app, one sign-in and one Console with hubzoid gateway, including its flags, URL layout, data directory and bridges.

`hubzoid gateway` runs one shared chat app over many hubs. Each hub runs as a headless bridge, appears as its own agent in the chat app's model picker, and keeps its own tools, schedules and access rules. People sign in once, see only the agents they may use, and administrators manage every hub from one Console.

Each Hub keeps its own bridge behind the gateway's single front door.

## When to use this

Use a gateway when several teams share one machine and should share one sign-in: a hub per team (finance, operations, support) with per-team access rather than per-team URLs. It is lighter than one `hubzoid run` per hub because it starts a single chat app process. When hubs need separate login realms or separate URLs, run them as [separate hubs](https://hubzoid.com/docs/deploy/topologies#supported-shapes) instead.

## Quick start

```bash
export WEBUI_AUTH=true
export WEBUI_SECRET_KEY='<a random value from openssl rand -hex 32, kept stable>'
export HUBZOID_GATEWAY_ADMIN_EMAIL=operator@example.com
export HUBZOID_GATEWAY_ADMIN_PASSWORD='a-strong-password'

hubzoid gateway ./finance ./operations \
  --data-dir /srv/hubzoid/gateway-data \
  --public-url https://hub.example.com
```

The shared chat app reads its sign-in settings from the environment the `hubzoid gateway` process starts with. Each hub's `.env` configures that hub's bridge (its model, keys and ports) and stays with that hub. Deployments that still keep sign-in and chat app settings (`WEBUI_*`, OAuth, `DEFAULT_USER_ROLE`, `ENABLE_SIGNUP`, `HUBZOID_PUBLIC_URL`) in a hub's `.env` keep working: when the gateway's environment lacks one, the gateway takes it from the hub files and names it at start, using the last hub listed if hubs disagree. Move these to the gateway's environment. `WEBUI_NAME` in a hub's `.env` never overrides `--name`. Nothing else in a hub's `.env`, such as its model keys or restricted-tool secrets, reaches another hub, the chat app or the edge.

## Command and flags

```bash
hubzoid gateway HUB [HUB ...] [OPTIONS]
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `HUB` | `path` | Required | Hub directories to front. Each needs an AGENTS.md at its root. |
| `--port` | `number` | `3080` | Public port the edge listens on. Falls back to the PORT variable. |
| `--host` | `string` | `"127.0.0.1"` | Interface the edge binds to. Falls back to HUBZOID\_HOST. Use 0.0.0.0 to listen on every interface. |
| `--public-url` | `string` |  | Public base URL, for example https\://hub.example.com. Used to build each hub's download links and to detect HTTPS. Falls back to HUBZOID\_PUBLIC\_URL. |
| `--name` | `string` | `"Hubzoid"` | Display name of the shared chat app. |
| `--data-dir` | `path` | `"./.hubzoid-gateway"` | Shared state directory: the chat app database, the operational store and the deployment manifest. |
| `--launch-bridges / --no-bridges` | `boolean` | `--launch-bridges` | Start each hub's headless bridge, or front bridges that already run as their own services. |

## What happens at start

Check hubs → Write manifest → Start bridges → Start chat app → Provision → Start edge

The gateway refuses to start when a check fails, and writes no manifest in that case.

Before anything runs, the gateway checks that:

- every path has an `AGENTS.md`
- hub folder names are unique ignoring case, because a folder name is the hub's access domain
- every hub has a unique `BRIDGE_PORT`
- every hub surfaces as a unique model id, from `name:` in its `AGENTS.md` or `MODEL_LABEL` in its `.env`, so one team's chats can never route to another team's agent
- every hub with WhatsApp, Telegram or a generic webhook configured has a unique `HUBZOID_INBOUND_PORT`
- a deployment with more than one hub does not point `HUBZOID_DBOS_DB` at a SQLite file

It then writes the deployment manifest, starts one `hubzoid run <hub> --no-ui` bridge per hub on its `BRIDGE_PORT`, waits for each bridge's health check, starts the chat app on a loopback port, provisions each hub in the chat app when credentials are set, and finally starts the edge on the public port. The gateway supervises all of these. When the chat app exits, the gateway stops the rest.

## URL layout

Everything is served from one public origin through the edge:

| Path | Serves |
| --- | --- |
| `/` | The shared chat app, including its websockets |
| `/portal/` | The Console for the whole deployment |
| `/b/<slug>/artifacts/...` | Download links for files made by that hub's agent |
| `/b/<slug>/mcp` | That hub's [hosted MCP server](https://hubzoid.com/docs/guides/connect-an-assistant), only when the hub's own `.env` sets `MCP_SERVER=true` |
| `/webhooks/<slug>/<surface>` | That hub's inbound server, when its `.env` configures WhatsApp, Telegram or a generic webhook |

A hub's slug is its folder name in lower case with every run of other characters replaced by `-`. When two folders produce the same slug, the second gets `-2`, the third `-3`. If that happens to an inbound hub, pin its webhook namespace with `HUBZOID_HUB_SLUG` in its `.env` so the inbound server and the edge agree.

When a public URL is set, the gateway gives each bridge it launches `HUBZOID_PUBLIC_URL=<public-url>/b/<slug>`, so the bridge's download links route back to it through the edge. Leave `HUBZOID_PUBLIC_URL` out of each hub's `.env` in a gateway, because a value there wins over the injected one.

The bridges' `/v1`, `/uploads` and `/healthz` routes stay on loopback. The chat app reaches each bridge at `http://127.0.0.1:<BRIDGE_PORT>/v1` with that hub's first `BRIDGE_API_KEYS` entry.

## The data directory

`--data-dir` (default `./.hubzoid-gateway` in the working directory) holds the state the hubs share:

| File | Contents |
| --- | --- |
| `webui.db` | The chat app's accounts, sessions, chats and model visibility, with its `uploads/` and `openwebui.log` beside it |
| `hubzoid-operational.db` | Grants, identities, access changes, tool decisions, usage and workflow state for every hub |
| `deployment.json` | The deployment manifest: registered hubs, their model ids and database URLs, plus deployment facts such as the configured owner, sign-in flags and whether Open WebUI's user list is hidden, never a secret value (mode `0600`) |
| `branding/` | Optional shared login page and browser tab branding |

Each hub keeps its own workflow engine database in `<hub>/.hubzoid/dbos.db` and a pointer to the manifest in `<hub>/.hubzoid/deployment.json`. Operator commands such as `hubzoid access` and `hubzoid backup` follow that pointer, so they reach the same stores as the running gateway without repeating any database URL. Keep hub folders out of the data directory, because `hubzoid backup` refuses a data directory that contains a hub.

To use PostgreSQL, set `HUBZOID_OPERATIONAL_DB` and `HUBZOID_DBOS_DB` in the gateway's environment before it starts. [Deployment topologies](https://hubzoid.com/docs/deploy/topologies#sqlite-or-postgresql) explains why a gateway should use these two variables rather than `DATABASE_URL`. The manifest can then contain database credentials, so protect it like the `.env` files.

## Bridges the gateway launches

For each bridge it starts, the gateway sets:

| Variable | Value |
| --- | --- |
| `HUBZOID_GATEWAY` | `1`, which turns on scheduling of code workflows |
| `HUBZOID_OPERATIONAL_DB` | The shared operational store |
| `HUBZOID_OWUI_DB` | `<data-dir>/webui.db`, where the bridge looks up chat app groups, API keys for MCP and uploaded files |
| `HUBZOID_PUBLIC_URL` | `<public-url>/b/<slug>`, when a public URL is known |
| `MCP_SERVER`, `MCP_ACCESS_GROUP` | Read from the hub's own `.env` file only, so one hub's MCP settings never leak into another |
| `OWUI_NATIVE_MCP` | One value for the whole gateway, taken from the gateway's environment |

The chat app forwards the signed-in person's identity headers to every bridge (`ENABLE_FORWARD_USER_INFO_HEADERS` defaults to `true`). Access control depends on them: without them every request reaches the bridge anonymously and every restricted tool is denied.

## Bridges as their own services

With `--no-bridges`, the gateway starts only the chat app and the edge and fronts bridges you run yourself, for example as separate systemd units. Each external bridge must:

- run on the same machine, as `hubzoid run <hub> --no-ui`, because the chat app reaches bridges on `127.0.0.1`
- have `HUBZOID_GATEWAY=1` and `HUBZOID_OWUI_DB=<data-dir>/webui.db` in its environment
- have `HUBZOID_PUBLIC_URL=<public-url>/b/<slug>` in its environment, since the gateway injects it only into bridges it launches
- use the same hub paths the gateway was given, and no different operational database in its `.env`

The gateway writes its manifest and then waits for every bridge's health check, exiting if one never answers. Start the bridges once the manifest exists, so each one finds the shared operational store through its pointer file. A bridge whose `HUBZOID_OPERATIONAL_DB` disagrees with the manifest refuses to start rather than writing to a second store.

## Provisioning each hub

With `HUBZOID_GATEWAY_ADMIN_EMAIL` and `HUBZOID_GATEWAY_ADMIN_PASSWORD` set, the gateway signs in to the chat app as that administrator on every start and sets up each hub:

- **The model entry.** Picker name and description from `AGENTS.md`, quick-start `suggestions:` and an avatar from `<hub>/branding/logo.png` or another raster logo. These identity fields are refreshed on every start, including removals.
- **A team group.** Named after the hub's slug, with read access to that hub's model only. Access is set only when the model entry is first created, so later changes are never overwritten.

Provisioning requires `WEBUI_AUTH=true`. With sign-in off the gateway skips it and says so, because the chat app would otherwise create its default administrator account. On a fresh data directory the configured account is created as the first administrator. On an existing one the same credentials sign in, and a wrong password is reported instead of creating a stray account. If provisioning cannot run, the gateway logs a warning and starts normally. Leave both variables unset to skip it.

The same service account is used for account lookup and account actions in the Console (**Add user**, approval, password reset, role changes and **Delete user**) and for projecting managed grants into the chat app's model visibility, so it must be a chat app administrator. Being a chat app administrator does not make it a Hubzoid administrator. As the configured owner, it becomes the first organization administrator the first time it signs in: see [first administrator](https://hubzoid.com/docs/deploy/access-administration#the-first-administrator).

## Model visibility and the Console

The gateway keeps the chat app's model access control on, so each team sees only its own agents. It forces `BYPASS_MODEL_ACCESS_CONTROL=False` even when the variable is set in its environment, and warns when it does. Set `HUBZOID_GATEWAY_ALLOW_BYPASS=1` only if you deliberately want every signed-in person to see every agent.

The picker also shows each person only the agents they may enter, chat app administrators included: the edge asks a bridge for that person's entry decision before it returns the model list.

The Console at `/portal/` covers every registered hub, including hubs with no grants yet. Every bridge reads the shared operational store, so any bridge can serve it. The edge sends `/portal/` and the picker's access check to the first hub's bridge and tries the next one when a bridge refuses connections, so restarting one hub no longer empties the picker. This covers the Console and the picker check only. It is not high availability for the gateway or the chat app.

Administrators and people with Manage access see **Admin Console** above their profile in the chat sidebar. Once a hub's access moves to [managed grants](https://hubzoid.com/docs/deploy/access-administration#move-a-hub-to-managed-grants), the edge blocks model access edits for that hub in the chat app and points to the Console, while chat app groups keep working for hubs that have not moved.

A gateway set up fresh with Console accounts (no earlier `deployment.json` or `webui.db`, `WEBUI_AUTH=true` and the service account set) also hides Open WebUI's user list and refuses its account admin writes from the browser, so accounts are managed under **People** in the Console. Existing deployments keep the list. `HUBZOID_HIDE_OWUI_USERS=true` or `false` in the gateway's environment overrides either. See [hide the Open WebUI user list](https://hubzoid.com/docs/deploy/access-administration#hide-the-open-webui-user-list).

## Branding

The login page, favicon and tab title are shared by the whole gateway. The gateway takes them from the first match of:

1. `HUBZOID_GATEWAY_BRANDING`, set to a hub slug or to a directory that contains a `branding/` folder
2. `<data-dir>/branding/`, when it contains files
3. the first hub's `branding/` folder

Each hub's own logo appears as its agent avatar through provisioning. Unlike a single hub, the gateway keeps the chat app's **Workspace** menu visible, because administrators manage groups there.

## Next steps

- [Access administration](https://hubzoid.com/docs/deploy/access-administration): Bootstrap the first administrator and move hubs to managed grants.
- [Authentication](https://hubzoid.com/docs/deploy/authentication): Sign-in options for the shared chat app.
- [Backup and restore](https://hubzoid.com/docs/deploy/backup-and-restore): One archive covers the whole gateway.
- [Console overview](https://hubzoid.com/docs/console/overview): Usage, runs and access across every hub.
