---
title: Docker
description: Build or pull the Hubzoid image, run a hub with Docker Compose on SQLite or PostgreSQL, and keep its state on a mounted hub folder.
canonical_url: https://hubzoid.com/docs/deploy/docker
last_updated: 2026-09-27
---

# Docker

Build or pull the Hubzoid image, run a hub with Docker Compose on SQLite or PostgreSQL, and keep its state on a mounted hub folder.

The Hubzoid image runs `hubzoid run /hub` against a hub folder you mount into the container. It is a drop-in replacement for a native install: the same processes, the same `.env`, the same public port.

## When to use this

Use the image when `pip install hubzoid` is impractical on the host (PyAV build problems, the wrong Python version, missing system libraries), or when your platform runs containers. For the processes and data stores the container runs, see [deployment topologies](https://hubzoid.com/docs/deploy/topologies).

## The image

The [`Dockerfile`](https://github.com/hubzoid/hubzoid/blob/main/Dockerfile) at the repository root builds the image from the checked-out source with the reviewed dependency set in `requirements.lock`, so the image version is the version you checked out.

| Property | Value |
| --- | --- |
| Base | `python:3.12-slim-trixie` (Debian 13, SQLite 3.46, new enough for the workflow engine) |
| System packages | `ffmpeg`, plus the PyAV build dependencies |
| Python dependencies | `requirements.lock`, including the PostgreSQL driver and the CPU build of PyTorch |
| User | `hubzoid`, unprivileged |
| Working directory | `/hub` |
| Entrypoint and default command | `hubzoid`, then `run /hub` |
| Defaults | `PORT=3080`, `BRIDGE_PORT=8000`, `HUBZOID_HOST=0.0.0.0` |
| Published port | `3080`, the edge |

Only port 3080 is meant to be published. The edge on that port serves the chat app, download links, the Console at `/portal/` and the hosted MCP server. The bridge listens on `127.0.0.1` inside the container and is never reachable from outside it.

The Docker path was not re-verified end to end for the 1.0.1 release, so run `hubzoid doctor` inside a new container before you rely on it.

## Build and run

Check out the release you want and build it:

```bash
git checkout v1.0.1
docker build -t hubzoid:1.0.1 .
```

Run one hub:

```bash
docker run -d --restart unless-stopped \
  --name devops-agent \
  -p 3080:3080 \
  -v "$PWD/devops-agent:/hub" \
  --env-file "$PWD/devops-agent/.env" \
  hubzoid:1.0.1
```

Arguments after the image name replace the default command, so `hubzoid:1.0.1 run /hub --slack` also starts the Slack adapter. Put a reverse proxy with TLS in front of port 3080, exactly as on a [single server](https://hubzoid.com/docs/deploy/single-server#deploy-a-hub).

### Published images

The release pipeline in [`.github/workflows/ci.yml`](https://github.com/hubzoid/hubzoid/blob/main/.github/workflows/ci.yml) runs when a `vX.Y.Z` tag is pushed. It runs the full test suite, builds the Console and the wheel, builds the image for amd64 and arm64, and checks that the image starts and keeps the bridge unreachable. Only then does it push the image to the GitHub Container Registry, followed by PyPI and the GitHub release:

```bash
docker pull ghcr.io/hubzoid/hubzoid:1.0.1
```

Each release is tagged with its version and `latest`. Pin the version tag in production. Building from the tag yourself gives the same result.

## Docker Compose

The [`docker/`](https://github.com/hubzoid/hubzoid/tree/main/docker) folder holds three Compose files. The base file runs one hub on SQLite. The other two add to it.

| File | Adds |
| --- | --- |
| `docker/docker-compose.yml` | The `hubzoid` service: image `${HUBZOID_IMAGE:-hubzoid:local}` built from the checkout, port `${HUBZOID_PUBLISH:-3080}`, the hub folder `${HUB_PATH}` mounted at `/hub`, restart `unless-stopped` |
| `docker/docker-compose.postgres.yml` | A `postgres:17` service named `db` and `DATABASE_URL` for the hub |
| `docker/browser-compose.yml` | A shared, resource-limited browser for the hub's agents |

### SQLite

The hub's databases, chat data and runtime state live inside the mounted folder.

```bash
HUB_PATH=$PWD/my-hub docker compose -f docker/docker-compose.yml up -d
```

### PostgreSQL

Adds a database container whose port is not published. The hub's `DATABASE_URL` becomes `postgresql+psycopg://hubzoid:${POSTGRES_PASSWORD}@db:5432/hubzoid`, and the hub waits for the database health check before it starts. Use letters and digits for the password, because it is placed in a URL.

```bash
HUB_PATH=$PWD/my-hub POSTGRES_PASSWORD=<letters and digits> \
  docker compose -f docker/docker-compose.yml -f docker/docker-compose.postgres.yml up -d
```

Hubzoid's tables, the workflow engine's `dbos` schema and the chat app's tables all go into the one `hubzoid` database, stored in the `hubzoid-pg` volume. Uploads and hub runtime files stay in the mounted hub folder. This single `DATABASE_URL` layout suits one hub that manages access with grants and does not serve MCP. [Deployment topologies](https://hubzoid.com/docs/deploy/topologies#how-sharing-one-database-behaves) explains why.

### Shared browser

Turns on `HUBZOID_BROWSER` for the hub and runs two sidecars: a browserless browser pool with hard limits and a Playwright MCP server attached to it. Neither sidecar publishes a port.

```bash
HUB_PATH=$PWD/my-hub docker compose \
  -f docker/docker-compose.yml -f docker/browser-compose.yml up -d
```

Tune the pool with `HUBZOID_BROWSER_CONCURRENT` (default 2), `HUBZOID_BROWSER_QUEUED` (5), `HUBZOID_BROWSER_TIMEOUT_MS` (60000) and `HUBZOID_BROWSER_MEMORY` (`2g`). Set `HUBZOID_BROWSER_TOKEN` to your own value, since the pool token defaults to `hubzoid`.

## Persistence

Everything a hub writes stays in the folder mounted at `/hub`, so it survives container replacement and `hubzoid backup` covers it.

| Path in the hub folder | Contents |
| --- | --- |
| `.hubzoid/` | Hubzoid's SQLite databases, the workflow engine's `dbos.db`, the download-link signing key and access snapshots |
| `.openwebui-data/` | The chat app's `webui.db`, uploads and log |
| `.inbound/` | Inbound channel state, including stored webhook events |
| `logs/` | Hub runtime logs |
| `output/` | Files the agent made for people |

With the PostgreSQL file, the database itself lives in the named volume `hubzoid-pg`. Back it up with `pg_dump` as described in [backup and restore](https://hubzoid.com/docs/deploy/backup-and-restore#postgresql).

On Linux the container runs as an unprivileged user, so the mounted folder must be writable by it. For example, `chown -R 999:999 my-hub`, or run the container with `--user "$(id -u):$(id -g)"`.

## Configuration

The hub reads its `.env` from the mounted folder at start, the same way a native install does. Values in that file win over variables in the container environment, including ones set by `--env-file` or a Compose `environment:` block. To drive a setting from the container environment instead, leave it out of the hub's `.env`.

The settings a public deployment needs are the same as on a server: `MODEL` and its provider key, `BRIDGE_API_KEYS`, and the sign-in block with `WEBUI_AUTH=true`, `WEBUI_SECRET_KEY` and `WEBUI_URL`. See [single server](https://hubzoid.com/docs/deploy/single-server#deploy-a-hub) and [authentication](https://hubzoid.com/docs/deploy/authentication).

## Operator commands

Run operator commands inside the container against `/hub`:

```bash
docker compose -f docker/docker-compose.yml exec hubzoid hubzoid doctor /hub
docker compose -f docker/docker-compose.yml exec hubzoid hubzoid backup /hub --out /hub/backup.tar.gz
docker compose -f docker/docker-compose.yml exec hubzoid hubzoid schedule status /hub
```

For liveness probes, the public port answers Open WebUI's `/health` endpoint.

## Upgrading the container

Back up, stop the container, pull or build the new image, and start again. The new version upgrades Hubzoid's tables at its first start. Read [upgrading](https://hubzoid.com/docs/deploy/upgrading) before moving between minor versions.

## Container platforms

The image is the entry point for ECS, Kubernetes and other orchestrators. Two rules keep a hub's data safe there:

- **One task or pod per hub.** Give each hub its own service with a desired count of 1 and no horizontal autoscaling. Several processes writing one SQLite database corrupt it.
- **No SQLite on a network filesystem.** Keep `webui.db` and the `.hubzoid` databases on block storage such as an EBS volume. With a network filesystem such as EFS, mount it only for uploads, keep the databases on the task's own storage, and snapshot them on a schedule. Or move Hubzoid's stores to PostgreSQL.

The container listens on `PORT` (default 3080) and accepts `.env` values as container environment variables.

## What differs in a container

| Feature | In the image |
| --- | --- |
| `MODEL=claude-local` | Does not work, because the image has no `claude` CLI. Use a model provider API key such as OpenRouter, OpenAI or Anthropic. |
| `MODEL=codex-local` | Does not work in the base image, which neither installs nor logs in to the Codex CLI. To use it in containers, build your own image with Codex CLI 0.147.0 and a file-backed login for the container user. A provider API key is simpler. |
| Shared browser in direct mode | The image has no Node.js, so Hubzoid cannot start the browser itself. Use `docker/browser-compose.yml`, which runs the browser as sidecars. |
| Mounted hub folder | Must be writable by the container's unprivileged user. |

## Next steps

- [Deployment topologies](https://hubzoid.com/docs/deploy/topologies): Shapes, data stores, and SQLite versus PostgreSQL.
- [Authentication](https://hubzoid.com/docs/deploy/authentication): Turn on sign-in before exposing the container.
- [Backup and restore](https://hubzoid.com/docs/deploy/backup-and-restore): Archive the mounted hub folder and dump PostgreSQL.
- [Observability](https://hubzoid.com/docs/deploy/observability): Health checks, doctor output, usage and traces.
