How to Self-Host ERPNext on VPS with Docker
Run ERPNext for your VPS with Docker, including the eleven-container stack, sizing, TLS, outbound email, pinned versions, and tested restore steps.
Wetín you dey sign up to run
To self-host ERPNext for VPS na operations work, e no be one-command install. The official Docker Compose stack get eleven containers, and e dey hold your general ledger plus customer records. So, everything wey follow get higher standard: backup no be backup until you don restore am, and image tag wey you no pin na schema migration wey dey wait to happen.
Some names go show throughout this guide. ERPNext na the business application. Frappe na the Python framework wey dey underneath am. Bench na the command line tool wey dey manage sites, and e don already install inside the containers. A site na one tenant: one MariaDB database plus one directory for uploaded files. Almost every command for here dey run bench inside backend container against one named site.
This guide dey use frappe_docker repository, wey na the deployment wey the project dey maintain. We check every command below against that repository for August 2026. If Docker Compose still new to you, run Docker Compose for VPS go explain the basic things wey this guide assume.
ERPNext need how much VPS?
The data behind this chart
[
{
"label": "Evaluation",
"vcpu": 2,
"ram_gb": 4,
"disk_gb": 40
},
{
"label": "Small production",
"vcpu": 4,
"ram_gb": 8,
"disk_gb": 100
},
{
"label": "Room to grow",
"vcpu": 4,
"ram_gb": 16,
"disk_gb": 160
}
]Published guidance dey start from 2 vCPU and 4 GB RAM before even one user login. Na evaluation tier be that. These figures na starting points, dem no be measurements from this guide, and the volume of your own documents go decide the actual number. The last row no be published minimum at all. Na roughly the point where memory stop to be the thing wey you dey worry about.
Talk true to yourself about small plans. 1 GB or 2 GB VPS go start the stack, then e go crash for the first import or first long report, because nine long-running containers plus MariaDB buffer pool plus one Python worker wey dey build report no fit enter that memory. The failure no dey graceful. Kernel out-of-memory killer go stop one container, and docker inspect on top am go then show "OOMKilled": true with exit code 137. If worker die halfway through job, submitted document fit remain with background work wey no complete.
For company wey dey use ERPNext every day, 8 GB RAM and 4 vCPU with 100 GB SSD na the honest minimum. RAM dey finish first. Disk dey grow pass wetin people expect, because every attachment and every local backup dey land for the same volume wey database dey use.
Di containers eleven, and wetin each one dey do
Run docker compose ps after the stack don come up and containers nine dey run. Two more, configurator and create-site, go do their work once and exit. Na from there the total of eleven come from.
backenddey run the Frappe application under gunicorn. Na herebenchdey.frontendna nginx. E dey serve static assets and pass everything else go the backend.queue-shortandqueue-longna RQ (Redis Queue) workers. Dem dey run background jobs like outgoing email, imports, and report builds.schedulerdey run the time-based jobs, including scheduled reports and auto repeat documents.websocketna the socket.io process wey dey power live updates for browser.dbna MariaDB.redis-cacheandredis-queuena two separate Redis instances: one for cache and one for the job queue.
This separation dey worth learning, because e go tell you which log to read. If email dey stuck, na queue worker problem, so docker compose logs -f queue-short na the correct command. If page load but notification badge no ever update, na websocket problem. If you read backend logs for either problem, you go waste the whole afternoon.
Install with the production compose files, no be the demo
Repository dey ship pwd.yml, and README talk am clear: "This setup na only for short-lived evaluation. You no go fit install custom apps for this setup." Use am check ERPNext for one afternoon. No use am run company.
sudo apt update && sudo apt install -y git
curl -fsSL https://get.docker.com | bash
git clone https://github.com/frappe/frappe_docker
cd frappe_docker
mkdir -p ~/gitops
cp example.env ~/gitops/erpnext.envOpen ~/gitops/erpnext.env and change four values. ERPNEXT_VERSION dey pin the image tag. DB_PASSWORD dey show as 123 for the example file. SITES_RULE na the Traefik routing rule, and LETSENCRYPT_EMAIL go receive certificate warnings.
ERPNEXT_VERSION=v16.32.1
DB_PASSWORD=<a long random password>
SITES_RULE=Host(`erp.example.com`)
LETSENCRYPT_EMAIL=ops@example.comNow render one compose file, then start am.
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.https.yaml \
config > ~/gitops/erpnext.yaml
docker compose --project-name erpnext -f ~/gitops/erpnext.yaml up -dconfig no start anything. E merge the base file with the override files and print the result after e don substitute every variable. Then run that rendered file. This extra step useful because the running stack dey inside one file wey you fit read and commit. So e no fit change without you knowing when person edit the env file or when you pull the repository. how several Docker Compose files merge explain the override rules well well.
Wait for db to start and for configurator to exit. This one dey take few seconds. Then create the site.
docker compose --project-name erpnext exec backend \
bench new-site --mariadb-user-host-login-scope=% \
--db-root-password '<your DB_PASSWORD>' \
--install-app erpnext \
--admin-password '<a strong admin password>' \
erp.example.comCheck am:
docker compose --project-name erpnext ps
docker compose --project-name erpnext exec backend bench --site erp.example.com list-appslist-apps suppose print frappe and erpnext with their versions. Healthy ps go show nine services for running state and none for restarting.
Two things dey fail often for here. --mariadb-user-host-login-scope=% no be optional under Docker. App container dey reach MariaDB through the Docker network, so e dey connect as remote host. Database user wey scope to localhost no fit log in from there. Site creation go then fail with MariaDB access denied error wey name the root user. % scope give the new site's user access from any host for that private network.
The second issue na the site name. Frontend dey choose which site to serve from the HTTP Host header by default. So site wey you create as erpnext no go reachable at erp.example.com, even though both sites exist. Name the site after the domain, as shown above. Or set FRAPPE_SITE_NAME_HEADER for the env file to the site name, then render the compose file again.
HTTPS, wetin must dey true before e go work
The compose.https.yaml override dey run Traefik for port 443, redirect port 80 go am, and request certificates from Let's Encrypt. TLS (transport layer security) na wetin stop invoice and session cookie from passing through network as plain text.
Two things must dey true, otherwise certificate no go ever issue. DNS A record for erp.example.com must already point to the VPS. Ports 80 and 443 must dey reachable from internet, because Let's Encrypt dey prove say you control the name with HTTP-01 challenge for port 80. Check your provider network firewall and the one for the server too. Dem na separate controls, and na the panel firewall people dey often forget.
Certificates dey enter the cert-data volume for /letsencrypt/acme.json. If browser show default certificate instead of your own, find proxy service name inside docker compose --project-name erpnext ps and read the logs for ACME (automatic certificate management environment) error. You dey run other web apps for the same server? one Traefik instance wey dey in front of several Docker Compose apps show how to share the proxy instead of making dem fight over port 443.
Outbound email, or the invoices no dey leave the box
Na this step most ERPNext guides dey skip, and na e dey decide whether the system useful. Without working outbound mail, no invoice go reach customer, no password reset go arrive, and no scheduled report go deliver. The stack no get mail server.
No try send mail directly from the VPS through port 25. Most providers dey block outbound port 25 for new accounts, and anything wey manage come out fit get rejected or enter spam, because fresh VPS address no get sending reputation. Use authenticated relay for port 587.
The supported way na the Email Account screen for ERPNext interface, wey dey store password encrypted. You fit also write the keys inside site config:
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-config mail_server smtp.example.com
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-config mail_port 587 --parse
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-config use_tls 1 --parse
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-config mail_login 'erp@example.com'
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-config auto_email_id 'erp@example.com'--parse dey store 587 as number instead of string "587". Read the file again and confirm say those two values no get quotes around dem:
docker compose --project-name erpnext exec backend \
cat sites/erp.example.com/site_config.jsonSet mail_password through the Email Account screen instead of command line, so e go store encrypted and never enter your shell history.
Then send real message. Create Sales Invoice, email am to address wey you control, and monitor the queue as you dey do am:
docker compose --project-name erpnext logs -f queue-shortOutgoing mail na background job, so message wey never arrive usually go show as failed job for that log instead of error for browser. Publish SPF (sender policy framework) and DKIM (domainkeys identified mail) records for the sending domain too, then add DMARC policy. Without dem, invoice wey technically correct still fit enter customer spam folder. If you prefer to control the whole path, self-hosted Mailcow mail server go give you relay wey you control, for separate box from ERP.
Backups wey fit actually restore
Database dump by itself no be ERPNext backup. Attachments and private files dey inside sites directory, no be MariaDB. If you restore only the database, every uploaded purchase order go come back as broken link.
docker compose --project-name erpnext exec backend \
bench --site erp.example.com backup --with-filesThat one go write four files inside sites/erp.example.com/private/backups for the sites volume:
- one
-database.sql.gzdump - one
-files.tararchive of public files - one
-private-files.tararchive of private files - one
-site_config_backup.jsoncopy of the site config
Na the fourth file people dey throw away, and na that one dey cause serious problem. E contain encryption_key, the key wey Frappe dey use encrypt stored passwords: email account credentials, payment gateway keys, and every integration secret. If you restore database without the matching key, the site go load normally, but sending mail go fail with:
frappe.exceptions.ValidationError: Encryption key is invalid! Please check site_config.jsonAlways keep all four files together.
After that, move dem comot from the server. Backup wey dey inside the volume no go survive if the server fail, and bench go prune am anyway: by default, e dey delete backups wey pass 24 hours from that directory.
docker compose --project-name erpnext cp \
backend:/home/frappe/frappe-bench/sites/erp.example.com/private/backups \
~/erpnext-backupsRun that from cron, then push the directory go somewhere wey you no administer. encrypted restic backups to off-site storage na the correct tool, because e encrypt data before upload, and restic check confirms say the repository still dey readable. ERP backup na copy of your complete ledger, so e suppose dey encrypted at rest for hardware wey no be this one.
Test the restore before you need am
Backup wey you never test na guesswork. Restore am for second site wey dey the same box; never restore am for live site.
docker compose --project-name erpnext exec backend \
bench new-site --mariadb-user-host-login-scope=% \
--db-root-password '<your DB_PASSWORD>' \
--admin-password '<a strong admin password>' \
restore-test.example.com
docker compose --project-name erpnext exec backend \
bench --site restore-test.example.com --force restore \
sites/erp.example.com/private/backups/<stamp>-erp.example.com-database.sql.gz \
--with-public-files sites/erp.example.com/private/backups/<stamp>-erp.example.com-files.tar \
--with-private-files sites/erp.example.com/private/backups/<stamp>-erp.example.com-private-files.tar \
--db-root-password '<your DB_PASSWORD>'Copy the encryption key from the backed-up config go the restored site. If you no do am, the integrations go remain broken:
docker compose --project-name erpnext exec backend \
bench --site restore-test.example.com set-config encryption_key '<value from site_config_backup.json>'Now check the restore the way accountant go check am. Open the Accounts Receivable report and compare the closing balance with the live site. Open recent purchase invoice and download the attachment. Site wey fit render login page no prove anything.
Remove the test site when you don finish:
docker compose --project-name erpnext exec backend \
bench drop-site restore-test.example.comWhy version pinning matter more for ERPNext
For static site, image tag wey you no pin mean surprise restart. For ERPNext, e mean schema migration. bench migrate dey rewrite database tables and fit rewrite document data, and e no get undo. To roll back na to restore from backup, no be docker compose down.
So pin the tag. ERPNEXT_VERSION=v16.32.1 na the release wey repository pin for im own pwd.yml for August 2026. No carry that number go forward without checking am. Current releases dey listed for frappe/erpnext releases page, and image tags wey exist dey for Docker Hub. Read the notes for the version wey you wan move to before you move.
The upgrade itself dey start with backup and maintenance mode.
docker compose --project-name erpnext exec backend \
bench --site erp.example.com backup --with-files
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-maintenance-mode onEdit ERPNEXT_VERSION for ~/gitops/erpnext.env, then render, pull and migrate.
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.https.yaml \
config > ~/gitops/erpnext.yaml
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 exec backend \
bench --site erp.example.com migrate
docker compose --project-name erpnext exec backend \
bench --site erp.example.com set-maintenance-mode offMaintenance mode matter because migrate dey alter the schema while e dey run. If user submit document against table wey migration never finish, na so you go end up repairing records by hand.
Move one major version at a time, and make backup between each step. The migration code for one release dey written to upgrade from the release before am, so if you skip major versions, migrations go run for combination wey nobody test.
The repository still ships overrides/compose.migrator.yaml, wey add container wey dey run bench --site all migrate every time e start. E convenient. But e also mean say docker compose up with changed tag fit migrate your production database while nobody dey monitor am. For business system, run migrate as decision wey you make that morning.
Fortalecer server wey dey hold customer records
Change the Administrator password for first login. The evaluation compose file release admin as that password, and people fit carry that habit enter production.
Change DB_PASSWORD comot from the 123 wey dey inside example.env. That value go show for the rendered ~/gitops/erpnext.yaml as plain text, so chmod 600 the file and no put am for any git repository. If you want something stronger, overrides/compose.mariadb-secrets.yaml fit read the password from a Docker secret file instead of an environment variable. how to handle env files and secrets for Docker Compose explain the trade-offs.
Publish only wetin you need. With the HTTPS override, na ports 80 and 443 only dey exposed. No add ports mapping to the db service just to make database client connection easier: that one go put MariaDB for public internet. Use docker compose --project-name erpnext exec backend bench mariadb instead. For the host, allow 22, 80 and 443, deny every other one, and check the provider separate network firewall too.
Turn on two factor authentication for System Settings for every account wey get the System Manager role. That role fit read every document and export every table, so treat am as administrator account, no be convenience account. If you dey run several self-hosted apps, Authentik as self-hosted single sign-on provider better pass adding one more password for every app.
Patch the host and reboot when kernel updates land. Before you trust say the stack go come back, check the rendered file for restart policy on every service, because stack wey no get one go remain down after that reboot. how to make Docker Compose stack start again after reboot cover the systemd side.
When ERPNext no longer dey comfortable for one VPS
One VPS fit carry small company for long time. These signs show say e no fit again:
- Background jobs dey pile up, so emails and imports fit arrive minutes or hours late.
docker inspectreports containers with"OOMKilled": trueor exit code 137.- Reports wey dey take two seconds before now dey take thirty, and MariaDB na the process wey dey hold the CPU.
- Backups dey run long enough make one overlap the next scheduled run.
Start by giving MariaDB resources wey e no share with other services, because database and Python workers dey compete for the same memory, and na the buffer pool dey need more memory. Bigger application server no go help as much as people dey expect. running the database inside Docker or on the host explains that decision, while setting memory limits inside Docker Compose prevents one container from starving the others as you dey make the change.
After that, add queue workers instead of web capacity. ERPNext slow work dey happen for background: report generation and bulk imports. More worker containers cost less than bigger server, and dem fix the problem wey users dey complain about.
FAQ
VPS need how much RAM for ERPNext?
Published guidance dey start for 4 GB with 2 vCPU, and that tier na for evaluation only. For company wey dey use am every day, plan for 8 GB and 4 vCPU with 100 GB SSD. If e fall below that, kernel out of memory killer go stop containers under load, and docker inspect go report am as "OOMKilled": true with exit code 137. Treat these values as starting points, no be exact measurements, so monitor your own memory use during the first month.
I fit run pwd.yml for production?
No. The project README describe am as something for short-lived evaluation only, and e note say you no fit install custom apps inside am. Use compose.yaml with the MariaDB, Redis and HTTPS overrides, render dem into one file with docker compose config, then run that file.
Why my ERPNext site no dey reachable immediately after I create am?
By default, frontend dey choose which site to serve from the HTTP Host header, so the site name must match the domain wey dey browser. Site wey you create as erpnext no go serve for erp.example.com. Either create the site with the domain as its name, or set FRAPPE_SITE_NAME_HEADER for the env file to the site name, render the compose file again, then restart the stack.
Wetin must dey inside ERPNext backup?
Four files wey you keep together: the -database.sql.gz dump, the -files.tar and -private-files.tar archives, and the -site_config_backup.json config copy. When you run bench --site erp.example.com backup --with-files, e go produce all four. The config copy hold encryption_key, so restore wey no get am no go decrypt stored integration passwords. This one go show as Encryption key is invalid! Please check site_config.json.
How I fit upgrade ERPNext without spoiling my data?
Back up with --with-files, turn on maintenance mode, change ERPNEXT_VERSION for your env file, render the compose file again, pull, bring the stack up, then run bench --site erp.example.com migrate and turn maintenance mode off. Move one major version at a time and read the release notes first, because migrate dey rewrite schema and document data without any undo. To roll back, restore the backup wey you take at the beginning.