ERPNext Docker Compose setup with Traefik reverse proxy

This post shows how to set up ERPNext, the open-source ERP built on the Frappe framework, using Docker Compose with Traefik as the reverse proxy. Unlike the Onyx setup, no path-based routing is needed: the frontend container bundles an nginx that routes /socket.io to the websocket service and everything else to the gunicorn backend, so a single Host() router in front of frontend:8080 covers the UI, the REST API and realtime updates.

Directory setup

First create the data directories. The frappe/erpnext image runs as UID 1000 (frappe) and needs write access to sites and logs. common_site_config.json must exist — see the bind-mount note below:

init.sh
#!/bin/sh
mkdir -p sites logs db_data redis_queue_data
[ -f sites/common_site_config.json ] || echo '{}' > sites/common_site_config.json
chown -R 1000:1000 sites logs
Setup-ERPNext.sh
mkdir -p erpnext && cd erpnext
./init.sh

Configuration

Create a .env file next to docker-compose.yml:

.env
# Domain Settings
DOMAIN=erpnext.mydomain.com
WEB_DOMAIN=https://erpnext.mydomain.com

# ERPNext Version
ERPNEXT_VERSION=v16.37.0

# Database Credentials
DB_ROOT_PASSWORD=INSERT_YOUR_PASSWORD_HERE

# ERPNext Admin Password
ADMIN_PASSWORD=INSERT_YOUR_PASSWORD_HERE

# Traefik Settings
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_CERTRESOLVER=cloudflare

Generate random passwords with openssl rand -hex 16. DOMAIN doubles as the Frappe site name — create-site runs bench new-site ${DOMAIN} and frontend serves exactly that site.

docker-compose.yml

docker-compose.yml
x-backend-defaults: &backend_defaults
  image: frappe/erpnext:${ERPNEXT_VERSION}
  restart: unless-stopped
  volumes:
    - ./sites:/home/frappe/frappe-bench/sites
    - ./logs:/home/frappe/frappe-bench/logs

services:
  db:
    image: mariadb:11.8
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 5s
      interval: 5s
      timeout: 5s
      retries: 12
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --skip-character-set-client-handshake
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - ./db_data:/var/lib/mysql

  redis-cache:
    image: redis:6.2-alpine
    restart: unless-stopped

  redis-queue:
    image: redis:6.2-alpine
    restart: unless-stopped
    volumes:
      - ./redis_queue_data:/data

  configurator:
    <<: *backend_defaults
    restart: "no"
    entrypoint:
      - bash
      - -c
    command:
      - >
        ls -1 apps > sites/apps.txt;
        [ -f sites/common_site_config.json ] || echo "{}" > sites/common_site_config.json;
        bench set-config -g db_host $$DB_HOST;
        bench set-config -gp db_port $$DB_PORT;
        bench set-config -g redis_cache "redis://$$REDIS_CACHE";
        bench set-config -g redis_queue "redis://$$REDIS_QUEUE";
        bench set-config -g redis_socketio "redis://$$REDIS_QUEUE";
        bench set-config -gp socketio_port $$SOCKETIO_PORT;
    environment:
      DB_HOST: db
      DB_PORT: "3306"
      REDIS_CACHE: redis-cache:6379
      REDIS_QUEUE: redis-queue:6379
      SOCKETIO_PORT: "9000"

  create-site:
    <<: *backend_defaults
    restart: "no"
    entrypoint:
      - bash
      - -c
    command:
      - >
        wait-for-it -t 120 db:3306;
        wait-for-it -t 120 redis-cache:6379;
        wait-for-it -t 120 redis-queue:6379;
        export start=`date +%s`;
        until [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".db_host // empty"` ]] &&
          [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_cache // empty"` ]] &&
          [[ -n `grep -hs ^ sites/common_site_config.json | jq -r ".redis_queue // empty"` ]];
        do
          echo "Waiting for sites/common_site_config.json to be created";
          sleep 5;
          if (( `date +%s`-start > 120 )); then
            echo "could not find sites/common_site_config.json with required keys";
            exit 1;
          fi;
        done;
        echo "sites/common_site_config.json found";
        if [ -d "sites/$${SITE_NAME}" ]; then
          echo "Site $${SITE_NAME} already exists, skipping creation";
        else
          bench new-site --mariadb-user-host-login-scope='%' --admin-password=$${ADMIN_PASSWORD} --db-root-username=root --db-root-password=$${DB_ROOT_PASSWORD} --install-app erpnext --set-default $${SITE_NAME};
        fi;
    environment:
      SITE_NAME: ${DOMAIN}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
      configurator:
        condition: service_completed_successfully

  backend:
    <<: *backend_defaults
    environment:
      DB_HOST: db
      DB_PORT: "3306"
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    depends_on:
      configurator:
        condition: service_completed_successfully

  frontend:
    <<: *backend_defaults
    command:
      - nginx-entrypoint.sh
    environment:
      BACKEND: backend:8000
      FRAPPE_SITE_NAME_HEADER: ${DOMAIN}
      SOCKETIO: websocket:9000
      UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1
      UPSTREAM_REAL_IP_HEADER: X-Forwarded-For
      UPSTREAM_REAL_IP_RECURSIVE: "off"
      PROXY_READ_TIMEOUT: 120
      CLIENT_MAX_BODY_SIZE: 50m
    depends_on:
      - backend
      - websocket
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.erpnext.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.erpnext.entrypoints=${TRAEFIK_ENTRYPOINT}"
      - "traefik.http.routers.erpnext.tls.certresolver=${TRAEFIK_CERTRESOLVER}"
      - "traefik.http.services.erpnext.loadbalancer.server.port=8080"

  websocket:
    <<: *backend_defaults
    command:
      - node
      - /home/frappe/frappe-bench/apps/frappe/socketio.js
    environment:
      FRAPPE_REDIS_CACHE: redis://redis-cache:6379
      FRAPPE_REDIS_QUEUE: redis://redis-queue:6379
    depends_on:
      configurator:
        condition: service_completed_successfully

  queue-short:
    <<: *backend_defaults
    command:
      - bench
      - worker
      - --queue
      - short,default
    environment:
      FRAPPE_REDIS_CACHE: redis://redis-cache:6379
      FRAPPE_REDIS_QUEUE: redis://redis-queue:6379
    depends_on:
      configurator:
        condition: service_completed_successfully

  queue-long:
    <<: *backend_defaults
    command:
      - bench
      - worker
      - --queue
      - long,default,short
    environment:
      FRAPPE_REDIS_CACHE: redis://redis-cache:6379
      FRAPPE_REDIS_QUEUE: redis://redis-queue:6379
    depends_on:
      configurator:
        condition: service_completed_successfully

  scheduler:
    <<: *backend_defaults
    command:
      - bench
      - schedule
    depends_on:
      configurator:
        condition: service_completed_successfully

