SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-24

Tự host ERPNext trên VPS bằng Docker

Triển khai ERPNext trên VPS với stack Docker Compose 11 container, TLS, email outbound và image tag được pin; kiểm tra restore backup trước khi chạy production.

Bạn sắp triển khai gì

Tự host ERPNext trên một VPS là công việc vận hành, không phải thao tác cài đặt bằng một lệnh. Stack Docker Compose chính thức gồm 11 container và lưu trữ sổ cái tổng hợp cùng hồ sơ khách hàng của bạn. Vì vậy, mọi bước dưới đây phải tuân theo tiêu chuẩn cao hơn: một bản backup chưa được restore thì chưa thể xem là bản backup, còn image tag không được pin là dấu hiệu một schema migration có thể xảy ra bất cứ lúc nào.

Một số tên sẽ xuất hiện xuyên suốt tài liệu. ERPNext là ứng dụng nghiệp vụ. Frappe là Python framework bên dưới ứng dụng đó. Bench là công cụ command line dùng để quản lý các site và đã được cài sẵn bên trong các container. Một site là một tenant: gồm một database MariaDB và một thư mục chứa các file đã upload. Hầu hết command ở đây chạy bench bên trong container backend và thao tác trên một site được chỉ định tên.

Hướng dẫn này sử dụng repository frappe_docker, là deployment do project duy trì. Tất cả command dưới đây đã được kiểm tra với repository đó vào tháng 8 năm 2026. Nếu Docker Compose còn mới với bạn, chạy Docker Compose trên VPS sẽ trình bày các kiến thức nền mà hướng dẫn này giả định bạn đã biết.

ERPNext cần VPS cấu hình thế nào?

ChartCommon published ERPNext sizing tiers (guidance, not a measurement)
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
  }
]

Tài liệu hướng dẫn được công bố bắt đầu từ 2 vCPU và 4 GB RAM trước khi có một người dùng đăng nhập. Đây là mức dùng để đánh giá. Các con số này chỉ là điểm bắt đầu, không phải kết quả đo từ hướng dẫn này. Số lượng tài liệu của bạn mới quyết định cấu hình thực tế. Hàng cuối hoàn toàn không phải mức tối thiểu được công bố. Đó là mức gần như đủ để bạn không còn phải thường xuyên nghĩ đến vấn đề thiếu RAM.

Hãy đánh giá thực tế các gói cấu hình nhỏ. VPS 1 GB hoặc 2 GB sẽ khởi động được stack, rồi sập ngay ở lần import đầu tiên hoặc báo cáo chạy lâu đầu tiên. Chín container chạy dài hạn, buffer pool của MariaDB và một Python worker đang tạo báo cáo không thể cùng hoạt động trong từng đó RAM. Lỗi sẽ không được xử lý êm. Kernel out-of-memory killer dừng một container, sau đó docker inspect trên container đó sẽ hiển thị "OOMKilled": true với exit code 137. Nếu worker bị dừng giữa job, document đã submit có thể chỉ hoàn tất một phần công việc chạy nền.

Với một công ty sử dụng ERPNext hằng ngày, 8 GB RAM, 4 vCPU và 100 GB SSD là mức tối thiểu thực tế. RAM sẽ hết trước. Dung lượng disk tăng nhanh hơn dự kiến, vì mọi attachment và mọi bản backup cục bộ đều nằm trên cùng volume với database.

Mười một container và chức năng của từng container

Chạy docker compose ps sau khi stack đã khởi động và chín container đang chạy. Hai container còn lại, configurator và create-site, chạy xong một lần rồi thoát. Vì vậy tổng số container là mười một.

  • backend chạy ứng dụng Frappe bằng gunicorn. Đây là nơi bench hoạt động.
  • frontend là nginx. Nó phục vụ static asset và chuyển tiếp mọi yêu cầu khác đến backend.
  • queue-short và queue-long là các worker RQ (Redis Queue). Chúng chạy các background job như gửi email đi, import dữ liệu và tạo report.
  • scheduler chạy các job theo lịch, bao gồm report định kỳ và document tự động lặp lại.
  • websocket là tiến trình socket.io xử lý cập nhật trực tiếp trên trình duyệt.
  • db là MariaDB.
  • redis-cache và redis-queue là hai instance Redis riêng biệt: một instance dùng cho cache và một instance dùng cho job queue.

Bạn nên hiểu cách tách này vì nó cho biết cần đọc log nào. Email bị kẹt là vấn đề của queue worker, nên docker compose logs -f queue-short là command phù hợp. Trang tải được nhưng badge thông báo không bao giờ cập nhật là vấn đề của websocket. Đọc log của backend trong cả hai trường hợp chỉ làm mất thời gian.

