Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host ERPNext, the fully open-source ERP, on a VPS with the official frappe_docker stack and Easy Install script, automatic Let's Encrypt HTTPS, a restorable backup and a safe upgrade path.
ERPNext is an ERP for running a business from one database: accounting, sales and purchase orders, inventory, manufacturing, projects, assets and a CRM. It is written in Python on the Frappe Framework (MIT-licensed), with MariaDB for data and Redis for caches and queues, and it is rated 4 / 5 to deploy. People reach for it as a self-hosted alternative to QuickBooks and as the open-source ERP that does not hold features back for a paid tier.
That last point is the licensing story. ERPNext is GPL-3.0 end to end; Frappe sells managed hosting (Frappe Cloud), not a premium edition. HR and payroll live in a separate GPL-3.0 app, Frappe HR, which this guide does not install. If you are choosing between ERPNext and Odoo, the Odoo vs ERPNext comparison covers the edition split, and the Odoo deploy guide is the equivalent walkthrough for the other side.
This guide uses the official Easy Install script from the frappe/bench
repository. It downloads frappe_docker, writes an environment file, renders a
production Compose file with Traefik for HTTPS and a backup cron container, and
creates your first site. For help choosing a provider and tier, see
Best VPS for self-hosting.
The catalog puts ERPNext's floor at 4 GB of RAM. In our run the eleven containers idled at 0.5 to 0.9 GB in total, depending on how soon after the install we looked: the backend 160 to 280 MB, MariaDB 120 to 270 MB, and the websocket, scheduler and two queue workers 50 to 70 MB each. Idle numbers flatter an ERP. Memory grows with users, reports over large tables and background jobs, and installing the site is a bursty step that runs the database migrations for every document type.
The image is the other cost. The frappe/erpnext image alone is about
4.4 GB, and the whole install added about 5 GB of disk before any of
your data. Plan on a 4 GB server for a small team and move up when the
accounting year starts filling tables.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl wget python3 ufw dnsutils
If you are logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Open only SSH and the two ports Traefik needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
Be aware that Docker publishes container ports around ufw. That is why it
matters that the generated stack publishes only Traefik's ports 80 and 443 and
leaves MariaDB and Redis on the internal Compose network; a check further down
confirms it.
Paid link — we earn a commission if you shop through it.
The installer can install Docker itself, but doing it first keeps the group change under your control (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version
The group change applies on your next login. Log out and back in before the
next step, because the installer calls docker directly rather than through
sudo. This must pass silently:
docker ps > /dev/null
Point an A record for your hostname (this guide uses erp.example.com;
replace it with yours) at the server's public IP, and confirm it resolves:
DOMAIN=erp.example.com
dig +short "$DOMAIN"
Now run the installer. Two details decide whether this goes smoothly. First,
the script downloads frappe_docker into the current directory but writes
its environment file, Compose file and passwords file into your home
directory, so use a dedicated working directory and come back to it for
upgrades. Second, the email must be real; it is registered with Let's Encrypt
for expiry notices. Replace both values:
DOMAIN=erp.example.com
EMAIL=admin@yourdomain.com
mkdir -p ~/erpnext && cd ~/erpnext
wget -q https://raw.githubusercontent.com/frappe/bench/develop/easy-install.py
python3 easy-install.py deploy --email="$EMAIL" --sitename="$DOMAIN" --app=erpnext > install.log 2>&1
rc=$?
tr '\r' '\n' < install.log | grep -v -i password | tail -n 6
[ "$rc" -eq 0 ]
The installer comes from the develop branch of frappe/bench, which moves, so
it can change under you even though the images are version-pinned. To pin it,
replace develop in the URL with a release tag or a commit hash.
It pulls the images and creates the site, which is the slow part (several
minutes). The later blocks reuse $DOMAIN; if you open a new terminal, set it
again first.
The script exits 0 even when site creation fails, so do not trust the exit
code alone. Wait for the site to answer through Traefik; the loop is bounded,
so a broken install fails instead of hanging:
code=000
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' "https://$DOMAIN/api/method/ping")
[ "$code" = "200" ] && break
sleep 5
done
echo "ERPNext ping answered HTTP $code"
curl -s "https://$DOMAIN/api/method/ping"; echo
docker compose -p frappe ps --format '{{.Service}} {{.Status}}'
HTTP 200 and {"message":"pong"} mean the site exists and the certificate
works. Check that only the proxy is reachable from outside:
docker compose -p frappe ps --format '{{.Ports}}' | grep -o '0.0.0.0:[0-9]*->' | sort -u
You should see ports 80 and 443 only.
The installer leaves three files in your home directory readable by other
local users, and two of them hold secrets: the MariaDB root password sits in
frappe.env, in the rendered frappe-compose.yml and in
frappe-passwords.txt. Tighten them:
chmod 600 ~/frappe.env ~/frappe-compose.yml ~/frappe-passwords.txt
ls -l ~/frappe.env ~/frappe-compose.yml ~/frappe-passwords.txt
There is nothing more to configure. Traefik, started by the same Compose file, requested the certificate from Let's Encrypt over the HTTP challenge as soon as the name resolved, renews it on its own and redirects plain HTTP to HTTPS. Verify both:
curl -s -o /dev/null -w 'HTTPS %{http_code}, certificate check %{ssl_verify_result}\n' "https://$DOMAIN/"
curl -sI "http://$DOMAIN/" | head -n 2
A certificate check of 0 means the chain verified, and the HTTP request
should answer with a permanent redirect to the HTTPS address. For background on
how ACME works, see
Automatic HTTPS with Caddy; Traefik does
the same job here with a different config. Traefik routes by the hostname
stored in SITES_RULE in frappe.env, so the site answers only on the exact
name you passed with --sitename.
The administrator login is Administrator, and its password was generated for
you. Print it on the server (it is the ADMINISTRATOR_PASSWORD line):
cat ~/frappe-passwords.txt
Open https://erp.example.com, sign in as Administrator and ERPNext starts
its setup wizard: language, country, company name, fiscal year and chart of
accounts. Complete it before you invite anyone; the wizard creates the company
that everything else hangs on. Our automated run does not click through the
wizard, so we confirmed the login with the API instead, which proves the
password in the file is the real one:
PASS=$(grep '^SITE_ADMIN_PASS=' ~/frappe.env | cut -d= -f2)
curl -s -X POST "https://$DOMAIN/api/method/login" \
-H 'Content-Type: application/json' \
-d "{\"usr\":\"Administrator\",\"pwd\":\"$PASS\"}"; echo
It should return "Logged In". After the wizard, change the Administrator
password and create real users for your team.
The scheduler starts disabled. Right after the install, bench doctor
reported the scheduler as disabled and inactive for the new site, even though
the scheduler container was running. A disabled scheduler means recurring
jobs, email queue flushing and report emails never run. Turn it on once:
docker compose -p frappe exec backend bench --site "$DOMAIN" enable-scheduler
docker compose -p frappe exec backend bench --site "$DOMAIN" scheduler status
The stack ships with a backup cron container: it runs
bench --site all backup on a schedule (every six hours by default, the
BACKUP_CRONSTRING line in frappe.env). Confirm the job is registered:
grep 'ofelia.job-exec.datecron' ~/frappe-compose.yml
That job backs up the database only, and the archives are written inside the
frappe_sites Docker volume, on the same disk as the data they protect. Treat
it as a safety net against a bad migration, not as a backup. For a real one,
take a full backup that includes uploaded files and copy it off the server:
docker compose -p frappe exec backend bench --site "$DOMAIN" backup --with-files 2>&1 | tail -n 6
mkdir -p ~/erpnext-backup && chmod 700 ~/erpnext-backup
docker compose -p frappe cp "backend:/home/frappe/frappe-bench/sites/$DOMAIN/private/backups/." ~/erpnext-backup/
ls -lh ~/erpnext-backup
You get four files per run: the compressed SQL dump, a public-files archive, a
private-files archive and the site config. Copy ~/erpnext-backup, together
with ~/frappe.env, to encrypted storage or another machine, and run the
backup --with-files command from cron. Frappe also ships a helper for pushing
backups to S3-compatible storage; see the frappe_docker site-operations
documentation.
A backup you have never restored is a guess. This loads the newest dump into a scratch database inside the running MariaDB container and counts the users:
DUMP=$(ls -t ~/erpnext-backup/*database.sql.gz | head -n 1)
docker compose -p frappe exec -T db sh -c 'mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" -e "DROP DATABASE IF EXISTS restore_test; CREATE DATABASE restore_test"'
zcat "$DUMP" | docker compose -p frappe exec -T db sh -c 'mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" restore_test'
docker compose -p frappe exec -T db sh -c 'mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SELECT COUNT(*) FROM restore_test.tabUser; DROP DATABASE restore_test"'
It should print a number of at least 2, the built-in Administrator and
Guest accounts (before you have created any users). This proves the dump
loads and the schema is intact; it does not start ERPNext against the restored
data. For a real restore onto a new server, run the installer there, then use
bench --site <site> restore with the dump and the --with-public-files and
--with-private-files archives.
ERPNext releases are major versions with database migrations, so take a backup
first (above) and read the release notes. The Easy Install script handles the
mechanics: it re-renders the Compose file from frappe.env, recreates the
containers on the new image and runs bench --site all migrate. Run it from the
same directory you installed from:
cd ~/erpnext
python3 easy-install.py upgrade > upgrade.log 2>&1
rc=$?
tr '\r' '\n' < upgrade.log | tail -n 6
[ "$rc" -eq 0 ]
Run as is, this keeps the version recorded in frappe.env. To move to a newer
release, change the ERPNEXT_VERSION line in ~/frappe.env first. The script's
source reads that line back from the existing file ahead of any --version
flag, so passing --version on an install that already has the line does not
change it. Rehearse a major upgrade on a restored copy of your backup before
you do it on the live site, and re-run the ping and login checks afterwards.
The installer prints "Emails with example.com not acceptable" and stops.
It refuses any address containing example.com. We hit this on the first try
with the placeholder address from the catalog's install snippet; use an address
you actually read.
The script finished but the site returns 404 or never answers. Site
creation can fail while the script still exits 0. Read ~/erpnext/install.log
for the bench new-site error, and run
docker compose -p frappe exec backend ls sites to see whether a directory
named after your domain exists. Traefik also routes only by hostname, so
browsing to the bare IP address returns a 404 from the proxy instead of the site.
Traefik never gets a certificate. Confirm dig +short "$DOMAIN" returns
the server's IP and that your provider's network firewall, not just ufw,
allows inbound 80 and 443. A blocked port 80 silently breaks the HTTP
challenge. Read docker compose -p frappe logs proxy for the ACME error.
Emails, reports and background jobs do nothing. Check
bench --site "$DOMAIN" scheduler status; a disabled scheduler is the usual
cause (see the first-login section).
Containers restart or the server crawls. Run docker stats --no-stream
and dmesg | grep -i oom. An out-of-memory kill means the server is too small
for your data and users; move up a tier.
You are done when https://erp.example.com loads the login page with a valid
certificate, Administrator signs in with the generated password, the setup
wizard has been completed, the scheduler reports enabled, and you have restored
a backup into a scratch database at least once. restart: unless-stopped
brings the stack back after a reboot.
From there, work through the setup wizard's first modules, set up outgoing email under Email Account (not tested in this guide's run, because it needs a live provider), and decide whether you need Frappe HR. Lighter alternatives for the billing-only case are Invoice Ninja and Akaunting. See the ERPNext entry for catalog details and Best VPS for self-hosting for the ranked server picks.