Deploy and operate

Single server

Run a Hub on your own Linux server under systemd, with sign-in turned on and a reverse proxy in front for TLS, step by step with the expected output.

At the end of this guide your Hub runs on a Linux server under systemd, people sign in at an HTTPS address, and you are its first administrator with two-step sign-in set up. The steps use Ubuntu 24.04. Debian and RHEL-family systems work the same way with their own package manager.

hubzoid run is the production entry point, the same command you use on a laptop. On a server you wrap it in systemd, keep every Hubzoid port on loopback, turn sign-in on, and let a reverse proxy you already trust terminate TLS.

What you need

  • A Linux server with at least 2 GB of memory for one Hub, or 4 GB for two or three. There is no certified maximum, so size it for your workload.
  • A DNS name for the Hub, such as kestrel.example.com, pointing at the server.
  • Your Hub in a git repository, with a requirements.txt that pins the release you tested, for example hubzoid==<version>.
  • A model provider key, or a Claude subscription token (see running on a Claude subscription).

Deploy a hub

Prepare the server

Install Python 3.12 and git, then create a service user:

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

In the firewall or security group, allow only 22/tcp from your administration address, and 80/tcp and 443/tcp from anywhere. The bridge binds to 127.0.0.1, so it stays unreachable from outside even with a permissive firewall.

Install Hubzoid and your Hub

As the service user, clone the repository and install the pinned release into a virtual environment. Use hubzoid[postgres]==<version> in requirements.txt if you plan to use PostgreSQL.

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

Check the install:

Terminal
hubzoid version

Configure the Hub's .env

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

Terminal
cd /opt/hubzoid/agents/my-hub
touch .env && chmod 600 .env

A public deployment needs at least these values:

my-hub/.env
MODEL=anthropic/claude-haiku-4-5
ANTHROPIC_API_KEY=<your key>
BRIDGE_API_KEYS=<output of openssl rand -hex 32>

HUBZOID_AUTH=true
HUBZOID_PUBLIC_URL=https://kestrel.example.com
HUBZOID_ADMIN_EMAIL=sam@example.com
HUBZOID_ADMIN_PASSWORD=<at least 8 characters>
Why each setting matters
SettingWhy it matters
MODEL and its keyThe model the agent uses. See agents and models.
BRIDGE_API_KEYSThe key Hubzoid's own processes present to the bridge. Without it the bridge accepts the public key dev, and hubzoid doctor fails auth.bridge_keys.
HUBZOID_AUTHTurns sign-in on. Without it, hubzoid run keeps the web app on loopback and treats every visitor as the owner.
HUBZOID_PUBLIC_URLThe address people open. Sign-in redirects, one-time links and download links are built from it, and an https address turns the hosted MCP server on.
HUBZOID_ADMIN_EMAIL, HUBZOID_ADMIN_PASSWORDCreate the first administrator at the first start, when no account exists yet. Remove both lines after that start.

PORT (default 3080) is the public port. Set it only when another program uses 3080. A value in the Hub's .env wins over the same variable in the process environment.

Create the systemd unit

hubzoid service install writes one unit that runs hubzoid run for the whole deployment. Run it as root from the Hub folder:

Terminal
cd /opt/hubzoid/agents/my-hub
sudo /opt/hubzoid/agents/.venv/bin/hubzoid service install --port 3080 --user hubzoid --write

It answers Wrote /etc/systemd/system/hubzoid.service. Open that file and add the lines after User=hubzoid, which keep MODEL=claude-local working and harden the service:

/etc/systemd/system/hubzoid.service
[Unit]
Description=Hubzoid
After=network-online.target
Wants=network-online.target

[Service]
WorkingDirectory=/opt/hubzoid/agents/my-hub
ExecStart=/opt/hubzoid/agents/.venv/bin/hubzoid run --port 3080 --host 127.0.0.1 --no-open
Restart=always
RestartSec=5
KillMode=mixed
TimeoutStopSec=180
User=hubzoid
# claude-local calls the `claude` CLI, which the default systemd PATH misses.
Environment=PATH=/opt/hubzoid/.local/bin:/usr/local/bin:/usr/bin:/bin
OOMPolicy=stop
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
What the restart and stop settings do

