Hub Deployment

The hub is the central component of Otavi: an API gateway, MQTT ingester, job queue manager, and web UI host. It runs as an 8+ container Docker Compose stack behind a Traefik reverse proxy (9 with split mode).

What the Hub Does

  • API gateway (Fastify) — serves the REST API for web and mobile clients, handles JWT and API key auth
  • MQTT ingester — receives audio chunks and telemetry from satellites via Mosquitto
  • Job queue — dispatches BirdNET inference jobs to workers via Redis + BullMQ
  • Web UI — serves the React SPA via nginx, proxying /api/* to the hub process
  • Database — PostgreSQL 17 with multi-tenant row-level isolation
  • Storage — MinIO for S3-compatible audio and image blob storage

Docker Compose Deployment

Docker Compose is the primary and recommended deployment method.

Containers

Container Image Purpose
postgres PostgreSQL 17 Multi-tenant data store
redis Redis 7 BullMQ job queue backend
mosquitto Mosquitto 2.x MQTT broker (dynamic security plugin)
minio MinIO S3-compatible audio + image storage
api Node.js Hub process (API gateway)
dispatcher Node.js Background workers (split mode only: MQTT ingester, monitors, webhooks, image worker)
web nginx React SPA, proxies /api/* to hub
docs Node.js Documentation site (Express + markdown-it)
worker Python 3.11 BirdNET inference (scalable)

Networks

  • traefik (external) — Traefik routes HTTPS + WSS traffic
  • backend (internal) — Inter-container communication

Storage

All persistent data uses bind mounts under OTAVI_VOLUMES_ROOT (default: ./storage/):

storage/
├── postgres/    # Database files
├── redis/       # Redis AOF
├── mosquitto/   # Broker data + dynamic security DB
└── minio/       # Audio blobs + species images

Before the first docker compose up, create the folders written by the services that run as PUID:PGID (Redis, Mosquitto, MinIO, the inference workers, the release keystore), owned by that user. Docker creates a missing bind folder as root, and those services then cannot write it. For the inference workers that fails silently: they cannot cache the registry's classifier or the Perch embedding model, so every job falls back to the bundled model and skips embeddings. pnpm ops:check flags it. Leave storage/postgres to Postgres, which sets its own ownership.

mkdir -p storage/{redis,mosquitto/data,mosquitto/log,minio,worker-models,mobile}
sudo chown "$(grep ^PUID= .env | cut -d= -f2):$(grep ^PGID= .env | cut -d= -f2)" \
  storage/{redis,mosquitto,mosquitto/data,mosquitto/log,minio,worker-models,mobile}

Commands

# Start full stack (default mode — single hub process)
docker compose up -d

# Rebuild after code changes
docker compose up -d --build

# View logs
docker compose logs -f api

# Stop everything
docker compose down

Hub Modes (OTAVI_HUB_MODE)

The hub can run in three modes, controlled by the OTAVI_HUB_MODE environment variable:

Mode Description
full (default) Single process running the API server and all background workers. No Redis pub/sub bridge needed. This is the original behavior.
api Stateless Fastify HTTP + WebSocket server only. Can be scaled horizontally with replicas. Uses a lightweight MqttConfigPusher to push config to satellites via MQTT.
dispatcher Background worker only: MQTT ingester, satellite monitor, detection watcher, webhook dispatcher, species image worker. Must run as a single instance.

In full mode, everything runs in one process exactly as before. In split mode, api and dispatcher run as separate containers and communicate via Redis pub/sub (e.g., detection alerts are published by the dispatcher and delivered to WebSocket clients by the API).

Default Mode (unchanged)

Don't set OTAVI_HUB_MODE (or set it to full). The standard docker compose up -d starts a single hub process that handles everything:

docker compose up -d

Split Mode Deployment

Split mode separates the stateless API from the background workers, allowing you to scale the HTTP/WebSocket layer independently.

  1. Set OTAVI_HUB_MODE=api in your .env file.
  2. Start the stack with the split profile:
docker compose --profile split up -d

This starts two hub containers:

  • api (OTAVI_HUB_MODE=api) — handles HTTP requests and WebSocket connections
  • dispatcher (OTAVI_HUB_MODE=dispatcher) — runs MQTT ingestion and all background workers
  1. Scale the API layer:
docker compose --profile split up -d --scale api=3

The dispatcher must remain a single instance (it owns MQTT subscriptions, satellite monitoring, and the species image download queue). The API containers are stateless and safe to scale behind Traefik.

Note: Each API instance maintains its own MQTT connections (for credential provisioning and config push). These connections auto-reconnect and will wait up to 10 seconds for reconnection if temporarily lost. No sticky sessions or shared state is required — all API instances are fully independent.

Configuration

All runtime configuration is via .env (see .env.example for the full template). No hardcoded secrets.

Public names

Three public names are required, each configured in .env, plus the Traefik resolver that issues their certificates:

Variable Purpose
OTAVI_APP_FQDN Web UI + API (HTTPS, nginx -> hub)
OTAVI_MQTT_FQDN Mosquitto broker (WSS via HTTPS :443)
OTAVI_DOCS_FQDN Documentation site (HTTPS)
OTAVI_TLS_CERTRESOLVER Certificate resolver for the three names, as named in Traefik's static configuration (required; the entrypoint's default may serve another domain)

Key Environment Variables

Variable Description
OTAVI_AUTH_JWT_SECRET Secret for signing JWT session tokens (required)
OTAVI_AUTH_PLATFORM_ADMIN_EMAILS Comma-separated email addresses that get hub admin access
OTAVI_HUB_INTERNAL_API_KEY Backwards-compatible machine access key (optional)

Container logs

Every service writes its log through the x-logging anchor in docker-compose.yml: Docker's json-file driver, at most 5 files of 10 MB per container, the oldest dropped first. The web server's access log and the hub's request log hold visitors' IP addresses, so this also bounds how long those stay on the server. Give any service you add the same logging: *logging line.

Privacy page

Every hub serves a privacy notice at /privacy, linked from the sign-in pages, the public pages, the user menu, the Account page and the phone's settings. Its text is the same on every hub; what varies is filled in from the hub itself: how long recordings stay (Hub settings, Storage), whether backups run and how many are kept (OTAVI_BACKUP_ENABLED, OTAVI_BACKUP_KEEP_DAILY, OTAVI_BACKUP_KEEP_WEEKLY, read by the hub as well as by the backup sidecar), and which outside services it uses (eBird with OTAVI_EBIRD_API_KEY, iNaturalist, email). Once the hub runs, fill in Hub settings, Access, Privacy page: who runs the hub and how to reach them about personal data (an email address or an https:// link). Until then the page asks readers to contact the person who invited them.

Traefik Configuration

Three routes are needed, all on the HTTPS entrypoint:

Entrypoint Port Protocol Service
websecure 443 HTTPS Web UI + API (OTAVI_APP_FQDN)
websecure 443 HTTPS (WSS) MQTT broker via WebSocket (OTAVI_MQTT_FQDN)
websecure 443 HTTPS Documentation site (OTAVI_DOCS_FQDN)

The docker-compose.yml includes Traefik labels for automatic routing. No services expose ports directly to the host; everything goes through Traefik.

MinIO has no route: the hub and the backup reach its S3 API on the backend network, every object people see is streamed through the hub, and its console is off (since hub 0.78.2; before, it was served at OTAVI_S3_FQDN). To look inside the buckets:

docker compose exec minio sh -c 'mc -C /tmp/mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc -C /tmp/mc ls local/birdnet-audio/'

Moving to new public names

People and satellites do not all move at once: phones and Pis keep the addresses they registered with, and links in emails, chats and share pages keep the old names. docker-compose.previous-names.yml keeps the previous names working during the move:

  • the previous app name still serves /api/ (satellites call it with their key, which a redirect to another host would drop) and redirects everything else, path kept, to the new name (301);
  • the previous broker name still serves MQTT over WSS (a broker connection cannot follow a redirect);
  • the previous docs name redirects to the new one.

The previous names keep the entrypoint's default certificate resolver. To start a move, set the new names and OTAVI_TLS_CERTRESOLVER, add the previous names and the file to .env, then recreate the services:

COMPOSE_FILE=docker-compose.yml:docker-compose.previous-names.yml
OTAVI_PREVIOUS_APP_FQDN=old.example.com
OTAVI_PREVIOUS_MQTT_FQDN=mqtt.old.example.com
OTAVI_PREVIOUS_DOCS_FQDN=docs.old.example.com

Certificates for the new names through a DNS challenge, two pitfalls met on the first move (2026-09-27):

  • a wildcard record in the new zone (*.example.com CNAME ...) also answers the _acme-challenge names, and Traefik's ACME client follows it into the CNAME's target zone, where it cannot write; give each name its own record instead;
  • when something on the local network answers all port-53 traffic itself (a router redirecting DNS to its own cache), Traefik's check of the zone's nameservers never passes (returned REFUSED, then time limit exceeded), whatever resolvers say. Let Let's Encrypt check instead: propagation: { disableChecks: true, delayBeforeChecks: 120 } on the resolver, or exempt the server from the redirection. Let's Encrypt allows 5 failed validations per name and hour, so fix the cause before retrying.

Everyone signs in again on the new name (sessions and remembered devices are per name) and passkeys must be registered again (a passkey belongs to the name it was created on; the authenticator-app code still works). A phone moves when it is unregistered and signed in again with the new address: it keeps its device id, so it continues as the same satellite. A Pi moves when OTAVI_SAT_MQTT_BROKER_URL in its .env names the new broker. pnpm ops:check checks the previous names while the file is active. Once Traefik's access log shows no satellite on the previous names, remove the four lines and recreate the web, mosquitto and docs services; keep a redirect elsewhere if old links matter.

Database Migrations

Migrations run automatically on hub startup (see packages/hub/src/db/migrate.ts). There are currently 72 migrations (001-072); see guide/architecture.md for the full list.

To run migrations manually:

docker compose exec api node packages/hub/dist/db/migrate.js

Scaling Inference Workers

Workers are stateless and can be scaled independently of the hub:

docker compose up --scale worker=4 -d

See the Workers page for detailed worker deployment options.

Updating

To update the hub and all services:

cd ~/birdnet-ng
git pull
docker compose --profile split up -d --build --scale api=2 --scale worker=2

This rebuilds all containers with the latest code and restarts them. Database migrations run automatically on API startup. No data loss — PostgreSQL, Redis, and MinIO volumes are persistent.

Then run the smoke check:

pnpm ops:check        # scripts/ops-check.sh; -q prints only problems

It probes what the container healthchecks cannot see from inside: every service's state, the hub's readiness with per-dependency latency, an MQTT login with the hub's own credentials, a refused login (dynamic security on), the MQTT-over-WSS path the satellites use, the public names through the proxy (and the previous ones during a move), the public stats endpoint, leftover pre-rename variables, and each device's last contact. It exits non-zero on any FAIL, so it can gate a deploy script.

For zero-downtime updates with scaled API instances, the load balancer (Traefik) handles rolling restarts automatically.