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:
#!/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 logsmkdir -p erpnext && cd erpnext
./init.shConfiguration
Create a .env file next to docker-compose.yml:
# 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=cloudflareGenerate 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
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_successfullyNotes 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
docker compose up -dThe 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:
curl -fsSL https://techoverflow.net/scripts/create-docker-compose-service.sh | sudo bash /dev/stdin