Cài bằng các file compose production, không dùng bản demo

Repository cung cấp pwd.yml, và README nói rất rõ: "Thiết lập này chỉ dành cho việc đánh giá trong thời gian ngắn. Bạn sẽ không thể cài app tùy chỉnh vào thiết lập này." Hãy dùng nó để xem qua ERPNext trong một buổi chiều. Không dùng nó để vận hành công ty.

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.env

Mở ~/gitops/erpnext.env và thay đổi 4 giá trị. ERPNEXT_VERSION cố định image tag. DB_PASSWORD được cung cấp dưới dạng 123 trong file ví dụ. SITES_RULE là rule định tuyến của Traefik, còn LETSENCRYPT_EMAIL nhận các cảnh báo về certificate.

ERPNEXT_VERSION=v16.32.1
DB_PASSWORD=<a long random password>
SITES_RULE=Host(`erp.example.com`)
LETSENCRYPT_EMAIL=ops@example.com

Bây giờ render một file compose, rồi khởi động file đó.

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 -d

config không khởi động gì cả. Lệnh này gộp file base với các file override và in ra kết quả sau khi đã thay thế toàn bộ biến. Sau đó chạy file đã render. Bước bổ sung này rất đáng làm: stack đang chạy nằm trong một file duy nhất mà bạn có thể đọc và commit, nên nội dung không tự thay đổi khi ai đó sửa file env hoặc khi bạn pull repository. cách nhiều file Docker Compose được gộp giải thích chi tiết các rule override.

Chờ db khởi động và configurator thoát, mất vài giây, rồi tạo 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.com

Kiểm tra:

docker compose --project-name erpnext ps
docker compose --project-name erpnext exec backend bench --site erp.example.com list-apps

list-apps phải in ra frappe và erpnext cùng với version tương ứng. ps khỏe mạnh sẽ hiển thị 9 service ở trạng thái running và không có service nào ở trạng thái restarting.

Ở đây thường xảy ra 2 lỗi. --mariadb-user-host-login-scope=% là bắt buộc khi chạy dưới Docker. Container app truy cập MariaDB qua Docker network, nên MariaDB xem kết nối đó là từ một host từ xa. Vì vậy, user database chỉ được cấp quyền cho localhost sẽ không thể đăng nhập từ đó. Khi tạo site, lệnh sẽ fail với lỗi MariaDB access denied và nêu tên user root. Phạm vi % cấp cho user của site mới quyền truy cập từ mọi host trên private network đó.

Lỗi thứ hai là tên site. Mặc định, frontend chọn site cần phục vụ dựa trên HTTP header Host. Vì vậy, site được tạo với tên erpnext sẽ không truy cập được tại erp.example.com, dù cả hai đều tồn tại. Hãy đặt tên site theo domain như trên, hoặc đặt FRAPPE_SITE_NAME_HEADER trong file env thành tên site rồi render lại file compose.

HTTPS và các điều kiện cần có trước khi hoạt động

Override compose.https.yaml chạy Traefik trên cổng 443, chuyển hướng cổng 80 sang đó và yêu cầu chứng chỉ từ Let's Encrypt. TLS (transport layer security) bảo vệ invoice và session cookie để chúng không truyền trên network dưới dạng plain text.

Hai điều kiện phải đúng, nếu không sẽ không có chứng chỉ nào được cấp. Bản ghi DNS A cho erp.example.com phải trỏ đến VPS từ trước. Cổng 80 và 443 phải có thể truy cập từ Internet, vì Let's Encrypt xác minh bạn kiểm soát hostname bằng HTTP-01 challenge trên cổng 80. Hãy kiểm tra network firewall của nhà cung cấp và firewall trên server. Đây là hai lớp kiểm soát riêng biệt, và firewall trong panel là thứ mọi người thường quên.

Chứng chỉ được lưu trong volume cert-data tại /letsencrypt/acme.json. Nếu browser hiển thị chứng chỉ mặc định thay vì chứng chỉ của bạn, hãy tìm tên proxy service trong docker compose --project-name erpnext ps rồi đọc log của service đó để tìm lỗi ACME (automatic certificate management environment). Bạn đang chạy các web app khác trên cùng server? một Traefik instance đứng trước nhiều ứng dụng Docker Compose hướng dẫn cách dùng chung proxy thay vì tranh chấp cổng 443. Ứng dụng thứ hai trên một server như thế này thường phục vụ khách hàng, và một bàn hỗ trợ Chatwoot tự host cũng nằm sau proxy đó, để những người xử lý invoice có thể trả lời email và chat của khách hàng tại cùng một nơi.

