The Ultimate Guide to Running Frappe/ERPNext Side-by-Side with Docker — From Quick Start to Production-Ready Devcontainer

 —  Waliullah Thebo





Frappe Docker Setup ERPNext v15 & v16 Multi-Tenancy & Production-Ready Devcontainer

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.
Docker Hub Public image registry pull / push images Docker Host Your machine or server Docker Daemon Builds images, starts and stops containers Image Read-only template Container A Container B Container C docker CLI Commands you type

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.
User request Docker Host frappe_docker compose stack frontend (Nginx) Serves static assets, routes requests backend (Werkzeug) Dynamic content processing websocket (Socket.IO) Real-time updates db MariaDB redis-cache Site caching scheduler Runs scheduled tasks redis-queue Background job queue queue-short / long RQ background workers configurator Sets DB/Redis config, exits

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 & v16 folders, 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.

  1. 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).
  2. 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.
  3. Launch Docker Desktop and wait for the whale icon in the menu bar / system tray to show “Docker is running.”
  4. 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.
  5. 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.

  1. 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
  2. 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
  3. 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
  4. Verify the install with the test container:
    sudo docker run hello-world
  5. Optional but recommended: run Docker without typing sudo every time, by adding your user to the docker group, then logging out and back in:
    sudo usermod -aG docker $USER
  6. Confirm Compose is available (it now ships as a plugin, so the command is docker compose, not the old standalone docker-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 the v15 and v16 project folders. Inside each, the actual Frappe bench — apps/ and sites/ — 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 run bench commands, while your browser talks to the bench’s own web server on its own port.

Your Mac / PC v15 folder v16 folder VS Code (Dev Containers) Browser (localhost:8000/8001) Docker Engine v15 devcontainer (port 8000) bench: web · watch · socketio MariaDB · Redis v16 devcontainer (port 8001) bench: web · watch · socketio MariaDB · Redis attaches to container talks to bench web server

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.

  1. Create your main project directory and version folders:
    mkdir ~/Documents/erpnext_projects
    cd ~/Documents/erpnext_projects
    mkdir v15 v16
  2. Clone the repo into each folder:
    Navigate into v15 and clone the official repo:

    cd v15
    git clone https://github.com/frappe/frappe_docker.git .

    (Repeat for the v16 folder).

  3. Set Your Versions and Ports:
    By default, the pwd.yml file might have a hardcoded version like v16.28.0.

    • In the v15 folder: Open pwd.yml, find all instances of the version, and change them to v15.
    • In the v16 folder: Open pwd.yml, leave the version as v16, but scroll down to the frontend service and change the ports from "8080:8080" to "8081:8080" to avoid conflicts.
  4. Launch the Containers:
    Inside each folder, run:

    docker compose -f pwd.yml up -d

    Docker will pull the images, start the database, and automatically create your first site (named frontend by default).

Phase 3: Initial Setup & Browser Access

Once the containers are up, the create-site container is busy setting up your database.

  1. Open your browser and go to http://localhost:8080 (for v15) or http://localhost:8081 (for v16).
  2. Log in using administrator and admin.
  3. 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:

  1. Install the Dev Containers and Docker extensions in VS Code.
  2. Press Cmd + Shift + P (or Ctrl + Shift + P), type Dev Containers: Attach to Running Container..., and select your backend container.
  3. VS Code will open a new window. Go to File > Open Folder and type /home/frappe/frappe-bench.
  4. 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!)

  1. Open the VS Code integrated terminal (which is inside the container).
  2. Download the app for your specific version:
    bench get-app https://github.com/frappe/payments.git --branch version-15
  3. Install it on your site:
    bench --site frontend install-app payments

Gotcha #1: The Missing App Error
Sometimes, bench get-app fails to register the app in sites/apps.txt. If you get a “Module not found” error, manually add it:

echo "payments" >> sites/apps.txt

Then run bench --site frontend migrate to update the database.

Visualizing the Architecture & Asset Flow
When you run bench 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:

  1. Clear the Redis Cache:
    docker compose -f pwd.yml exec redis-cache redis-cli flushall
  2. Build with Hard Links: (This physically copies the files instead of making shortcuts!)
    docker compose -f pwd.yml exec backend bench build --hard-link
  3. 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.

sites/ volume — SHARED across all containers site config, database credentials, apps.txt (rewritten from each container’s own local apps on restart) backend local apps/ + assets/ frappe, erpnext + payments ✓ (installed here manually) frontend local apps/ + assets/ frappe, erpnext no payments ✕ → CSS/JS 404 scheduler local apps/ + assets/ frappe, erpnext no payments ✕ → ModuleNotFoundError create-site rewrites apps.txt from its own local apps/ on restart Result on next docker compose up payments silently dropped from apps.txt, assets stay broken — not a bug, this is how immutable production images are meant to work

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 
  1. Install the Dev Containers extension in VS Code if you haven’t already.
  2. Command Palette (Cmd+Shift+P) → “Dev Containers: Reopen in Container”.
  3. 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
Your Mac’s disk frappe-bench/apps/ frappe-bench/sites/ bind mount Devcontainer one bench process web watch (esbuild) socketio Browser localhost:8000 bench get-app / install-app writes directly to the bind-mounted folder — survives any restart

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.

Tab 1: site1.localhost:8000 Tab 2: site2.localhost:8000 Shared Bench Installed once: Frappe, ERPNext, Payments Routes by site name in the request Site 1 database Fully separate data Site 2 database Fully separate data

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. One docker buildx build command reads the whole file and bakes frappe + erpnext (from the base) + payments + hrms + sindh_education into one image, under the one tag you gave it. You never build once per app; you build once per version of the app list.

apps.json apps to include docker buildx build layered Containerfile thebonext-erpnext :v15-sindh Same tag used by backend, frontend, websocket, queue-short, queue-long, scheduler, create-site

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!