hubzoid run restarts a process of the Hub that exits, and Restart=always has systemd restart hubzoid run itself. KillMode=mixed lets hubzoid run stop its own processes, and TimeoutStopSec=180 gives running replies their two-minute grace period on a stop.

Start the Hub

Terminal
sudo systemctl daemon-reload
sudo systemctl enable --now hubzoid
journalctl -u hubzoid -f

When the Hub is up, the log prints one ready line that says sign-in is on:

Output
✓ Hubzoid is ready: https://kestrel.example.com  (sign-in on)

hubzoid status in the Hub folder shows the same: each Hub ready, restarting, failed or stopped.

Put a reverse proxy in front

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

  • terminate TLS on 443 and redirect 80 to 443
  • forward to 127.0.0.1:3080
  • pass Server-Sent Events through without buffering, because replies stream as they are written
  • carry WebSocket upgrades
  • set X-Forwarded-For, because per-address sign-in limits rely on it

With Caddy, which obtains and renews the certificate for you:

/etc/caddy/Caddyfile
kestrel.example.com {
    reverse_proxy 127.0.0.1:3080 {
        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.

A proxy or CDN on public addresses

A proxy on the same machine, or on a private network, is believed for X-Forwarded-For. A proxy or CDN on public addresses, such as Cloudflare without a tunnel, must be listed in HUBZOID_TRUSTED_PROXIES. Otherwise everyone behind it counts as one address, and ten failed sign-ins lock sign-in for all of them for 15 minutes.

Sign in and set up two-step sign-in

Open https://kestrel.example.com and sign in with the administrator email and password from .env. Administrators must use two-step sign-in by default, so Hubzoid takes you straight to Set up two-step sign-in: scan the QR code with an authenticator app, enter the first code, then save the ten one-time recovery codes somewhere safe.

Then remove HUBZOID_ADMIN_EMAIL and HUBZOID_ADMIN_PASSWORD from .env. Add your team in Settings → People, as described in access administration.

Check the deployment

Terminal
/opt/hubzoid/agents/.venv/bin/hubzoid doctor /opt/hubzoid/agents/my-hub

Each check starts with ✓ (ok), · (worth knowing), ! (needs attention) or ✗ (failed). Fix every ✗, and the command exits with status 1 until you do. The doctor reference explains each check.

Several Hubs on one server

To give several teams one sign-in and one Settings page, list the Hubs in a hubzoid.yaml in the folder above them and run that folder with the same single unit. See several Hubs.

To keep Hubs fully separate, with their own accounts and URLs, run one hubzoid run per Hub. Give each Hub its own PORT and its own unit name. hubzoid service install prints the unit, followed by two lines of advice, when you leave out --write. Save only the unit:

Terminal
cd /opt/hubzoid/agents/sales-desk
/opt/hubzoid/agents/.venv/bin/hubzoid service install --port 3081 --user hubzoid \
  | sed '/^Save it as/,$d' | sudo tee /etc/systemd/system/hubzoid-sales-desk.service
sudo systemctl daemon-reload
sudo systemctl enable --now hubzoid-sales-desk

Bridge ports are picked for you, skipping any port already in use. Add a second site block to the proxy for port 3081.

Running on a Claude subscription

MODEL=claude-local runs the agent through the claude CLI on a Claude Pro or Max subscription instead of a metered key. On a server:

  1. On any machine signed in to the subscription, run claude setup-token. It prints a long-lived token.
  2. Put it in the Hub's .env as CLAUDE_CODE_OAUTH_TOKEN, next to MODEL=claude-local.
  3. 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.

Heavy automated use shares the subscription's limits with interactive use.

Claude credential failover

You can add up to two backup tokens beside the primary one:

my-hub/.env
CLAUDE_CODE_OAUTH_TOKEN=<primary credential>
CLAUDE_CODE_OAUTH_TOKEN_SECONDARY=<first backup>
CLAUDE_CODE_OAUTH_TOKEN_TERTIARY=<second backup>

Hubzoid moves to the next token only when Claude reports a usage limit or rejects the login. The model, the Hub, the caller and their tools stay the same.

Failover limits and authorization
  • It needs an explicit primary and Claude OAuth tokens only. Hubzoid refuses to combine it with an API key, another auth token or base URL, or another cloud provider's settings.
  • A request moves to a backup only before any content is written or any tool runs. After that, the turn ends with the usual error and the next request uses the backup. Interrupted actions are never replayed.
  • A limited token rests until its reported reset, or five minutes without one. A rejected login stays disabled until you replace that token and restart.
  • Hubs that share an operational store share these cooldowns. Separate standalone Hubs cannot coordinate them.
  • Different tokens can share one quota, and failover does not help during a Claude-wide outage. There are no alerts and no Settings screen for it. Logs name the slot (0 for the primary, 1 and 2 for the backups) without printing a token.

Every backup must be authorized for this deployment. Anthropic's Agent SDK guidance requires prior approval for third-party products that offer claude.ai sign-in or rate limits, and this feature is not a claim of approved shared subscription hosting. Remove both backup lines and restart to turn failover off. The SDK deployment guide (opens in a new tab) has the full rules.

Slack and inbound channels

A channel turns on with a file in the Hub's channels/ folder, never a flag. It starts and restarts with its Hub, so it needs no unit of its own. The Slack adapter uses Socket Mode and needs no public route:

my-hub/channels/slack-assistant.yaml
version: 1
id: slack-assistant
provider: slack_assistant
enabled: true

SLACK_BOT_TOKEN and SLACK_APP_TOKEN stay in the Hub's .env. WhatsApp, Telegram and generic webhooks use provider: whatsapp_inbound, telegram or webhook, arrive on the public port at /webhooks/<hub>/..., and are served by the inbound server. A channel whose settings are missing stays off, and the start names what is missing. Follow its output with hubzoid logs my-hub -f. See Slack and WhatsApp and Telegram.

Scheduled work

Markdown tasks in schedule/ run whenever their files exist. Code workflows in workflows/ start on their own on a standalone Hub only when its environment sets HUBZOID_SCHEDULES=1. A deployment that hubzoid run starts from hubzoid.yaml or with several Hubs turns this on for every Hub. Named webhooks that start workflows are received either way. See operating runs.

Files the agent makes are served at /artifacts/... through the edge, on links built from HUBZOID_PUBLIC_URL. Each link is signed with a per-Hub secret in .hubzoid/artifact_secret and expires after 7 days. HUBZOID_ARTIFACT_LINK_TTL sets the lifetime in seconds, and 0 means never. The signed-in owner of a conversation can always download its files.

Hubzoid removes one-time sign-in link tokens and OAuth codes from its own access logs. Your proxy logs them unless you redact the /api/auth/link/ path and the token, code and state query parameters, so treat its logs as sensitive.

If something goes wrong

What you seeWhat to do
Anything unexpectedRun hubzoid doctor <hub> first.
Sign-in is off, so listening on 0.0.0.0 (--host or HUBZOID_HOST) would let anyone who can reach this port use the hub as its owner.Set HUBZOID_AUTH=true and the first administrator, or keep --host 127.0.0.1.
Nobody can sign in after turning sign-in onNo account exists yet. Run hubzoid admin create <email> <hub> --owner, or set HUBZOID_ADMIN_EMAIL and HUBZOID_ADMIN_PASSWORD for one start.
The web app could not open port 3080Another program uses the port. Set PORT in .env or --port in the unit.
The unit starts but the site is unreachableRun hubzoid status, then watch journalctl -u hubzoid -f for the ready line, then check the proxy's logs.
More problems and fixes
What you seeWhat to do
Scheduled tasks never run and the log says the workflow engine did not startdeps.sqlite in doctor. Use a Python build with SQLite 3.42 or newer, or PostgreSQL.
One agent answers "This agent is restarting"hubzoid status shows it restarting or failed, with the reason. Fix it, then hubzoid start <hub>.
The certificate never issuesDNS has not propagated, or the server cannot reach the certificate issuer. Check the proxy's logs.

Next steps

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.