Email gửi đi, nếu không thì invoice không bao giờ rời khỏi máy chủ

Đây là bước mà hầu hết hướng dẫn ERPNext bỏ qua, nhưng nó quyết định hệ thống có dùng được hay không. Nếu email gửi đi không hoạt động, không invoice nào đến được khách hàng, không email đặt lại mật khẩu nào được gửi đến và không report theo lịch nào được chuyển đi. Stack này không có mail server.

Không thử gửi mail trực tiếp từ VPS qua port 25. Hầu hết nhà cung cấp chặn outbound port 25 trên các tài khoản mới. Những email gửi được cũng thường bị từ chối hoặc đưa vào spam vì địa chỉ VPS mới chưa có uy tín gửi mail. Hãy dùng authenticated relay qua port 587.

Cách được hỗ trợ là dùng màn hình Email Account trong giao diện ERPNext. Màn hình này lưu password ở dạng encrypted. Bạn cũng có thể ghi các key vào 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 lưu 587 dưới dạng số thay vì chuỗi "587". Đọc lại file và xác nhận hai giá trị đó không có dấu ngoặc kép:

docker compose --project-name erpnext exec backend \
  cat sites/erp.example.com/site_config.json

Thiết lập mail_password qua màn hình Email Account thay vì trên command line. Như vậy giá trị được lưu ở dạng encrypted và không xuất hiện trong shell history.

Sau đó gửi một email thật. Tạo một Sales Invoice, gửi email đến địa chỉ do bạn kiểm soát, rồi theo dõi queue trong lúc thực hiện:

docker compose --project-name erpnext logs -f queue-short

Mail gửi đi là một background job. Vì vậy, email không đến nơi thường xuất hiện dưới dạng failed job trong log này thay vì hiển thị lỗi trên browser. Đồng thời, hãy publish các record SPF (sender policy framework) và DKIM (domainkeys identified mail) cho domain gửi mail, rồi thêm policy DMARC. Nếu thiếu các record này, invoice dù được tạo đúng vẫn có thể rơi vào thư mục spam của khách hàng. Nếu muốn tự quản lý toàn bộ đường đi của email, mail server Mailcow tự host cung cấp một relay do bạn kiểm soát trên một máy riêng với ERP.

Các bản backup có thể restore thực sự

Chỉ dump database không phải là bản backup của ERPNext. Các file đính kèm và file private nằm trong thư mục sites, không nằm trong MariaDB. Nếu chỉ restore database, mọi purchase order đã upload sẽ trở thành link hỏng.

docker compose --project-name erpnext exec backend \
  bench --site erp.example.com backup --with-files

Lệnh này ghi 4 file vào sites/erp.example.com/private/backups trong volume sites:

  • dump -database.sql.gz
  • archive -files.tar của các file public
  • archive -private-files.tar của các file private
  • bản sao -site_config_backup.json của cấu hình site

File thứ 4 thường bị bỏ đi, nhưng lại gây ra vấn đề nghiêm trọng nhất. File này chứa encryption_key, key mà Frappe dùng để mã hóa các password được lưu: thông tin đăng nhập email, key của payment gateway và mọi secret của integration. Nếu restore database mà không có key tương ứng, site vẫn load bình thường, nhưng gửi mail sẽ fail với lỗi:

frappe.exceptions.ValidationError: Encryption key is invalid! Please check site_config.json

Luôn giữ cả 4 file cùng nhau.

Sau đó phải đưa chúng ra khỏi server. Bản backup nằm trong volume sẽ không còn nếu server gặp sự cố, và bench cũng tự xóa chúng: mặc định, bench xóa các backup cũ hơn 24 giờ trong thư mục đó.

docker compose --project-name erpnext cp \
  backend:/home/frappe/frappe-bench/sites/erp.example.com/private/backups \
  ~/erpnext-backups

Chạy lệnh này từ cron, rồi đẩy thư mục đến một nơi bạn không quản trị. backup restic được mã hóa đến storage off-site là công cụ phù hợp, vì nó mã hóa dữ liệu trước khi upload và restic check xác nhận repository vẫn có thể đọc được. Backup ERP là bản sao của toàn bộ sổ cái, nên phải được mã hóa khi lưu trên phần cứng không phải server này.

Kiểm tra bản khôi phục trước khi cần dùng

