Skip to main content

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​

RequirementMinimumRecommended
RAM4 GB8 GB
Disk Space20 GB50 GB
Docker20.10+Latest
Docker Compose2.0+Latest
Git2.30+Latest

These host ports are published by default (each can be remapped in .env):

Default PortServiceVariable
80Nginx reverse proxyAPP_PORT
3000React frontend (Vite dev server)FRONTEND_PORT
6001Soketi WebSocket serverSOKETI_PORT
9601Soketi metricsSOKETI_METRICS_PORT
4040ngrok 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):

PortServiceVariable
3306MySQLDB_EXPOSE_PORT
6379RedisREDIS_EXPOSE_PORT
8333SeaweedFS S3 APIMINIO_EXPOSE_PORT
9333SeaweedFS master UIMINIO_CONSOLE_EXPOSE_PORT
8181Dialer worker health endpoint (container port 8081)DIALER_WEBHOOK_HOST_PORT
8080MCP serverMCP_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.

Bootstrap script

./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:

VariableDescriptionHow to Generate
APP_KEYLaravel encryption keyecho "base64:$(openssl rand -base64 32)"
APP_URLYour public URLhttp://localhost for local use
DB_PASSWORDMySQL opbx user passwordopenssl rand -base64 32
DB_ROOT_PASSWORDMySQL root passwordopenssl rand -base64 32
REDIS_PASSWORDRedis passwordopenssl 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:

CheckFails when
Weak passwordsDB_PASSWORD, MINIO_ACCESS_KEY or MINIO_SECRET_KEY is exactly secret, password, rootsecret, minioadmin, admin or 123456
Required secretsAPP_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
GrafanaSame rule for GRAFANA_ADMIN_PASSWORD, only when the log stack is enabled (LOG_STACK_ENABLED=true or logs in COMPOSE_PROFILES)
Password lengthDB_PASSWORD is shorter than 8 characters
ProductionAPP_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:

VariableDescriptionDefault
APP_PORTHost port for Nginx80
FRONTEND_PORTHost port for the Vite dev server3000
NGROK_AUTHTOKENngrok authtoken for local webhook development(empty)
NGROK_DOMAINCustom ngrok domain (paid plans)(empty)
USERCHECK_API_TOKENUserCheck email validation token(empty)
DB_CDR_ARCHIVE_DATABASESeparate 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 Configuration

Cloudonix credentials are per organization and are entered in the web UI after your first login. Do not add them to .env.

Security Notice

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:

ServiceContainerPurposeHost Port
appopbx_appLaravel PHP-FPM backend—
nginxopbx_nginxReverse proxyAPP_PORT (80)
frontendopbx_frontendReact Vite dev serverFRONTEND_PORT (3000)
mysqlopbx_mysqlMySQL 8.4 LTS—
redisopbx_redisCache, sessions, queues, locks—
seaweedfsopbx_seaweedfsS3-compatible object storage—
soketiopbx_websocketWebSocket server6001, 9601
queue-workeropbx_queue_workerLaravel queue worker—
queue-worker-longopbx_queue_worker_longQueue worker for long jobs (distribution list uploads, queue hold-music segmentation)—
scheduleropbx_schedulerLaravel scheduler—
dialer-workeropbx_dialer_workerGo auto-dialer worker (one replica)—
amd-workeropbx_amd_workerJava/Vert.x answering-machine detection—
acd-workeropbx_acd_workerJava/Vert.x call queue engine—
mcp-serveropbx_mcpMCP server for AI agents (see MCP Server)—

Two groups of services start only with a profile:

ProfileServicesStart with
devngrok (webhook tunnel)docker compose --profile dev up -d ngrok
logsloki, alloy, grafana, docker-socket-proxydocker compose --profile logs up -d (see Centralized Logs)
First Boot

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​

URLPurpose
http://localhost/ui/loginOPBX web application
http://localhost/statusPublic platform status page
http://localhost/api/healthPublic health check
http://localhost:4040ngrok dashboard (dev profile)
http://localhost/logs/Grafana log viewer (logs profile)
Next Steps

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:

  1. Add NGROK_AUTHTOKEN to .env.
  2. Start the tunnel: docker compose --profile dev up -d ngrok.
  3. Find your public HTTPS URL in the ngrok dashboard (http://localhost:4040) or with docker compose logs ngrok.
  4. 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:

  1. If ./volumes/minio still holds data from an older installation, it runs ./scripts/migrate-minio-to-seaweedfs.sh once (see below). With many recordings this takes a while.
  2. It builds and starts MySQL, Redis, SeaweedFS and app, then shows the app log (database migrations, seeding, bucket creation) until the container is healthy. The first start after an upgrade can take several minutes; that is expected.
  3. 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.

Data Preservation

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:

  1. Terminate TLS in front of Nginx (a load balancer or another reverse proxy).
  2. Back up MySQL (./scripts/backup-database.sh) and ./volumes/seaweedfs on a schedule.
  3. Set APP_ENV=production and APP_DEBUG=false.
  4. Set OPBX_APPLICATION_MODE=production and OPBX_APPLICATION_WEBHOOK_BASEURL to your public URL.
  5. Restrict SANCTUM_STATEFUL_DOMAINS to your domains.
  6. Rotate the worker tokens regularly.
  7. Optionally enable the log stack.
Security Notice

Never expose the MySQL, Redis or SeaweedFS ports to the internet, and never use docker-compose.ports.yml on a production host.