Introduction:
Want to run Frappe v15 and v16 on the same machine without losing your mind over version conflicts? This guide starts from zero — what Docker actually is, why Frappe needs several containers instead of one — then walks through two complete setups: the quick-start pwd.yml method most tutorials show you first, the notorious broken CSS/JS asset problem it runs into the moment you install a custom app, and the devcontainer-based setup that fixes that problem at the root instead of patching around it. By the end you’ll be able to install apps like payments or hrms whenever you need to, add more apps months later, run multi-tenant sites, authenticate to private GitHub repos safely, and build a proper production image when it’s time to ship.
Let’s dive in!
Part 1: What Docker Actually Is
Docker packages an application together with everything it needs to run — code, runtime, system libraries, configuration — into a single unit called a container. Unlike a virtual machine, a container doesn’t boot its own operating system; it shares the host machine’s kernel. That’s why containers start in seconds and use a fraction of the memory a VM needs.
Four terms cover almost everything you need to know to get started:
- Image — a read-only template for a container. Think of it as a class in programming; you can create many containers from one image.
- Container — a running instance of an image. This is where your app actually executes.
- Docker Engine (daemon) — the background service on your machine that builds images and starts/stops containers.
- Docker Hub (registry) — a public repository where images are stored and shared, similar to how GitHub hosts code.
Fig 1. The Docker CLI talks to the daemon, which pulls images from Docker Hub and runs containers from them.
Part 2: Why Frappe/ERPNext Needs Several Containers, Not One
Frappe doesn’t run as a single process. It’s a web server, a background job processor, a scheduler, a real-time messaging layer, and a database — each with different resource needs and different scaling requirements. Docker lets each of these run in its own isolated, restartable container, wired together by Docker Compose. The official frappe_docker project uses this exact multi-service architecture.
The core services you’ll see in every Frappe Docker setup:
- configurator — a one-shot service that writes database and Redis connection settings on startup, then exits.
- backend — the Werkzeug application server that processes dynamic requests (the actual Frappe/Python code).
- frontend — an Nginx reverse proxy that serves static assets (CSS/JS/images) directly and forwards everything else to the backend.
- websocket — a Node.js Socket.IO server for real-time updates (live notifications, document locks, etc.).
- queue-short / queue-long — Python workers (RQ) that process background jobs like emails, reports, and imports.
- scheduler — runs Frappe’s scheduled/cron-style tasks.
- db — MariaDB (or PostgreSQL), added via a compose override.
- redis-cache / redis-queue — Redis instances for caching and job queues, also added via overrides.
Fig 2. How the Frappe/ERPNext Docker services talk to each other — the blue chain handles requests, the gold chain handles background jobs.
With that mental model in place, let’s get hands-on. We’ll start with the fastest way to get v15 and v16 running side-by-side — the quick-start pwd.yml method — see exactly where and why it breaks the moment you install a custom app, and then move to the setup that avoids the problem entirely.
Part 3: Quick Start — Running v15 & v16 with pwd.yml
Visualizing the Setup
Before we start, it’s important to understand how your Mac/PC (Host) interacts with Docker. When we clone the repository, we only download instructions (pwd.yml). The actual Frappe code lives inside the containers. VS Code attaches directly to the container to let us edit those files.
- Your Mac/PC: Contains the
v15&v16folders, VS Code, and your Browser.- Docker Engine: Runs the isolated containers (Backend, Frontend, DB) for both versions separately.
- Connection: VS Code attaches directly to the Backend container to edit code, while your Browser talks to the Frontend container.
Phase 1: Installing Docker & Docker Compose
Before any of the folder setup below will work, you need Docker Engine and Docker Compose actually running on your machine. How you install them depends on your OS — Mac and Windows use one app, Linux distributions like Ubuntu use the package manager.
Option A — macOS or Windows (Docker Desktop):
Docker Desktop is the simplest route on both platforms and already bundles Docker Compose — no separate install needed.
- Download the installer from
docker.com/products/docker-desktop(choose the Mac or Windows build; on Mac pick Apple Silicon or Intel to match your chip). - Run the installer and follow the prompts. On Windows, make sure WSL 2 is enabled when asked — Docker Desktop will offer to enable it for you if it isn’t already.
- Launch Docker Desktop and wait for the whale icon in the menu bar / system tray to show “Docker is running.”
- On Apple Silicon, go to Settings → General and confirm VirtioFS is selected as the file sharing implementation — it’s noticeably faster than the older gRPC-FUSE option for bind-mounted projects like this one.
- Open a terminal and confirm both tools are available:
docker --version docker compose version
Option B — Ubuntu / Debian-based Linux (Docker Engine + Compose plugin):
On Linux servers or Ubuntu desktops, install Docker Engine directly from Docker’s official APT repository rather than the older docker.io package that ships with Ubuntu, since it’s usually out of date.
- Remove any old/conflicting versions first:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt remove -y $pkg done - Set up Docker’s official APT repository:
sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update - Install Docker Engine, the CLI, containerd, and the Compose/Buildx plugins in one go:
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - Verify the install with the test container:
sudo docker run hello-world - Optional but recommended: run Docker without typing
sudoevery time, by adding your user to thedockergroup, then logging out and back in:sudo usermod -aG docker $USER - Confirm Compose is available (it now ships as a plugin, so the command is
docker compose, not the old standalonedocker-compose):docker compose version
Once docker --version and docker compose version both return a version number without errors, you’re ready to move on to the actual Frappe/ERPNext setup below.
Visualizing the Setup
Your Mac holds thev15andv16project folders. Inside each, the actual Frappe bench —apps/andsites/— lives as a real folder on your Mac’s disk, bind-mounted into a dev container. VS Code attaches directly to that container to edit code and runbenchcommands, while your browser talks to the bench’s own web server on its own port.
Fig 4. Two fully isolated devcontainer stacks run side-by-side; VS Code attaches to each container, your browser talks to each bench’s web server on its own port.
Phase 2: The Foundation (Folders & Configuration)
To run two versions smoothly, isolation is key. We will create separate folders for each version.
- Create your main project directory and version folders:
mkdir ~/Documents/erpnext_projects cd ~/Documents/erpnext_projects mkdir v15 v16 - Clone the repo into each folder:
Navigate intov15and clone the official repo:cd v15 git clone https://github.com/frappe/frappe_docker.git .(Repeat for the
v16folder). - Set Your Versions and Ports:
By default, thepwd.ymlfile might have a hardcoded version likev16.28.0.- In the
v15folder: Openpwd.yml, find all instances of the version, and change them tov15. - In the
v16folder: Openpwd.yml, leave the version asv16, but scroll down to thefrontendservice and change the ports from"8080:8080"to"8081:8080"to avoid conflicts.
- In the
- Launch the Containers:
Inside each folder, run:docker compose -f pwd.yml up -dDocker will pull the images, start the database, and automatically create your first site (named
frontendby default).
Phase 3: Initial Setup & Browser Access
Once the containers are up, the create-site container is busy setting up your database.
- Open your browser and go to
http://localhost:8080(for v15) orhttp://localhost:8081(for v16). - Log in using
administratorandadmin. - Complete the initial ERPNext setup wizard (Language, Country, Company, etc.).
Phase 4: Developer Tools & Basic Docker Commands
To develop efficiently, you need to know how to manage your containers and access the code.
Essential Docker Commands (Run these from your Mac/PC terminal in the v15 or v16 folder):
docker compose -f pwd.yml ps-> Check which containers are running.docker compose -f pwd.yml logs -f backend-> Watch live error logs.docker compose -f pwd.yml restart frontend-> Restart the Nginx web server.docker compose -f pwd.yml exec backend bash-> Enter the backend container’s terminal.
Setting up VS Code for Container Development:
- Install the Dev Containers and Docker extensions in VS Code.
- Press
Cmd + Shift + P(orCtrl + Shift + P), typeDev Containers: Attach to Running Container..., and select yourbackendcontainer. - VS Code will open a new window. Go to File > Open Folder and type
/home/frappe/frappe-bench. - Boom! You now have full VS Code access to your Frappe backend code, complete with an integrated terminal.
Phase 5: Installing Custom Apps (And the “Gotchas”)
Let’s install the payments app. (Note: If you are on v15, you MUST specify the branch, or you will get a Python version error!)
- Open the VS Code integrated terminal (which is inside the container).
- Download the app for your specific version:
bench get-app https://github.com/frappe/payments.git --branch version-15 - Install it on your site:
bench --site frontend install-app payments
Gotcha #1: The Missing App Error
Sometimes,bench get-appfails to register the app insites/apps.txt. If you get a “Module not found” error, manually add it:echo "payments" >> sites/apps.txtThen run
bench --site frontend migrateto update the database.
Visualizing the Architecture & Asset Flow
When you runbench build, the backend creates CSS files. But your browser asks the Frontend (Nginx) for those files. Because they are separate containers, the Frontend container throws a 404 error because it doesn’t have the app code natively.
- Browser ➔ Requests Page ➔ Frontend (Nginx)
- Frontend ➔ Serves Static Files (CSS/JS) ➔ Browser
- Frontend ➔ Forwards API Requests ➔ Backend (Python)
- Backend ↔ Talks to Database & Redis Cache
Phase 6: The “Magic Bullet” Fix for Broken Assets (CSS/JS)
After installing an app and running bench build, you will likely refresh your browser and find the UI completely broken—no styling, no buttons.
Why? The backend created shortcut files (symlinks) to the new app’s assets, but the frontend (Nginx) container doesn’t have the app code, so it throws 404 errors.
The Fix:
Run these three commands from your Mac/PC terminal in your version folder:
- Clear the Redis Cache:
docker compose -f pwd.yml exec redis-cache redis-cli flushall - Build with Hard Links: (This physically copies the files instead of making shortcuts!)
docker compose -f pwd.yml exec backend bench build --hard-link - Restart the Frontend:
docker compose -f pwd.yml restart frontend
Do a hard refresh in your browser (Cmd + Shift + R), and your UI will be perfect!
This quick-start method gets you running fast, and the hard-link fix above will get your assets working again this time. But it’s worth understanding why it broke in the first place — because the same problem comes back every time you add a new app, and on your next docker compose up, it can silently undo itself. That’s what the next section digs into. (Multi-tenancy — adding a second site — is covered once, later in this guide, under the devcontainer setup, so you’re not doing it twice.)
Part 4: Why Assets Break on Prebuilt-Image Setups (And How to Avoid It)
Before diving into the better setup, it’s worth understanding one architectural fact that saves you hours of confusion later. The quick-start compose file (pwd.yml) and standard production images ship with frappe and erpnext already baked into every container at image build time. Across the whole stack, only the sites/ folder — site config, database credentials, uploaded files — is shared between containers via a Docker volume. The apps/ folder and the assets/ folder are not shared. Every service — backend, frontend, scheduler, queue-short, queue-long, websocket, create-site — has its own local copy of those folders, baked in from the image.
If you exec into just the backend container and run bench get-app payments followed by bench --site frontend install-app payments, you’ve only updated backend‘s local filesystem. frontend never receives the new app’s CSS/JS, so it 404s. scheduler never receives it either, and crashes with ModuleNotFoundError the next time it loads the site’s module map. Worse, the next time your containers restart, the create-site service re-derives sites/apps.txt from its own local, image-baked app list — silently dropping any app you added by hand, even though it looked like it had “stuck.”
Frappe’s own documentation is explicit that this isn’t something to patch around — it’s how these images are designed to behave: production images come pre-packaged with built assets, and running bench build or installing apps live inside a running production container isn’t a supported workflow.
Fig 3. Only the sites/ volume is shared — apps/ and assets/ are local to each container, so manually installing an app into one service never reaches the others.
Part 5: Two Setups, Two Purposes
The fix isn’t a workaround inside the broken model — it’s picking the right setup for what you’re actually doing:
| Devcontainer (Development) | Custom Image (Production) | |
|---|---|---|
| Where app code lives | Bind-mounted from your Mac | Baked into the image at build time |
| Install a new app | bench get-app + install-app, done |
Requires rebuilding the image |
| CSS/JS changes | Live, via bench start watch process |
Requires rebuild |
| Survives container recreation | Yes — it’s your files on disk | Yes, but only what was baked in |
| Best for | Active development, adding apps often | Shipping a finished build to a client server |
You’ll use the devcontainer setup day-to-day on your Mac, and only go through the custom-image build step once you’re ready to deploy something to a live server.
Part 6: Hands-On — Devcontainer Setup for v15 & v16 Side-by-Side
Phase 7: The Foundation (Folders)
Same starting point as the quick-start method in Part 3 — separate folders keep the two versions isolated. If you already created v15 and v16 folders and cloned frappe_docker into each back in Part 3, Phase 2, reuse those folders and skip straight to Phase 8 below. Starting fresh, it’s the same two steps:
mkdir -p ~/Documents/erpnext_projects
cd ~/Documents/erpnext_projects
mkdir v15 v16
cd v15 && git clone https://github.com/frappe/frappe_docker.git . && cd ..
cd v16 && git clone https://github.com/frappe/frappe_docker.git . && cd ..
Phase 8: Set Up the Devcontainer in Each Folder
Inside each version folder:
cp -R devcontainer-example .devcontainer
Open the folder in VS Code:
code v15
open -a "Visual Studio Code" ~/Documents/erpnext_projects/v15
- Install the Dev Containers extension in VS Code if you haven’t already.
- Command Palette (
Cmd+Shift+P) → “Dev Containers: Reopen in Container”. - Wait for the first build (~5 minutes) — this pulls the dev image and starts MariaDB, Redis, and the bench container.
Because each version folder has its own .devcontainer, VS Code opens each as a fully separate stack — same isolation as before, just a different underlying mechanism.
Avoiding port collisions: open .devcontainer/docker-compose.yml in the v16 folder and change the forwarded port for the web process from 8000 to 8001, so both benches can run at the same time.
Phase 9: Create Your Bench and Site
Inside the VS Code integrated terminal (running inside the container):
cd /workspace/development
python installer.py
Follow the prompts — site name (e.g. development.localhost), whether to install ERPNext, and the version branch (version-15 for the v15 folder, version-16 for the v16 folder).
Start the bench:
cd frappe-bench
cd /workspace/development/frappe-bench
bench start
Visit http://development.localhost:8000/desk (or 8001 for v16). Log in with Administrator and the admin password you set.
Visit http://localhost:8000 (or 8001 for v16). Log in with Administrator and the admin password you set.
Phase 10: Installing Apps — Whenever You Need To
This is the part that used to break everything. In this setup it doesn’t, because your frappe-bench/apps/ folder is a real folder on your Mac’s disk, bind-mounted into the container. Installing payments, hrms, or your own custom apps like thebo_erpnext_theme or sindh_education is a normal bench workflow — no separate frontend container that misses the update, no assets volume to keep in sync.
# Payments app
bench get-app payments --branch version-15
bench --site development.localhost install-app payments
# HRMS
bench get-app hrms --branch version-15
bench --site development.localhost install-app hrms
# Your own custom app
bench get-app https://github.com/thebonext/thebo_erpnext_theme.git
bench --site development.localhost install-app thebo_erpnext_theme
No --hard-link, no manually editing apps.txt, no Redis flush ritual, no restarting a separate frontend container. If CSS/JS ever looks off after installing an app, a plain rebuild against that one app is enough, because it writes straight into the same folder that’s already being served:
bench build --app thebo_erpnext_theme
Fig 5. One bench process, one set of files, bind-mounted from your Mac — installing an app has nowhere else to drift to.
Restarting later: if you close VS Code or your Mac reboots, just reopen the folder and hit “Reopen in Container” again — frappe-bench/apps/ and frappe-bench/sites/ are untouched, because they were never inside the container’s own ephemeral filesystem to begin with.
Phase 11: Multi-Tenancy (Adding a Second Site)
To add another site to the same bench (e.g. site2.localhost):
bench new-site site2.localhost --admin-password admin
bench --site site2.localhost install-app erpnext
Update your Mac’s /etc/hosts to map site2.localhost to 127.0.0.1, then visit http://site2.localhost:8000.
Fig 6. Multi-tenancy in one Bench: shared code and apps, completely separate data per site.
Phase 12: When You Actually Need a Production Image
The devcontainer setup above is for local development. When you’re ready to deploy a finished build — say, sindh_education with payments and hrms — to a client’s server, that’s the point where everything gets packaged into an immutable image, using the layered build process:
cat > apps.json << 'EOF'
[
{ "url": "https://github.com/frappe/payments.git", "branch": "version-15" },
{ "url": "https://github.com/frappe/hrms.git", "branch": "version-15" },
{ "url": "https://github.com/thebonext/sindh_education.git", "branch": "main" }
]
EOF
docker buildx build \
--platform=linux/amd64 \
--secret id=apps_json,src=apps.json \
--build-arg FRAPPE_BRANCH=version-15 \
--tag=thebonext-erpnext:v15-sindh \
--file=images/layered/Containerfile .
Note the --secret id=apps_json,src=apps.json flag instead of the older --build-arg APPS_JSON_BASE64=... approach you may see in some tutorials. This matters if any of your repos are private (more on that in Phase 14) — Docker permanently records build-arg values in the image's layer metadata, so a token passed that way stays readable by anyone who later pulls the image. BuildKit secrets are mounted only for the build step that needs them and never get written into a layer. This requires Docker Engine 23+, which ships BuildKit as the default builder.
Then reference thebonext-erpnext:v15-sindh as the image: for every service in your production compose file — backend, frontend, websocket, queue-short, queue-long, scheduler, configurator, create-site — so all of them start from an identical, fully baked image with no drift between them.
One
apps.json, one image, every app in it. The array above isn't a list of separate builds — it's a single manifest. Onedocker buildx buildcommand reads the whole file and bakesfrappe+erpnext(from the base) +payments+hrms+sindh_educationinto one image, under the one tag you gave it. You never build once per app; you build once per version of the app list.
Fig 7. One image, one tag, used identically by every service — the same guarantee of consistency that a devcontainer gets from a single bench process.
Whenever you add or update an app for that deployment, you bump the tag and rebuild — a deliberate, versioned step, not something that happens by accident mid-development.
Phase 13: Installing Another App Later — What Actually Changes
Six months from now, when you need to add another app, the process depends on which of the two setups you're touching — it isn't a one-size-fits-all answer.
In the devcontainer (day-to-day dev work): nothing changes about the process at all. Run the same two commands you'd run any other day:
bench get-app <app-name-or-url> --branch <branch>
bench --site development.localhost install-app <app-name>
No rebuild, no compose edits, no downtime. This is the entire point of keeping your bench on real disk instead of inside an image.
In a production custom image (after you've deployed to a client server): add the new app to your existing apps.json, rebuild under a new tag, then point your compose file's services at the new tag and recreate:
# Add the new entry to apps.json alongside the existing ones
docker buildx build \
--platform=linux/amd64 \
--secret id=apps_json,src=apps.json \
--build-arg FRAPPE_BRANCH=version-15 \
--tag=thebonext-erpnext:v15-sindh-2 \
--file=images/layered/Containerfile .
# Update every service's image: line to v15-sindh-2, then:
docker compose up -d
Versioning the tag (-1, -2, or a date) rather than reusing the same one is worth the small extra discipline — if a new app breaks something, rolling back is just pointing the compose file at the previous tag.
Phase 14: Working with Private GitHub Repositories
Some apps — your own client work like sindh_education, or paid third-party apps — live in private repos. Authenticating to them looks different depending on where the clone happens.
Devcontainer — cloning a private repo with bench get-app: the simplest route is a fine-grained Personal Access Token (scoped to only the repo you need, not your whole account), embedded in the HTTPS URL:
bench get-app https://<YOUR_GITHUB_USERNAME>:<YOUR_PAT>@github.com/thebonext/private-repo.git
Since this runs inside your own container on your own Mac, exposure is limited — just avoid committing that URL anywhere. If you'd rather not type a token per clone, set up a credential helper once and Git will cache it after the first prompt:
git config --global credential.helper store
An SSH deploy key mounted into the container works just as well if you prefer SSH over HTTPS tokens.
Production image build — private repos in apps.json: use the same PAT-in-URL format inside the file, but the file itself must go in via --secret, never --build-arg — this is exactly the fix already applied to the Phase 12 command above:
cat > apps.json << 'EOF'
[
{ "url": "https://github.com/frappe/payments.git", "branch": "version-15" },
{ "url": "https://YOUR_USERNAME:YOUR_PAT@github.com/thebonext/sindh_education.git", "branch": "main" }
]
EOF
docker buildx build \
--secret id=apps_json,src=apps.json \
--build-arg FRAPPE_BRANCH=version-15 \
--tag=thebonext-erpnext:v15-sindh \
--file=images/layered/Containerfile .
Two habits worth locking in here: add apps.json to .gitignore immediately so a token never ends up committed, and use a fine-grained token scoped to only the repos you're building with — not a classic PAT with full account access.
Conclusion:
The quick-start pwd.yml method is the fastest way to get ERPNext v15 and v16 running side-by-side, and the hard-link fix will get a broken UI working again in a pinch. But the broken-CSS problem that trips up almost every new Frappe Docker developer isn't a quirky bug to patch around each time — it's a design boundary. Prebuilt images assume apps are fixed at build time, and don't share apps/ or assets/ across containers. The lasting fix is matching your setup to what you're actually doing: a devcontainer with a single bench on real disk for active development, where installing an app is just installing an app, and a versioned custom image only for the moment you're actually shipping something to a client server. Happy coding!