Multi-Worker Setup
Multi-worker mode splits PasarGuard Panel into dedicated services so API traffic and background jobs run independently. It improves throughput and keeps long-running jobs from blocking the dashboard.
In the examples below, the docker-compose.yml and .env files live in /opt/pasarguard, and data is stored in /var/lib/pasarguard.
What Multi-Worker Runs
| Role | Purpose |
|---|---|
backend | HTTP API + dashboard + migrations |
node | Node operations, logs, and node-related tasks |
scheduler | Scheduled jobs, notifications, and queue processing |
When To Use
- You have many nodes/users and want better responsiveness.
- You need scheduler jobs isolated from API traffic.
- You want to scale API workers independently.
Requirements
- NATS is required for inter-worker coordination.
- All services must share the same database.
- All workers must share
/var/lib/pasarguard(templates, certs, runtime files).
Multi-worker mode requires NATS_ENABLED=1. If NATS is disabled, node/scheduler workers cannot coordinate with the backend.
Step 1: Prepare .env
Below is a minimal set. Add the database block from your selected tab.
# NATS
NATS_ENABLED = 1
NATS_URL = "nats://nats:4222"
# Web server
UVICORN_PORT = 8000
# Optional: Uvicorn processes inside the backend container
UVICORN_WORKERS = 2
# Database connection (set per database section)
SQLALCHEMY_DATABASE_URL = "..."
# Pooling (per worker process)
SQLALCHEMY_POOL_SIZE = 10
SQLALCHEMY_MAX_OVERFLOW = 30ROLE is defined per service in docker-compose.yml. Leave it unset in .env when using the multi-worker compose files below.
Step 2: Choose Your Database
.env
DB_NAME = "pasarguard"
DB_USER = "pasarguard"
DB_PASSWORD = "CHANGE_ME"
SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg://pasarguard:CHANGE_ME@pgbouncer:6432/pasarguard"docker-compose.yml
services:
nats:
image: nats:latest
restart: unless-stopped
command: ["-js"]
ports:
- "4222:4222"
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 2s
timeout: 2s
retries: 10
start_period: 30s
panel:
container_name: pasarguard
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: backend
depends_on:
nats:
condition: service_healthy
pgbouncer:
condition: service_healthy
ports:
- "${UVICORN_PORT}:${UVICORN_PORT}"
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
healthcheck:
test: ["CMD", "/code/healthcheck.sh"]
interval: 5s
timeout: 5s
retries: 10
start_period: 60s
node-worker:
container_name: node-worker
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: node
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
scheduler:
container_name: scheduler
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: scheduler
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
timescaledb:
image: timescale/timescaledb:latest-pg17
restart: always
command: >
postgres -c max_connections=${PG_MAX_CONNECTIONS:-400}
-c shared_buffers=${PG_SHARED_BUFFERS:-512MB}
-c work_mem=${PG_WORK_MEM:-16MB}
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- /var/lib/postgresql/pasarguard:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -q -d ${DB_NAME} -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
pgbouncer:
image: edoburu/pgbouncer:latest
restart: always
environment:
DB_HOST: timescaledb
DB_PORT: 5432
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
LISTEN_PORT: 6432
POOL_MODE: transaction
MAX_CLIENT_CONN: ${PG_MAX_CLIENT_CONN:-600}
DEFAULT_POOL_SIZE: ${PG_DEFAULT_POOL_SIZE:-50}
RESERVE_POOL_SIZE: ${PG_RESERVE_POOL_SIZE:-25}
AUTH_TYPE: scram-sha-256
LOG_CONNECTIONS: 0
LOG_DISCONNECTIONS: 0
LOG_POOLER_ERRORS: 0
VERBOSE: 0
ports:
- "127.0.0.1:6432:6432"
depends_on:
timescaledb:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep -f pgbouncer > /dev/null"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
nats-data:.env
DB_NAME = "pasarguard"
DB_USER = "pasarguard"
DB_PASSWORD = "CHANGE_ME"
SQLALCHEMY_DATABASE_URL = "postgresql+asyncpg://pasarguard:CHANGE_ME@pgbouncer:6432/pasarguard"docker-compose.yml
services:
nats:
image: nats:latest
restart: unless-stopped
command: ["-js"]
ports:
- "4222:4222"
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 2s
timeout: 2s
retries: 10
start_period: 30s
panel:
container_name: pasarguard
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: backend
depends_on:
nats:
condition: service_healthy
pgbouncer:
condition: service_healthy
ports:
- "${UVICORN_PORT}:${UVICORN_PORT}"
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
healthcheck:
test: ["CMD", "/code/healthcheck.sh"]
interval: 5s
timeout: 5s
retries: 10
start_period: 60s
node-worker:
container_name: node-worker
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: node
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
scheduler:
container_name: scheduler
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: scheduler
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
postgresql:
image: postgres:latest
restart: always
command: >
postgres -c max_connections=${PG_MAX_CONNECTIONS:-400}
-c shared_buffers=${PG_SHARED_BUFFERS:-512MB}
-c work_mem=${PG_WORK_MEM:-16MB}
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- /var/lib/postgresql/pasarguard:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -q -d ${DB_NAME} -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
pgbouncer:
image: edoburu/pgbouncer:latest
restart: always
environment:
DB_HOST: postgresql
DB_PORT: 5432
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
LISTEN_PORT: 6432
POOL_MODE: transaction
MAX_CLIENT_CONN: ${PG_MAX_CLIENT_CONN:-600}
DEFAULT_POOL_SIZE: ${PG_DEFAULT_POOL_SIZE:-50}
RESERVE_POOL_SIZE: ${PG_RESERVE_POOL_SIZE:-25}
AUTH_TYPE: scram-sha-256
LOG_CONNECTIONS: 0
LOG_DISCONNECTIONS: 0
LOG_POOLER_ERRORS: 0
VERBOSE: 0
ports:
- "127.0.0.1:6432:6432"
depends_on:
postgresql:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep -f pgbouncer > /dev/null"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
nats-data:.env
DB_NAME = "pasarguard"
DB_USER = "pasarguard"
DB_PASSWORD = "CHANGE_ME"
MYSQL_ROOT_PASSWORD = "CHANGE_ME"
SQLALCHEMY_DATABASE_URL = "mysql+asyncmy://pasarguard:CHANGE_ME@mysql:3306/pasarguard"docker-compose.yml
services:
nats:
image: nats:latest
restart: unless-stopped
command: ["-js"]
ports:
- "4222:4222"
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 2s
timeout: 2s
retries: 10
start_period: 30s
panel:
container_name: pasarguard
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: backend
depends_on:
nats:
condition: service_healthy
mysql:
condition: service_healthy
ports:
- "${UVICORN_PORT}:${UVICORN_PORT}"
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
healthcheck:
test: ["CMD", "/code/healthcheck.sh"]
interval: 5s
timeout: 5s
retries: 10
start_period: 60s
node-worker:
container_name: node-worker
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: node
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
scheduler:
container_name: scheduler
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: scheduler
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
mysql:
image: mysql:lts
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
command:
- --mysqlx=OFF
- --bind-address=0.0.0.0
- --character_set_server=utf8mb4
- --collation_server=utf8mb4_unicode_ci
- --log-bin=mysql-bin
- --binlog_expire_logs_seconds=1209600
- --host-cache-size=0
- --innodb-open-files=1024
- --innodb-buffer-pool-size=256M
- --innodb-log-file-size=64M
- --innodb-log-files-in-group=2
- --general_log=0
- --slow_query_log=1
- --slow_query_log_file=/var/lib/mysql/slow.log
- --long_query_time=2
ports:
- "127.0.0.1:3306:3306"
volumes:
- /var/lib/mysql/pasarguard:/var/lib/mysql
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"127.0.0.1",
"-u",
"${DB_USER}",
"--password=${DB_PASSWORD}",
]
start_period: 5s
interval: 5s
timeout: 5s
retries: 55
volumes:
nats-data:.env
DB_NAME = "pasarguard"
DB_USER = "pasarguard"
DB_PASSWORD = "CHANGE_ME"
MYSQL_ROOT_PASSWORD = "CHANGE_ME"
SQLALCHEMY_DATABASE_URL = "mysql+asyncmy://pasarguard:CHANGE_ME@mariadb:3306/pasarguard"docker-compose.yml
services:
nats:
image: nats:latest
restart: unless-stopped
command: ["-js"]
ports:
- "4222:4222"
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 2s
timeout: 2s
retries: 10
start_period: 30s
panel:
container_name: pasarguard
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: backend
depends_on:
nats:
condition: service_healthy
mariadb:
condition: service_healthy
ports:
- "${UVICORN_PORT}:${UVICORN_PORT}"
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
healthcheck:
test: ["CMD", "/code/healthcheck.sh"]
interval: 5s
timeout: 5s
retries: 10
start_period: 60s
node-worker:
container_name: node-worker
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: node
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
scheduler:
container_name: scheduler
image: pasarguard/panel:latest
restart: unless-stopped
env_file: .env
environment:
ROLE: scheduler
depends_on:
panel:
condition: service_healthy
volumes:
- /var/lib/pasarguard:/var/lib/pasarguard
mariadb:
image: mariadb:lts
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
command:
- --bind-address=0.0.0.0
- --character_set_server=utf8mb4
- --collation_server=utf8mb4_unicode_ci
- --host-cache-size=0
- --innodb-open-files=1024
- --innodb-buffer-pool-size=256M
- --binlog_expire_logs_seconds=1209600
- --innodb-log-file-size=64M
- --innodb-doublewrite=0
- --general_log=0
- --slow_query_log=1
- --slow_query_log_file=/var/lib/mysql/slow.log
- --long_query_time=2
- --innodb_snapshot_isolation=0
ports:
- "127.0.0.1:3306:3306"
volumes:
- /var/lib/mysql/pasarguard:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
start_interval: 3s
interval: 10s
timeout: 5s
retries: 3
volumes:
nats-data:Step 3: Start Services
cd /opt/pasarguard
sudo docker compose up -dTo verify status:
sudo docker compose psView logs for the backend service:
sudo docker compose logs -f panel