Backup chưa được kiểm tra chỉ là phỏng đoán. Hãy khôi phục vào site thứ hai trên cùng máy chủ, tuyệt đối không khôi phục vào site đang chạy thật.

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>'

Sao chép encryption key từ file cấu hình đã backup sang site được khôi phục. Nếu không, các integration của site sẽ tiếp tục hỏng:

docker compose --project-name erpnext exec backend \
  bench --site restore-test.example.com set-config encryption_key '<value from site_config_backup.json>'

Bây giờ hãy kiểm tra bản khôi phục như một kế toán. Mở báo cáo Accounts Receivable và so sánh số dư cuối kỳ với site đang chạy thật. Mở một purchase invoice gần đây và tải attachment của invoice đó xuống. Một site chỉ hiển thị được trang đăng nhập thì chưa chứng minh được điều gì.

Xóa site kiểm thử sau khi hoàn tất:

docker compose --project-name erpnext exec backend \
  bench drop-site restore-test.example.com

Vì sao cần pin version chặt hơn với ERPNext

Trên một static site, image tag không được pin nghĩa là service có thể restart ngoài dự kiến. Với ERPNext, điều đó còn có nghĩa là chạy schema migration. bench migrate sẽ viết lại các bảng database và có thể viết lại dữ liệu document, đồng thời không có thao tác undo. Rollback là khôi phục từ backup, không phải docker compose down.

Vì vậy, hãy pin tag. ERPNEXT_VERSION=v16.32.1 là release được pin trong pwd.yml của chính repository vào tháng 8 năm 2026. Không dùng tiếp số version đó mà không kiểm tra. Các release hiện tại được liệt kê trên trang releases của frappe/erpnext, còn các image tag đang tồn tại có trên Docker Hub. Hãy đọc release note của version bạn sắp chuyển sang trước khi thực hiện.

Quá trình upgrade bắt đầu bằng việc tạo backup và bật 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 on

Chỉnh sửa ERPNEXT_VERSION trong ~/gitops/erpnext.env, sau đó render, pull và 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 off

Maintenance mode quan trọng vì migrate thay đổi schema trong khi chạy. Nếu người dùng submit một document vào bảng đang được migrate dở, bạn có thể phải sửa record thủ công.

Mỗi lần chỉ chuyển một major version, và tạo backup giữa từng bước. Code migration trong một release được viết để upgrade từ release ngay trước đó. Vì vậy, bỏ qua major version sẽ chạy các migration theo một tổ hợp chưa được ai kiểm thử.

Repository cũng cung cấp overrides/compose.migrator.yaml, bổ sung một container chạy bench --site all migrate mỗi lần start. Cách này tiện. Nhưng nếu tag thay đổi, docker compose up cũng có thể migrate production database mà không có ai giám sát. Với một hệ thống nghiệp vụ, hãy chạy migrate như một quyết định được bạn chủ động đưa ra trong ngày hôm đó.

Tăng cường bảo mật cho máy chủ lưu trữ hồ sơ khách hàng

Đổi mật khẩu Administrator ngay lần đầu đăng nhập. File compose dùng cho evaluation đặt admin làm mật khẩu đó, và thói quen này có thể theo người dùng vào môi trường production.

Đổi DB_PASSWORD khỏi giá trị 123 trong example.env. Giá trị này xuất hiện dưới dạng plain text trong ~/gitops/erpnext.yaml đã render, vì vậy chmod 600 file đó và không đưa nó vào bất kỳ git repository nào. Nếu cần cách an toàn hơn, overrides/compose.mariadb-secrets.yaml đọc mật khẩu từ Docker secret file thay vì environment variable. xử lý env file và secret trong Docker Compose trình bày các đánh đổi.

Chỉ publish những gì thực sự cần. Với HTTPS override, chỉ có port 80 và 443 được expose. Không thêm mapping ports cho service db chỉ để client database kết nối dễ hơn: việc đó sẽ đưa MariaDB lên public Internet. Thay vào đó, hãy dùng docker compose --project-name erpnext exec backend bench mariadb. Trên host, chỉ allow 22, 80 và 443, deny các port còn lại, đồng thời kiểm tra cả network firewall riêng của provider.

Bật two factor authentication trong System Settings cho mọi account có role System Manager. Role này có thể đọc mọi document và export mọi table, vì vậy hãy xử lý nó như một administrator account thay vì một tiện ích. Nếu bạn chạy nhiều self-hosted app, Authentik làm nhà cung cấp single sign-on tự host sẽ phù hợp hơn so với việc thêm một password cho mỗi app.

