The setup
We run ERPNext for internal accounting and contractor management. The original deployment was a frappe/erpnext Docker image on version 13, backed by a Docker Compose file that has been running quietly since 2021. It worked. It kept working. We didn’t touch it — the classic self-hosted production story.
Then two things happened:
- Frappe split HR out of ERPNext. In v14, the HR and Payroll modules migrated to a separate app called HRMS. In v15, payment gateway integrations (Stripe, GoCardless, and friends) moved out too, into a Payments app. Upgrading means installing two more apps, not just bumping a version tag.
- Our production host is ARM. We’re deploying to an ARM64 virtual machine (an OCI Ampere instance running Ubuntu). The official
frappe/erpnextimage ships frappe + erpnext only — no HRMS, no Payments. And we develop on AMD64. So whatever we build needs to run on both architectures.
Here’s the thing nobody tells you about Frappe version upgrades: the Docker image is not a version number, it’s a curated app bundle. When your target state is “ERPNext 15 + HRMS 15 + Payments + all links intact,” the stock image is the wrong shape. You have to build your own.
This post covers how we got there, including the part where bench decided to become interactive during a Docker build.
What’s in an ERPNext image, anyway
A Frappe “app” is a Python package plus a JS/CSS build output. The Docker image bakes the code of each app into /home/frappe/frappe-bench/apps/. At runtime, a site “installs” an app by wiring its DocTypes into the database.
The official image build process (from frappe/frappe_docker) accepts an apps.json file:
[
{ "url": "https://github.com/frappe/frappe", "branch": "version-15" },
{ "url": "github.com/frappe/erpnext", "branch": "version-15" },
{ "url": "github.com/frappe/hrms", "branch": "version-15" },
{ "url": "github.com/frappe/payments","branch": "version-15" }
]
…which is base64-encoded and passed as a build arg. That part is documented and works. The official docs even show how to build a custom image with bench init --apps_path. Great, copy their Containerfile, pass our JSON, done.
Except.
War story #1: bench init wants a human
The upstream pattern for the builder stage is roughly:
FROM frappe/build:version-15 AS builder
ARG APPS_JSON_BASE64
RUN echo ${APPS_JSON_BASE64} | base64 -d > apps.json
RUN bench init /home/frappe/frappe-bench \
--frappe-branch=version-15 \
--apps_path=apps.json
For version-15 this works. For version-14, bench init prompts:
Do you want to continue? [y/N]
I never captured the exact prompt text, because it doesn’t matter — the point is what it does. A click.confirm() call in the v14 bench CLI asks for confirmation when installing apps via --apps_path. In an interactive terminal you type y and move on with your life. During a docker buildx build, there is no stdin, click gets an EOF, and the build dies partway through bench init with a traceback pointing at the internals of click/termui.py.
This is the kind of failure that wastes an afternoon, because:
- The error surfaces deep in build logs, after several minutes of pip installs.
- Retrying with
--no-cacheproduces the identical failure. - The v15 build (which we ran first, to establish a baseline) sailed through, so the Containerfile looked correct.
Our workaround: don’t use --apps_path at all. Initialize with frappe only, then loop over the remaining apps with bench get-app, which does not prompt in either version:
RUN bench init \
--frappe-branch=${FRAPPE_BRANCH} \
--no-procfile \
--no-backups \
--skip-redis-config-generation \
/home/frappe/frappe-bench && \
cd /home/frappe/frappe-bench && \
EXTRA_APPS=$(python3 -c "import json; apps=json.load(open('/tmp/apps.json')); \
[print(a['url']+' '+a['branch']) for a in apps[1:]]") && \
echo "$EXTRA_APPS" | while read url branch; do \
bench get-app --branch "$branch" --skip-assets "$url"; \
done
The apps[1:] slice skips the first entry — frappe itself is installed by bench init, and bench get-app on frappe again would duplicate it. This is exactly the kind of thing that’s obvious in hindsight and invisible at 1 AM.
The full scripts/Containerfile ends up at ~60 lines, borrowing heavily from frappe_docker’s structure (we clone frappe_docker as the build context to reuse its resources/ entrypoint scripts) but replacing the init step. Base image in, apps loop, copy to the runtime stage, done.
War story #2: the multi-arch tax
The build script wraps docker buildx build:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg FRAPPE_BRANCH=version-15 \
--build-arg APPS_JSON_BASE64="$(base64 -w 0 apps.json)" \
--tag myorg/frappe-erpnext:v15-hrms \
--push .
To cross-build ARM64 on an AMD64 dev box you need QEMU user emulation registered in the kernel:
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap
And then you pay the tax: the bench get-app loop runs inside emulation, so every pip install of a native wheel (and Frappe’s dependency tree has several, including mysqlclient) compiles under QEMU. Our v15 build: ~12 minutes native, ~35 minutes with the arm64 leg sharing the same builder. Disk usage from build caches ballooned past 20GB before we started pruning.
Two things that keep multi-arch Frappe builds sane:
- Build one platform per invocation when iterating.
--platform linux/amd64for the fast feedback loop; add arm64 only when you’re ready to ship. The build cache is per-platform, so interleaving them thrashes it. - Base64 the apps list, don’t COPY it. Passing
APPS_JSON_BASE64as a build arg means the build context stays clean and you can swap app sets without touching the Containerfile. (Yes,--build-argvalues are visible in image history; this list contains no secrets.)
The payoff
Two tags, one script:
frappe-erpnext:v14-hrms 1.39GB frappe v14 + erpnext v14 + payments + hrms
frappe-erpnext:v15-hrms 2.44GB frappe v15 + erpnext v15 + payments + hrms
Why v14? Because you can’t skip major versions — the v13 database has to pass through v14’s migrations before v15 will touch it. That’s the subject of part 2.
In the next post: the migration itself — a staging “workbench” that reuses production volumes, the is_virtual patch failure that required --skip-failing, and why v15’s migrate deleted a pile of orphaned DocTypes on purpose.