PasarGuard
Learn

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

RolePurpose
backendHTTP API + dashboard + migrations
nodeNode operations, logs, and node-related tasks
schedulerScheduled 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 = 30

ROLE 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 -d

To verify status:

sudo docker compose ps

View logs for the backend service:

sudo docker compose logs -f panel