Installation
This guide installs OPBX with Docker Compose. The same compose file is used for local development and, with the production overlay, for production.
Prerequisites
| Requirement | Minimum | Recommended |
|---|---|---|
| RAM | 4 GB | 8 GB |
| Disk Space | 20 GB | 50 GB |
| Docker | 20.10+ | Latest |
| Docker Compose | 2.0+ | Latest |
| Git | 2.30+ | Latest |
These host ports are published by default (each can be remapped in .env):
| Default Port | Service | Variable |
|---|---|---|
| 80 | Nginx reverse proxy | APP_PORT |
| 3000 | React frontend (Vite dev server) | FRONTEND_PORT |
| 6001 | Soketi WebSocket server | SOKETI_PORT |
| 9601 | Soketi metrics | SOKETI_METRICS_PORT |
| 4040 | ngrok web interface (only with the dev profile) | — |
MySQL, Redis, SeaweedFS, the dialer worker and the MCP server publish no host ports. For local tools, docker-compose.ports.yml adds loopback-only ports (127.0.0.1):
| Port | Service | Variable |
|---|---|---|
| 3306 | MySQL | DB_EXPOSE_PORT |
| 6379 | Redis | REDIS_EXPOSE_PORT |
| 8333 | SeaweedFS S3 API | MINIO_EXPOSE_PORT |
| 9333 | SeaweedFS master UI | MINIO_CONSOLE_EXPOSE_PORT |
| 8181 | Dialer worker health endpoint (container port 8081) | DIALER_WEBHOOK_HOST_PORT |
| 8080 | MCP server | MCP_PORT |
docker compose -f docker-compose.yml -f docker-compose.ports.yml up -d
Never use docker-compose.ports.yml in production. MCP clients reach the MCP server through Nginx at /mcp without it.
Installation Steps
1. Clone the Repository
git clone https://github.com/greenfieldtech-nirs/opbx.git
cd opbx
2. Create the Configuration Files
cp .env.example .env
cp docker-compose.yml.example docker-compose.yml
docker-compose.yml is ignored by git, so you can adapt it to your host.
./opbx_bootstrap.sh does steps 2 and 3 for you and runs a few checks. It verifies that Docker and Docker Compose are installed and running, copies docker-compose.yml and .env from the examples when they are missing, generates APP_KEY when it is empty, runs docker/scripts/generate-secrets.sh, creates config/broadcasting.php if it is missing, creates the storage/ and bootstrap/cache directories, and warns when APP_NAME, APP_KEY, DB_DATABASE, DB_USERNAME or DB_PASSWORD is empty. You still set DB_PASSWORD, DB_ROOT_PASSWORD and REDIS_PASSWORD by hand. Ignore its warning about CLOUDONIX_API_TOKEN: Cloudonix credentials are entered per organization in the web UI.
3. Set the Secrets
Fill in the shared secrets the workers, Soketi and Grafana need. The script writes only values that are missing or still a placeholder, and prints no values:
docker/scripts/generate-secrets.sh
It sets DIALER_WORKER_API_TOKEN, AMD_WORKER_API_TOKEN, ACD_WORKER_API_TOKEN, PUSHER_APP_SECRET, GRAFANA_ADMIN_PASSWORD, MINIO_ACCESS_KEY and MINIO_SECRET_KEY.
Then edit .env and set the rest by hand:
| Variable | Description | How to Generate |
|---|---|---|
APP_KEY | Laravel encryption key | echo "base64:$(openssl rand -base64 32)" |
APP_URL | Your public URL | http://localhost for local use |
DB_PASSWORD | MySQL opbx user password | openssl rand -base64 32 |
DB_ROOT_PASSWORD | MySQL root password | openssl rand -base64 32 |
REDIS_PASSWORD | Redis password | openssl rand -base64 32 |
The .env.example ships the S3 keys empty (the script above generates them). Compose itself refuses to start the stack (any service, any profile) while the worker tokens, PUSHER_APP_SECRET, the S3 keys or GRAFANA_ADMIN_PASSWORD are unset.
The app container checks the values again on every start and exits when one fails:
| Check | Fails when |
|---|---|
| Weak passwords | DB_PASSWORD, MINIO_ACCESS_KEY or MINIO_SECRET_KEY is exactly secret, password, rootsecret, minioadmin, admin or 123456 |
| Required secrets | APP_KEY, DB_PASSWORD, PUSHER_APP_SECRET, DIALER_WORKER_API_TOKEN, AMD_WORKER_API_TOKEN or ACD_WORKER_API_TOKEN is empty, is admin, or contains a placeholder word: CHANGE_ME, GENERATE_32_CHAR, 64_CHAR_SECRET, default123, password, secret, change-in-production or pbxappsecret |
| Grafana | Same rule for GRAFANA_ADMIN_PASSWORD, only when the log stack is enabled (LOG_STACK_ENABLED=true or logs in COMPOSE_PROFILES) |
| Password length | DB_PASSWORD is shorter than 8 characters |
| Production | APP_ENV=production with APP_DEBUG=true, or with REDIS_EXPOSE_PORT set |
DB_ROOT_PASSWORD is not checked (MySQL falls back to rootsecret when it is unset), so make sure you set it. Because the check looks for the words inside the value, a generated secret must not contain password or secret. Values from openssl rand never do.
Optional but commonly configured variables:
| Variable | Description | Default |
|---|---|---|
APP_PORT | Host port for Nginx | 80 |
FRONTEND_PORT | Host port for the Vite dev server | 3000 |
NGROK_AUTHTOKEN | ngrok authtoken for local webhook development | (empty) |
NGROK_DOMAIN | Custom ngrok domain (paid plans) | (empty) |
USERCHECK_API_TOKEN | UserCheck email validation token | (empty) |
DB_CDR_ARCHIVE_DATABASE | Separate schema for the warm call-record archive. Create it and grant the app user first | (main schema) |
All variables are listed in the Configuration Reference.
Cloudonix credentials are per organization and are entered in the web UI after your first login. Do not add them to .env.
Never commit .env or docker-compose.yml to version control.
4. Install the PHP Dependencies
The compose file bind-mounts the repository over the app image, so the vendor/ directory must exist on the host. The app container stops with "Composer dependencies are not installed" when it is missing. Run Composer on the host, or through the Composer image:
composer install
# or, without PHP on the host:
docker run --rm -v "$PWD":/app -w /app composer:2 install --ignore-platform-reqs
5. Start the Services
docker compose up -d
This builds the local images and starts these services:
| Service | Container | Purpose | Host Port |
|---|---|---|---|
app | opbx_app | Laravel PHP-FPM backend | — |
nginx | opbx_nginx | Reverse proxy | APP_PORT (80) |
frontend | opbx_frontend | React Vite dev server | FRONTEND_PORT (3000) |
mysql | opbx_mysql | MySQL 8.4 LTS | — |
redis | opbx_redis | Cache, sessions, queues, locks | — |
seaweedfs | opbx_seaweedfs | S3-compatible object storage | — |
soketi | opbx_websocket | WebSocket server | 6001, 9601 |
queue-worker | opbx_queue_worker | Laravel queue worker | — |
queue-worker-long | opbx_queue_worker_long | Queue worker for long jobs (distribution list uploads, queue hold-music segmentation) | — |
scheduler | opbx_scheduler | Laravel scheduler | — |
dialer-worker | opbx_dialer_worker | Go auto-dialer worker (one replica) | — |
amd-worker | opbx_amd_worker | Java/Vert.x answering-machine detection | — |
acd-worker | opbx_acd_worker | Java/Vert.x call queue engine | — |
mcp-server | opbx_mcp | MCP server for AI agents (see MCP Server) | — |
Two groups of services start only with a profile:
| Profile | Services | Start with |
|---|---|---|
dev | ngrok (webhook tunnel) | docker compose --profile dev up -d ngrok |
logs | loki, alloy, grafana, docker-socket-proxy | docker compose --profile logs up -d (see Centralized Logs) |
On start, the app container validates the environment, waits for MySQL (up to MYSQL_WAIT_TIMEOUT, default 60 seconds), runs migrations, seeds a fresh database, checks object storage and validates the configuration. Wait 120 seconds after starting the containers before testing.
6. Create the Storage Buckets
The app expects two private buckets in SeaweedFS: recordings (call recordings, IVR audio) and cdr-archive (cold call-record archive). Startup only checks that they are reachable. Create them once:
echo "s3.bucket.create -name recordings" | docker compose exec -T seaweedfs weed shell
echo "s3.bucket.create -name cdr-archive" | docker compose exec -T seaweedfs weed shell
7. Verify the Installation
docker compose ps
Every service with a healthcheck should show healthy. The app, mysql, redis, seaweedfs, soketi, queue workers, scheduler, the three workers and mcp-server all have one.
Test the public health endpoint:
curl http://localhost/api/health
{
"status": "ok",
"timestamp": "2026-06-22T12:00:00+00:00"
}
8. Access the Application
| URL | Purpose |
|---|---|
http://localhost/ui/login | OPBX web application |
http://localhost/status | Public platform status page |
http://localhost/api/health | Public health check |
http://localhost:4040 | ngrok dashboard (dev profile) |
http://localhost/logs/ | Grafana log viewer (logs profile) |
Proceed to First Login. A fresh database is seeded with a default organization and an owner account (admin@example.com / password) whose password you must change. If you are enabling Auth0 social login, also see Auth0 Setup. To understand how calls are handled, see the Call Routing Overview and Outbound Calling Overview.
Troubleshooting
Port Conflicts
Change the corresponding variable in .env:
APP_PORT=8080 # Nginx on 8080 instead of 80
FRONTEND_PORT=3001 # Vite on 3001 instead of 3000
The app Container Exits on Start
docker compose logs app | head -n 50
Common causes: an empty or placeholder secret, a weak database password or S3 key, APP_DEBUG=true in production, a missing vendor/ directory ("Composer dependencies are not installed"), or MySQL not ready within MYSQL_WAIT_TIMEOUT seconds ("MySQL not ready after …"). The log names the variable that failed.
"No such image" Errors (podman-compose)
Podman-compose does not build images automatically. Build them first:
docker compose build # or: docker compose build mcp-server
docker compose up -d
docker compose up -d --build also works.
MySQL Slow Start
MySQL may take longer to initialize on first boot:
docker compose logs -f mysql
Permission Denied
Make sure your user is in the docker group:
sudo usermod -aG docker $USER
# Log out and back in for changes to take effect
Container Failures
docker compose logs -f [service-name]
Common service names: app, nginx, mysql, redis, seaweedfs, queue-worker, scheduler, dialer-worker, amd-worker, acd-worker, mcp-server.
Migrations Do Not Run
The app container runs migrations unless RUN_MIGRATIONS=false. The queue workers and scheduler never run them. To run them by hand:
docker compose exec app php artisan migrate
Webhooks Not Received in Local Development
For Cloudonix to reach your local instance, start the ngrok tunnel:
- Add
NGROK_AUTHTOKENto.env. - Start the tunnel:
docker compose --profile dev up -d ngrok. - Find your public HTTPS URL in the ngrok dashboard (
http://localhost:4040) or withdocker compose logs ngrok. - Sign in as the Owner, open Settings from the user menu (top right), enter that URL in Webhook Base URL and click Validate and Save Settings.
Updating OPBX
# Pull the new version
git pull
# Bring docker-compose.yml up to date with docker-compose.yml.example
./scripts/update-compose.sh --dry-run # report only
./scripts/update-compose.sh # asks per changed service
# Update PHP dependencies, then rebuild and start in order
composer install
./scripts/upgrade.sh # add --prod when you deploy with docker-compose.prod.yml
scripts/upgrade.sh starts the stack in the right order and shows progress instead of failing:
- If
./volumes/miniostill holds data from an older installation, it runs./scripts/migrate-minio-to-seaweedfs.shonce (see below). With many recordings this takes a while. - It builds and starts MySQL, Redis, SeaweedFS and
app, then shows theapplog (database migrations, seeding, bucket creation) until the container is healthy. The first start after an upgrade can take several minutes; that is expected. - It starts the rest of the stack and prints
docker compose ps.
It stops with an error only if app exits, turns unhealthy, or is not ready after 30 minutes (UPGRADE_TIMEOUT, in seconds). A plain docker compose up -d also works: nginx, the queue workers and the scheduler wait while app is still starting (its health check allows up to 15 minutes for the first start); follow it with docker compose logs -f app.
On every start app waits up to 2 minutes for SeaweedFS (STORAGE_WAIT_TIMEOUT, in seconds) and creates the recordings and cdr-archive buckets if they are missing.
scripts/update-compose.sh compares your docker-compose.yml with the new example service by service (all profiles). New services are added, services that exist only in your file are kept, and services removed upstream (such as minio) are removed only when you confirm. It validates the result with docker compose config, keeps the old file as docker-compose.yml.bak-<timestamp>, and restarts nothing. --yes runs it unattended. It needs only Docker.
Upgrading from MinIO
Older installations stored objects in MinIO (./volumes/minio). SeaweedFS replaces it. After updating docker-compose.yml, copy the objects across:
./scripts/migrate-minio-to-seaweedfs.sh --dry-run # list buckets and object counts
./scripts/migrate-minio-to-seaweedfs.sh # copy and verify every bucket
./scripts/upgrade.sh runs this for you the first time (and records it in ./volumes/.minio-migrated-to-seaweedfs); run it by hand only to check first or to re-run. The script starts SeaweedFS and a temporary MinIO container on the old data, mirrors every bucket, verifies it, and leaves ./volumes/minio in place as a fallback. It is safe to re-run. When it succeeds, make sure .env has MINIO_ENDPOINT=http://seaweedfs:8333 and start the stack (./scripts/upgrade.sh). A bucket that MinIO never had (for example cdr-archive) is created when app starts. Copied objects get a new modification date, so recording retention counts from the migration date.
Upgrading from MySQL 8.0
The compose file uses MySQL 8.4. Back up first (./scripts/backup-database.sh); an 8.0 data directory is upgraded in place on the first 8.4 start. docker/mysql/my.cnf keeps mysql_native_password loaded for older clients.
MySQL, Redis and SeaweedFS data live in bind mounts under ./volumes/ (mysql, redis, seaweedfs). They survive docker compose down and down -v. Deleting the volumes/ directory deletes the data. The logs profile keeps Loki and Grafana data in the named volumes loki_data and grafana_data, which down -v does remove.
Production Deployment
Serve the compiled frontend instead of the Vite dev server with the production overlay:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker-compose.prod.yml builds the frontend as a static site (the VITE_* values are baked in at build time; set VITE_WS_HOST, VITE_WS_PORT and VITE_WS_SCHEME for your public host), removes the dev port and bind mounts, and switches Nginx to the production virtual host in docker/nginx/conf.d.prod.
Also:
- Terminate TLS in front of Nginx (a load balancer or another reverse proxy).
- Back up MySQL (
./scripts/backup-database.sh) and./volumes/seaweedfson a schedule. - Set
APP_ENV=productionandAPP_DEBUG=false. - Set
OPBX_APPLICATION_MODE=productionandOPBX_APPLICATION_WEBHOOK_BASEURLto your public URL. - Restrict
SANCTUM_STATEFUL_DOMAINSto your domains. - Rotate the worker tokens regularly.
- Optionally enable the log stack.
Never expose the MySQL, Redis or SeaweedFS ports to the internet, and never use docker-compose.ports.yml on a production host.