Notes on the configuration

No explicit networks. All services run on the compose default network. As long as Traefik joins that network, it can reach frontend and no extra networks: section or traefik.docker.network label is needed. If your Traefik runs in a separate compose project, either start both files together (docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d) or point it at the same project with -p erpnext. To reuse a Traefik that is already running elsewhere, attach it to the project network once: docker network connect erpnext_default <traefik-container>.

Bind mounts and common_site_config.json. The official pwd.yml uses named volumes, which copy the image’s sites/ content on first use — including common_site_config.json (shipped as {}). A bind mount instead shadows the directory with an empty one, so bench set-config -g crashes with FileNotFoundError: sites/common_site_config.json. init.sh pre-creates the file; the configurator command creates it as well for good measure. The compiled frontend assets are not affected: the image entrypoint symlinks sites/assets to a baked copy inside the image on every start.

restart: "no" on one-shot services. configurator and create-site run once and exit. unless-stopped would restart them in a loop, so both get restart: "no" and other services wait via depends_on: condition: service_completed_successfully.

FRAPPE_SITE_NAME_HEADER=${DOMAIN}. The bundled nginx sets the X-Frappe-Site-Name header so Frappe knows which site to serve. Pinning it to the site name means the site also answers requests whose Host header doesn’t match, e.g. a health check via IP.

--mariadb-user-host-login-scope='%'. Required because MariaDB runs in a different container — without it bench new-site creates the DB user scoped to localhost and backend can’t connect.

YAML anchors. x-backend-defaults keeps image and the sites/logs volumes in one place instead of repeating them across the seven frappe services.

Start it

Start-ERPNext.sh
docker compose up -d

The first start takes a few minutes while create-site installs the frappe and erpnext apps into a fresh database. Watch progress with docker compose logs -f create-site — it ends with Current Site set to <your domain>.

Open https://erpnext.mydomain.com, log in as Administrator with ADMIN_PASSWORD, and the setup wizard runs on first login.

You can setup autostart using Create a systemd service for your docker-compose project in 10 seconds, TL;DR:

Setup-Autostart.sh
curl -fsSL https://techoverflow.net/scripts/create-docker-compose-service.sh | sudo bash /dev/stdin

Check out similar posts by category: Docker