Patch host và reboot khi có kernel update. Trước khi tin rằng stack sẽ khởi động lại bình thường, hãy kiểm tra file đã render để bảo đảm mỗi service có policy restart, vì stack không có policy này sẽ vẫn dừng sau lần reboot đó. làm cho Docker Compose stack tự khởi động lại sau reboot trình bày phần cấu hình systemd.

Khi ERPNext không còn hoạt động thoải mái trên một VPS

Một VPS có thể phục vụ một công ty nhỏ trong thời gian dài. Các dấu hiệu cho thấy VPS đã không còn đáp ứng đủ:

  • Các background job bị dồn lại, nên email và dữ liệu import đến chậm vài phút hoặc vài giờ.
  • docker inspect báo cáo các container có "OOMKilled": true hoặc exit code 137.
  • Báo cáo trước đây chạy mất hai giây nay mất ba mươi giây, và MariaDB là tiến trình chiếm CPU.
  • Backup chạy lâu đến mức một lần chồng lên lần chạy theo lịch tiếp theo.

Trước tiên, hãy cấp cho MariaDB các tài nguyên không dùng chung với ứng dụng. Database và các Python worker đang cạnh tranh cùng một vùng memory, trong đó buffer pool là thành phần cần thêm memory nhiều nhất. Tăng cấu hình application server thường không giúp được nhiều như mọi người nghĩ. chạy database trong Docker hoặc trên host giải thích lựa chọn này, còn đặt giới hạn memory trong Docker Compose giúp một container không làm các container khác cạn tài nguyên trong khi bạn xử lý việc này.

Sau đó, hãy thêm queue worker thay vì tăng capacity cho web. Các tác vụ chậm của ERPNext đều là background work, chẳng hạn tạo report và import số lượng lớn. Thêm worker container có chi phí thấp hơn mua một server lớn hơn, đồng thời xử lý đúng vấn đề mà người dùng thực sự phàn nàn.

FAQ

ERPNext cần bao nhiêu RAM trên VPS?

Hướng dẫn được công bố bắt đầu từ 4 GB với 2 vCPU, và cấu hình này chỉ dành cho mục đích đánh giá. Với công ty sử dụng hằng ngày, hãy chuẩn bị 8 GB RAM, 4 vCPU và 100 GB SSD. Nếu thấp hơn, kernel out of memory killer sẽ dừng các container khi có tải. docker inspect báo tình trạng này dưới dạng "OOMKilled": true với exit code 137. Đây chỉ là mức khởi điểm, không phải số đo cố định, nên hãy theo dõi mức sử dụng memory thực tế trong tháng đầu tiên.

Tôi có thể chạy pwd.yml trong production không?

Không. README của project mô tả file này chỉ dành cho việc đánh giá trong thời gian ngắn và lưu ý rằng bạn không thể cài custom app vào đó. Hãy dùng compose.yaml cùng với các override cho MariaDB, Redis và HTTPS, dùng docker compose config để render chúng thành một file duy nhất, rồi chạy file đó.

Vì sao site ERPNext của tôi không truy cập được ngay sau khi tạo?

Theo mặc định, frontend chọn site cần phục vụ dựa trên HTTP Host header, nên tên site phải khớp với domain trong browser. Site được tạo với tên erpnext sẽ không được phục vụ tại erp.example.com. Hãy tạo site với domain làm tên, hoặc đặt FRAPPE_SITE_NAME_HEADER trong env file thành tên site, render lại compose file rồi restart stack.

Một bản backup ERPNext phải có những gì?

Có 4 file và phải giữ cùng nhau: dump -database.sql.gz, các archive -files.tar và -private-files.tar, cùng bản sao cấu hình -site_config_backup.json. Chạy bench --site erp.example.com backup --with-files sẽ tạo đủ cả 4 file. Bản sao cấu hình chứa encryption_key, nên restore mà thiếu file này sẽ khiến các mật khẩu integration đã lưu không thể giải mã. Lỗi này hiển thị dưới dạng Encryption key is invalid! Please check site_config.json.

Làm cách nào để upgrade ERPNext mà không làm hỏng data?

Backup bằng --with-files, bật maintenance mode, thay đổi ERPNEXT_VERSION trong env file, render lại compose file, pull image, khởi động stack, rồi chạy bench --site erp.example.com migrate và tắt maintenance mode. Mỗi lần chỉ nâng cấp 1 major version và đọc release notes trước, vì migrate sẽ ghi lại schema và document data mà không có cách undo. Rollback nghĩa là restore bản backup bạn đã tạo lúc đầu.