---
title: Single server
description: Run one or more hubs on a Linux server with systemd, put a reverse proxy in front for TLS, and set the values a public deployment needs.
canonical_url: https://hubzoid.com/docs/deploy/single-server
last_updated: 2026-09-27
---

# Single server

Run one or more hubs on a Linux server with systemd, put a reverse proxy in front for TLS, and set the values a public deployment needs.

`hubzoid run <hub>` is the production entry point for a hub. On a server you wrap it in systemd, keep every Hubzoid port on loopback, and let a reverse proxy you already trust terminate TLS on port 443. The walkthrough below uses Ubuntu 24.04. The same steps work on Debian and RHEL-family systems with the package manager swapped.

## When to use this

Use this setup for one to a few hubs on a single Linux machine, each with its own chat app and URL. It is the simplest and cheapest production shape. If several teams should share one sign-in and one Console, run a [multi-hub gateway](https://hubzoid.com/docs/deploy/gateway) instead. If `pip install` is impractical on the host, use [Docker](https://hubzoid.com/docs/deploy/docker).

As a starting point, plan for 2 GB of memory for one hub and 4 GB for two or three hubs on the same machine.

## What runs

`hubzoid run` starts and supervises these processes. When the bridge exits, the command stops the others.

| Process | Bind | Started by |
| --- | --- | --- |
| Edge | `--host` (default `127.0.0.1`, or `HUBZOID_HOST`) on `PORT` (default `3080`) | Always |
| Chat app (Open WebUI) | `127.0.0.1` on `PORT + 40000`, or `HUBZOID_OWUI_PORT` | Always, unless `--no-ui` |
| Bridge | `127.0.0.1` on `BRIDGE_PORT` (default `8000`) | Always |
| Slack adapter | Outbound Socket Mode connection, no port | `--slack` |
| Inbound server | `127.0.0.1` on `HUBZOID_INBOUND_PORT` (default `8100`) | `--whatsapp`, `--telegram` or `--webhook` |

The edge is the only process your proxy talks to. It serves these public paths:

| Path | Goes to |
| --- | --- |
| `/` and everything not listed below | The chat app, including its websockets |
| `/portal/` | The Console, served by the bridge |
| `/artifacts/...` | Signed download links for files the agent made |
| `/mcp` | The hosted MCP server, when the hub sets `MCP_SERVER=true` |
| `/webhooks/<hub>/<surface>` | The inbound server, when an inbound surface is running |

The bridge's `/v1`, `/uploads` and `/healthz` routes are never forwarded. The edge also refuses `.` and `..` path segments and drops client-sent `X-Hubzoid-*` and `X-OpenWebUI-*` headers.

## Deploy a hub

1. **Prepare the server**

   Install Python 3.12 and the build dependencies, then create a service user:

   ```bash
   sudo apt update && sudo apt install -y \
     python3.12 python3.12-venv pkg-config ffmpeg build-essential git curl
   sudo useradd -r -m -d /opt/hubzoid -s /bin/bash hubzoid
   ```

   `pkg-config` and `ffmpeg` are the PyAV dependencies that most often fail on a fresh machine.

2. **Open only the ports you need**

   In the firewall or security group, allow inbound traffic on:

   - 22/tcp from your administration address, for SSH
   - 80/tcp from anywhere, for certificate issuance and the redirect to HTTPS
   - 443/tcp from anywhere

   Block everything else. The bridge binds to `127.0.0.1`, so it is unreachable from outside the machine even with a permissive firewall.

3. **Install Hubzoid and your hubs**

   Keep your hubs in a git repository with one folder per hub and a `requirements.txt` that pins the release, for example `hubzoid==1.0.1` (or `hubzoid[postgres]==1.0.1` for [PostgreSQL](https://hubzoid.com/docs/deploy/topologies#sqlite-or-postgresql)).

   ```bash
   sudo -iu hubzoid
   git clone git@github.com:your-org/your-hub-agents.git agents
   cd agents
   python3.12 -m venv .venv
   source .venv/bin/activate
   pip install -r requirements.txt
   ```

4. **Configure each hub's `.env`**

   A hub's `.env` holds its secrets, so it is listed in the hub's `.gitignore` and never arrives with `git clone`. Create it on the server and make it readable by the service user only:

   ```bash
   cd /opt/hubzoid/agents/devops-agent
   touch .env && chmod 600 .env
   ```

   A public deployment needs at least these values:

   ```bash title="devops-agent/.env"
   MODEL=anthropic/claude-haiku-4-5
   ANTHROPIC_API_KEY=<your key>
   BRIDGE_API_KEYS=<openssl rand -hex 32>

   WEBUI_AUTH=true
   WEBUI_SECRET_KEY=<openssl rand -hex 32>
   WEBUI_URL=https://devops.agents.example.com

   PORT=3080
   BRIDGE_PORT=8000
   ```

   | Setting | Why it matters |
   | --- | --- |
   | `MODEL` and its provider key | The model the agents use. See [agents and models](https://hubzoid.com/docs/concepts/agents-and-models). |
   | `BRIDGE_API_KEYS` | The key the chat app and adapters present to the bridge. Unset, the bridge accepts the public key `dev`, and `hubzoid doctor` fails `auth.bridge_keys`. |
   | `WEBUI_AUTH` and `WEBUI_SECRET_KEY` | Turn on sign-in. Hubzoid refuses to start with sign-in on and no secret. Add the rest of your sign-in block from [Authentication](https://hubzoid.com/docs/deploy/authentication). |
   | `WEBUI_URL` | The public address. OAuth callbacks are built from it, and download links fall back to it. |
   | `PORT`, `BRIDGE_PORT` | Must be unique for each hub on the machine. |

   Point a DNS record for the hostname at the server before you start the proxy, so the proxy can obtain a certificate.

   A value in the hub's `.env` wins over the same variable in the process environment. If you prefer to manage settings through systemd `Environment=` lines or an orchestrator, leave the value out of `.env`.

5. **Create the systemd unit**

   One template unit runs any number of hubs. Save it as `/etc/systemd/system/hubzoid@.service`:

   ```ini title="/etc/systemd/system/hubzoid@.service"
   [Unit]
   Description=Hubzoid agent %i
   After=network-online.target
   Wants=network-online.target

   [Service]
   Type=simple
   User=hubzoid
   Group=hubzoid
   WorkingDirectory=/opt/hubzoid/agents
   ExecStart=/opt/hubzoid/agents/.venv/bin/hubzoid run %i
   # claude-local calls the `claude` CLI, which the default systemd PATH misses.
   Environment=PATH=/opt/hubzoid/.local/bin:/usr/local/bin:/usr/bin:/bin
   Restart=always
   RestartSec=10s
   TimeoutStopSec=30
   StandardOutput=journal
   StandardError=journal

   NoNewPrivileges=true
   PrivateTmp=true
   ProtectSystem=strict
   # Hubs keep state under agents/, and claude-local writes under ~/.claude.
   ReadWritePaths=/opt/hubzoid
   # ProtectHome stays off: hiding ~/.claude breaks MODEL=claude-local.
   ProtectKernelTunables=true
   ProtectKernelModules=true
   RestrictSUIDSGID=true
   LockPersonality=true

   [Install]
   WantedBy=multi-user.target
   ```

   `%i` is the instance name, which is the hub folder. Start the hub and follow its log:

   ```bash
   sudo systemctl daemon-reload
   sudo systemctl enable --now hubzoid@devops-agent
   journalctl -u hubzoid@devops-agent -f
   ```

6. **Put a reverse proxy in front**

   Use the proxy you already operate. Whatever you choose, it must:

   - terminate TLS on 443 and redirect 80 to 443
   - forward to `127.0.0.1:<PORT>` for each hub
   - pass Server-Sent Events through without buffering, because replies stream token by token
   - carry WebSocket upgrades

   A Caddy configuration for two hubs:

   ```text title="/etc/caddy/Caddyfile"
   devops.agents.example.com {
       reverse_proxy 127.0.0.1:3080 {
           flush_interval -1
           transport http {
               read_timeout 600s
           }
       }
   }

   sales.agents.example.com {
       reverse_proxy 127.0.0.1:3081 {
           flush_interval -1
           transport http {
               read_timeout 600s
           }
       }
   }
   ```

   `flush_interval -1` turns off response buffering and `read_timeout 600s` covers long replies. With nginx, the equivalent is `proxy_buffering off;` plus passing the `Upgrade` header. Managed load balancers and tunnels work too, as long as they meet the list above.

   The edge keeps the incoming `Host` header. When `WEBUI_URL` or `HUBZOID_PUBLIC_URL` starts with `https://` and the proxy sends no `X-Forwarded-Proto`, the edge adds `X-Forwarded-Proto: https`. OAuth redirects are then built from the public address.

7. **Check the deployment**

   ```bash
   /opt/hubzoid/agents/.venv/bin/hubzoid doctor /opt/hubzoid/agents/devops-agent
   ```

   Fix every `fail`. Then open `https://devops.agents.example.com`, sign in, and open `/portal/` to reach the Console. The [doctor reference](https://hubzoid.com/docs/reference/doctor) lists every check.

## Public URLs for download links

Files the agent makes are served at `/artifacts/...` through the edge. The link's base address comes from `HUBZOID_PUBLIC_URL`, then `WEBUI_URL`, then `http://127.0.0.1:<BRIDGE_PORT>`. Behind a proxy, `WEBUI_URL` is usually enough. Set `HUBZOID_PUBLIC_URL` only when downloads must use a different public address.

Links are signed with a per-hub secret in `.hubzoid/artifact_secret`. They never expire unless you set `HUBZOID_ARTIFACT_LINK_TTL` in seconds.

## Several hubs on one server

Repeat the `.env` step for each hub with its own `PORT` and `BRIDGE_PORT`, then enable another instance of the same unit:

```bash
sudo systemctl enable --now hubzoid@sales-agent
```

Each hub has its own chat app, user list and URL. To give several teams one sign-in and one Console instead, use a [gateway](https://hubzoid.com/docs/deploy/gateway).

## Scheduled work

Markdown tasks in `schedule/*.md` run whenever their files exist (`HUBZOID_DISABLE_SCHEDULE=1` turns them off). Code workflows in `workflows/` are scheduled on a standalone hub only when its environment sets `HUBZOID_SCHEDULES=1`. A gateway turns this on for every hub. See [operating runs](https://hubzoid.com/docs/guides/operating-runs).

## Running on a Claude subscription

`MODEL=claude-local` runs inference through the `claude` CLI on a Claude Pro or Max subscription. On a server, mint a long-lived token on any machine signed in to the subscription with `claude setup-token`, and put it in the hub's `.env` as `CLAUDE_CODE_OAUTH_TOKEN`. Install the `claude` CLI as the `hubzoid` user so it is on the unit's `PATH`. The unit above already keeps the service user's home writable for the CLI's session files.

## Slack and inbound channels

The Slack adapter uses Socket Mode, so it needs no public route. Run it as its own unit so a Slack problem never restarts the chat app:

```bash
hubzoid slack systemd /opt/hubzoid/agents/devops-agent \
  --python /opt/hubzoid/agents/.venv/bin/python \
  --user hubzoid \
  | sudo tee /etc/systemd/system/hubzoid-slack@devops-agent.service
```

`hubzoid inbound systemd <hub>` prints the equivalent unit for WhatsApp, Telegram and webhook surfaces. See [Slack](https://hubzoid.com/docs/chat/slack) and [WhatsApp and Telegram](https://hubzoid.com/docs/chat/whatsapp-and-telegram).

## Troubleshooting

| Symptom | Check |
| --- | --- |
| Anything | Run `hubzoid doctor <hub>` first. |
| Boot fails with `WEBUI_AUTH=true requires WEBUI_SECRET_KEY` | Set `WEBUI_SECRET_KEY` in `.env`. |
| Boot fails with `OAuth client IDs are set but WEBUI_URL is not` | Set `WEBUI_URL=https://your.host` in `.env`. |
| Scheduled tasks never run and the log says the workflow engine did not start | `deps.sqlite` in doctor. Use a Python build with SQLite 3.42 or newer, or PostgreSQL. |
| The unit starts but the site is unreachable | Watch `journalctl -u hubzoid@<hub> -f` for the chat app's ready line, then check the proxy's logs. |
| Two hubs conflict at start | Give each hub a unique `PORT` and `BRIDGE_PORT`. |
| The certificate never issues | DNS has not propagated, or the server cannot reach the certificate issuer. Check the proxy's logs. |

## Next steps

- [Authentication](https://hubzoid.com/docs/deploy/authentication): Choose a sign-in method for the chat app and Console.
- [Backup and restore](https://hubzoid.com/docs/deploy/backup-and-restore): Schedule daily archives and restore them.
- [Security model](https://hubzoid.com/docs/deploy/security-model): What is exposed, what is checked and what you own.
- [Upgrading](https://hubzoid.com/docs/deploy/upgrading): Move to a new release with a backup and a way back.
