Self-Host ERPNext on a VPS with Docker
Run ERPNext on your own VPS with Docker: sizing, the eleven container stack, TLS, outbound email, version pinning, and a restore you have tested.
What you are signing up to run
Self-hosting ERPNext on a VPS is an operations job, not a one command install. The official Docker Compose stack is eleven containers, and it holds your general ledger and your customer records. That raises the standard for everything below: a backup is not a backup until you have restored it, and an unpinned image tag is a schema migration waiting to happen.
A few names appear throughout. ERPNext is the business application. Frappe is the Python framework underneath it. Bench is the command line tool that manages sites, already installed inside the containers. A site is one tenant: one MariaDB database plus one directory of uploaded files. Almost every command here runs bench inside the backend container against one named site.
This guide uses the frappe_docker repository, which is the deployment the project maintains. Every command below was checked against that repository in August 2026. If Docker Compose itself is new to you, running Docker Compose on a VPS covers the ground this guide assumes.
How much VPS does ERPNext need?
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 starts at 2 vCPU and 4 GB of RAM before a single user logs in. That is the evaluation tier. These are starting points, not measurements from this guide, and your own document volume decides the real number. The last row is not a published minimum at all. It is roughly where memory stops being the thing you think about.
Be direct with yourself about the small plans. A 1 GB or 2 GB VPS will start the stack and then fall over on the first import or the first long report, because nine long running containers plus MariaDB's buffer pool plus a Python worker building a report do not fit in that memory. The failure is not graceful. The kernel out of memory killer stops a container, and docker inspect on it then shows "OOMKilled": true with exit code 137. A worker killed mid job leaves a submitted document with its background work half done.
For a company using ERPNext every day, 8 GB and 4 vCPU with 100 GB of SSD is the honest floor. RAM runs out first. Disk grows faster than people expect, because every attachment and every local backup lands on the same volume as the database.
The eleven containers, and what each one does
Run docker compose ps after the stack is up and nine containers are running. Two more, configurator and create-site, do their work once and exit, which is where the total of eleven comes from.
backendruns the Frappe application under gunicorn. This is wherebenchlives.frontendis nginx. It serves static assets and passes everything else to the backend.queue-shortandqueue-longare RQ (Redis Queue) workers. They run background jobs such as outgoing email, imports and report builds.schedulerfires the time based jobs, including scheduled reports and auto repeat documents.websocketis the socket.io process behind live updates in the browser.dbis MariaDB.redis-cacheandredis-queueare two separate Redis instances, one for cache and one for the job queue.
That split is worth learning, because it tells you which log to read. A stuck email is a queue worker problem, so docker compose logs -f queue-short is the right command. A page that loads but never updates its notification badge is a websocket problem. Reading backend logs for either one wastes an afternoon.
Install with the production compose files, not the demo
The repository ships pwd.yml, and the README is blunt about it: "This setup is intended for short-lived evaluation only. You will not be able to install custom apps to this setup." Use it to look at ERPNext for an afternoon. Do not run a company on it.
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 pins the image tag. DB_PASSWORD ships as 123 in the example file. SITES_RULE is the Traefik routing rule, and LETSENCRYPT_EMAIL receives 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 it.
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 starts nothing. It merges the base file with the overrides and prints the result with every variable already substituted. You then run that rendered file. The extra step earns its keep: the running stack is one file you can read and commit, so it cannot change under you when somebody edits the env file or when you pull the repository. how several Docker Compose files merge explains the override rules in detail.
Wait for db to start and for configurator to exit, which takes a 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 it:
docker compose --project-name erpnext ps
docker compose --project-name erpnext exec backend bench --site erp.example.com list-appslist-apps should print frappe and erpnext with their versions. A healthy ps shows nine services in running state and none in restarting.
Two things go wrong here often. --mariadb-user-host-login-scope=% is not optional under Docker. The app container reaches MariaDB across the Docker network, so it arrives as a remote host, and a database user scoped to localhost cannot log in from there. Site creation then fails with a MariaDB access denied error naming the root user. The % scope grants the new site's user access from any host on that private network.
The second one is the site name. The frontend picks which site to serve from the HTTP Host header by default, so a site created as erpnext is not reachable at erp.example.com even though both exist. Name the site after the domain, as above, or set FRAPPE_SITE_NAME_HEADER in the env file to the site name and render the compose file again.
HTTPS, and what has to be true before it works
The compose.https.yaml override runs Traefik on port 443, redirects port 80 to it, and requests certificates from Let's Encrypt. TLS (transport layer security) is what keeps an invoice and a session cookie off the wire in plain text.
Two things must be true or no certificate is ever issued. The DNS A record for erp.example.com must already point at the VPS. Ports 80 and 443 must be reachable from the internet, because Let's Encrypt proves you control the name with an HTTP-01 challenge on port 80. Check your provider's network firewall as well as the one on the box. They are separate controls, and the panel firewall is the one people forget.
Certificates land in the cert-data volume at /letsencrypt/acme.json. If the browser shows a default certificate instead of yours, find the proxy service name in docker compose --project-name erpnext ps and read its logs for the ACME (automatic certificate management environment) error. Running other web apps on the same server? one Traefik instance in front of several Docker Compose apps shows how to share the proxy instead of fighting over port 443.
Outbound email, or the invoices never leave the box
This is the step most ERPNext guides skip, and it is the one that decides whether the system is useful. Without working outbound mail no invoice reaches a customer, no password reset arrives, and no scheduled report is delivered. The stack contains no mail server.
Do not try to send mail straight from the VPS on port 25. Most providers block outbound port 25 on new accounts, and whatever does get out is rejected or filed as spam, because a fresh VPS address has no sending reputation. Use an authenticated relay on port 587.
The supported path is the Email Account screen in the ERPNext interface, which stores the password encrypted. You can also write the keys into the 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 stores 587 as a number instead of the string "587". Read the file back and confirm those two values have no quotes around them:
docker compose --project-name erpnext exec backend \
cat sites/erp.example.com/site_config.jsonSet mail_password through the Email Account screen rather than on the command line, so it is stored encrypted and never enters your shell history.
Then send a real message. Create a Sales Invoice, email it to an address you control, and watch the queue while you do:
docker compose --project-name erpnext logs -f queue-shortOutgoing mail is a background job, so a message that never arrives usually shows up as a failed job in that log rather than as an error in the browser. Publish SPF (sender policy framework) and DKIM (domainkeys identified mail) records for the sending domain as well, then add a DMARC policy. Without them a technically correct invoice still lands in the customer's spam folder. If you would rather own the whole path, a self-hosted Mailcow mail server gives you a relay you control, on a separate box from the ERP.
Backups that actually restore
A database dump on its own is not a backup of ERPNext. Attachments and private files live in the sites directory, not in MariaDB. Restore only the database and every uploaded purchase order comes back as a broken link.
docker compose --project-name erpnext exec backend \
bench --site erp.example.com backup --with-filesThat writes four files into sites/erp.example.com/private/backups inside the sites volume:
- a
-database.sql.gzdump - a
-files.tararchive of public files - a
-private-files.tararchive of private files - a
-site_config_backup.jsoncopy of the site config
The fourth file is the one people throw away, and it is the one that hurts. It holds encryption_key, the key Frappe uses to encrypt stored passwords: email account credentials, payment gateway keys, every integration secret. Restore a database without the matching key and the site loads normally, but sending mail fails with:
frappe.exceptions.ValidationError: Encryption key is invalid! Please check site_config.jsonKeep all four files together, always.
Then get them off the server. A backup inside the volume does not survive the server, and bench prunes it anyway: by default it deletes backups older than 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 somewhere you do not administer. encrypted restic backups to off-site storage is the right tool, because it encrypts before upload and restic check proves the repository is still readable. An ERP backup is a copy of your entire ledger, so it belongs encrypted at rest on hardware that is not this one.
Test the restore before you need it
An untested backup is a guess. Test it into a second site on the same box, never into the live one.
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 out of the backed up config into the restored site, or its integrations stay 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 an accountant would. Open the Accounts Receivable report and compare the closing balance with the live site. Open a recent purchase invoice and download its attachment. A site that renders its login page proves nothing at all.
Remove the test site when you are finished:
docker compose --project-name erpnext exec backend \
bench drop-site restore-test.example.comWhy version pinning matters more for ERPNext
On a static site an unpinned image tag means a surprise restart. On ERPNext it means a schema migration. bench migrate rewrites database tables and can rewrite document data, and it has no undo. Rolling back is a restore from backup, not a docker compose down.
So pin the tag. ERPNEXT_VERSION=v16.32.1 was the release pinned in the repository's own pwd.yml in August 2026. Do not carry that number forward without checking it. Current releases are listed on the frappe/erpnext releases page, and the image tags that exist are on Docker Hub. Read the notes for the version you are moving to before you move.
The upgrade itself starts with a 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 in ~/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 matters because migrate alters the schema while it runs. A user submitting a document against a half migrated table is how you end up repairing records by hand.
Move one major version at a time, with a backup between each step. The migration code in a release is written to upgrade from the release before it, so skipping majors runs migrations in a combination nobody tested.
The repository also ships overrides/compose.migrator.yaml, which adds a container that runs bench --site all migrate on every start. It is convenient. It also means a docker compose up with a changed tag migrates your production database with nobody watching. On a business system, run migrate as a decision you made that morning.
Hardening a box that holds customer records
Change the Administrator password at first login. The evaluation compose file ships admin as that password, and the habit follows people into production.
Change DB_PASSWORD away from the 123 in example.env. That value ends up in the rendered ~/gitops/erpnext.yaml in plain text, so chmod 600 the file and keep it out of any git repository. For something stronger, overrides/compose.mariadb-secrets.yaml reads the password from a Docker secret file instead of an environment variable. handling env files and secrets in Docker Compose covers the trade-offs.
Publish only what you need. With the HTTPS override, ports 80 and 443 are the only ones exposed. Do not add a ports mapping to the db service to make a database client easier to connect: that puts MariaDB on the public internet. Use docker compose --project-name erpnext exec backend bench mariadb instead. On the host, allow 22, 80 and 443, deny the rest, and check the provider's separate network firewall too.
Turn on two factor authentication in System Settings for every account holding the System Manager role. That role can read every document and export every table, so treat it as an administrator account rather than a convenience. If you run several self-hosted apps, Authentik as a self-hosted single sign-on provider is better than one more password per app.
Patch the host and reboot for kernel updates. Before you rely on the stack coming back, check the rendered file for a restart policy on each service, because a stack without one stays down after that reboot. making a Docker Compose stack start again after a reboot covers the systemd side.
When ERPNext stops being comfortable on one VPS
One VPS carries a small company for a long time. The signs that it no longer does:
- Background jobs pile up, so emails and imports arrive minutes or hours late.
docker inspectreports containers with"OOMKilled": trueor exit code 137.- Reports that took two seconds take thirty, and MariaDB is the process holding the CPU.
- Backups run long enough that one overlaps the next scheduled run.
Start by giving MariaDB resources it does not share, because the database and the Python workers compete for the same memory and the buffer pool is the part that wants more of it. A bigger application server helps less than people expect. running the database in Docker or on the host covers that decision, and setting memory limits in Docker Compose keeps one container from starving the others while you make it.
After that, add queue workers rather than web capacity. ERPNext's slow work is background work: report generation and bulk imports. More worker containers cost less than a bigger box, and they fix the symptom users actually complain about.
FAQ
How much RAM does ERPNext need on a VPS?
Published guidance starts at 4 GB with 2 vCPU, and that tier is for evaluation only. For a company using it daily, plan on 8 GB and 4 vCPU with 100 GB of SSD. Below that the kernel out of memory killer stops containers under load, which docker inspect reports as "OOMKilled": true with exit code 137. These are starting points rather than measurements, so watch your own memory use through the first month.
Can I run pwd.yml in production?
No. The project's README describes it as intended for short-lived evaluation only, and notes that you cannot install custom apps into it. Use compose.yaml with the MariaDB, Redis and HTTPS overrides, render them into a single file with docker compose config, and run that file.
Why is my ERPNext site unreachable right after I create it?
The frontend chooses which site to serve from the HTTP Host header by default, so the site name has to match the domain in the browser. A site created as erpnext is not served at erp.example.com. Either create the site using the domain as its name, or set FRAPPE_SITE_NAME_HEADER in the env file to the site name, render the compose file again and restart the stack.
What has to be in an ERPNext backup?
Four files, kept together: the -database.sql.gz dump, the -files.tar and -private-files.tar archives, and the -site_config_backup.json config copy. Running bench --site erp.example.com backup --with-files produces all four. The config copy holds encryption_key, so a restore without it leaves stored integration passwords undecryptable, which surfaces as Encryption key is invalid! Please check site_config.json.
How do I upgrade ERPNext without breaking my data?
Back up with --with-files, turn on maintenance mode, change ERPNEXT_VERSION in 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 rewrites schema and document data with no undo. Rolling back means restoring the backup you took at the start.