Caption: Accounting, stock, and HR in one browser app. MariaDB and Redis in Compose. HTTPS via nginx-proxy. This is a business database, not a toy container.
Why I wanted this on my server
Invoices, stock, and customer balances do not belong in five SaaS logins and a spreadsheet named final-final.xlsx. ERPNext on Frappe is the open-source suite I was willing to host: selling, buying, inventory, a chart of accounts.
Self-hosting is the point. I decide where those records live and whether I can restore them. It is not set-and-forget. Permissions, fiscal year, and backups matter more than the first login.
What I actually installed
Official frappe/frappe_docker under /opt/frappe_docker, generated Compose in ~/gitops. MariaDB, Redis, backend workers, scheduler, websocket, frontend Nginx, nginx-proxy + acme-companion. Site erp.example.com. Floor: 2 vCPU / 4 GB; 4 / 8 is more honest once reports and jobs run. Docker Engine 23+.
pwd.yml in that repo is a demo. I did not use it for a hostname I care about.
Where it broke
On a fresh Ubuntu 24.04 box this install is famous for bench new-site failing on MariaDB.
Containers looked up. Then:
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench new-site ...
could not connect. I had generated ERPNEXT_DB_ROOT_PASSWORD, written erpnext-secrets.env, and only half-updated DB_PASSWORD in ~/gitops/erpnext.env. The db service and bench were not using the same password.
Fix: DB_PASSWORD in erpnext.env matches the value in erpnext-secrets.env, db is actually running, then new-site again with --db-root-password from that file.
HTTPS failing while containers are green is the other common one: DNS, ports 80/443, NGINX_PROXY_HOSTS exact hostname, then acme-companion logs.
Caption: Frontend, workers, scheduler, websockets, two Redis, MariaDB, proxy. Site name has to match the hostname people type.
Prerequisites
Domain pointing at the box before Let’s Encrypt. SMTP later for invoices. Named users — not shared Administrator for daily work. Backup DB and site files.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl git gnupg openssl ufw jq
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
The working install
1. Install Docker Engine
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version
2. Clone Frappe Docker
sudo mkdir -p /opt/frappe_docker
sudo chown -R "$USER":"$USER" /opt/frappe_docker
git clone https://github.com/frappe/frappe_docker.git /opt/frappe_docker
mkdir -p ~/gitops
chmod 700 ~/gitops
cd /opt/frappe_docker
3. Create the environment file
Replace erp.example.com and admin@example.com first:
cd /opt/frappe_docker
cp example.env ~/gitops/erpnext.env
chmod 600 ~/gitops/erpnext.env
umask 077
ERPNEXT_DB_ROOT_PASSWORD="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)"
ERPNEXT_ADMIN_PASSWORD="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)"
python3 - <<'PY'
from pathlib import Path
import os
env_path = Path.home() / "gitops" / "erpnext.env"
text = env_path.read_text()
text = text.replace("DB_PASSWORD=123", f"DB_PASSWORD={os.environ['ERPNEXT_DB_ROOT_PASSWORD']}")
text += "\nNGINX_PROXY_HOSTS=erp.example.com\n"
text += "LETSENCRYPT_EMAIL=admin@example.com\n"
env_path.write_text(text)
PY
cat > ~/gitops/erpnext-secrets.env <<EOF
ERPNEXT_DB_ROOT_PASSWORD=${ERPNEXT_DB_ROOT_PASSWORD}
ERPNEXT_ADMIN_PASSWORD=${ERPNEXT_ADMIN_PASSWORD}
EOF
jq -n --arg site "erp.example.com" '{"site": $site, "note": "Store ~/gitops/erpnext-secrets.env in a password manager or encrypted vault."}'
Those two env files stay off public git.
4. Render the Compose file
cd /opt/frappe_docker
docker compose --project-name erpnext \
--env-file ~/gitops/erpnext.env \
-f compose.yaml \
-f overrides/compose.mariadb.yaml \
-f overrides/compose.redis.yaml \
-f overrides/compose.nginxproxy.yaml \
-f overrides/compose.nginxproxy-ssl.yaml \
config > ~/gitops/erpnext.yaml
chmod 600 ~/gitops/erpnext.yaml
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml config --services
A rendered file is something I can diff before an upgrade.
5. Start the stack
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml pull
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml up -d
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml ps
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml logs --since=10m backend frontend db redis-cache redis-queue
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml logs --since=10m nginx-proxy acme-companion
6. Create the ERPNext site
set -a
. ~/gitops/erpnext-secrets.env
set +a
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench new-site --mariadb-user-host-login-scope=% \
--db-root-password "${ERPNEXT_DB_ROOT_PASSWORD}" \
--install-app erpnext \
--admin-password "${ERPNEXT_ADMIN_PASSWORD}" \
erp.example.com
Site name = hostname. Another hostname later means proxy list, re-render erpnext.yaml, restart, then create or map that site on purpose.
7. Verify HTTPS and first login
curl -I https://erp.example.com
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench --site erp.example.com list-apps
Sign in as Administrator with ERPNEXT_ADMIN_PASSWORD. Then named admin accounts. I do not live as Administrator.
Very Important to remember
Background work is Redis + scheduler. Login page up but “submit” hangs: bench --site erp.example.com scheduler status and the queue Redis services. Large imports timing out: workers, RAM, proxy timeouts — not a missing checkbox in the wizard.
Wizard still matters: country, timezone, currency, company, fiscal year, chart of accounts. Wrong opening balances are not a Docker problem. I stop posting real invoices until finance looks at GL and taxes.
Email: SMTP inside ERPNext, then a test mail. Uploads failing: volume disk and proxy body size.
First workflow I actually ran
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench --site erp.example.com doctor
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench --site erp.example.com scheduler status
Caption: Customer → quotation → order → invoice → payment, plus stock. If that path works, the stack is more than a login screen.
Test customer, supplier, one item. Quotation → sales order → sales invoice → payment. Purchase receipt or stock entry. Then GL, stock ledger, AR. Cancel or delete the junk per policy before real books.
Backup, expose, next step
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml exec backend \
bench --site erp.example.com backup --with-files
--with-files is public + private. The files still sit in a Docker volume until I copy them off-box (restic, borg, rclone, whatever I already trust). Restore on a separate environment before I believe it.
sudo crontab -e
0 */6 * * * docker compose --project-name erpnext -f /home/ubuntu/gitops/erpnext.yaml exec backend bench --site all backup --with-files >/var/log/erpnext-backup.log 2>&1
Adjust the path if the gitops dir is not /home/ubuntu/gitops.
Caption: DB dump, public files, private files, encrypted copy elsewhere, restore rehearsal. Upgrade notes for accounting versions.
What I have running now is ERPNext on erp.example.com over HTTPS, one site, MariaDB password consistent, wizard not finished with real opening balances. Next I lock roles, connect SMTP, and restore a --with-files backup onto a staging compose project.
Did you hit the same wall?
I got stuck on bench new-site not connecting to MariaDB because DB_PASSWORD in erpnext.env did not match the secret I passed to --db-root-password. Did you hit the same thing, or a different one? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan