Set up a reproducible Frappe development environment with frappe-nix
Use the frappe-nix flake to run a pinned, reproducible Frappe or ERPNext bench with devenv, uv2nix-managed Python, local services and development guard rails.
On this page
This document shows how to stand up a Frappe or ERPNext bench with frappe-nix, a flake-parts module that describes the whole bench declaratively. Use it when you want every developer to get the same Python, Node, MariaDB and Redis, and you want that same description to build production artifacts later. For the reasoning behind the approach, read Why Nix for Business Systems.
What you get
frappe-nix replaces bench init and a hand-built virtualenv. From one uv workspace it provides:
A devenv development shell with MariaDB, Redis, nginx, Mailpit, an asset watcher and the Frappe runtime.
Python environments built by uv2nix from your committed
uv.lock. In the dev shell your apps are installed as editable packages, so source edits reload.A
node_modulestree for every app. The Nix build makes it from each app's ownyarn.lock. The dev shell runs a normal onlineyarn installinstead, as upstream tooling expects.A
builtBenchpackage (nix build) with assets compiled, which the NixOS module and the container images consume later.Development guard rails that stop a bench restored from production from emailing customers or touching production storage.
This document covers the development side. For production, see Building Production Images with frappe-nix and Running Frappe as a NixOS Service.
Prerequisites
You need:
Nix with flakes enabled.
direnv, so the shell loads when you enter the directory. Without it,
nix develop --no-pure-evaldoes the same job by hand.git, since apps are tracked as submodules or as flake inputs.
The generated flake declares the devenv.cachix.org binary cache, so Nix may ask you to trust it the first time. The --no-pure-eval flag appears in every entry point (.envrc and nix develop), so keep it when you load the shell by hand.
Choose a mode
frappe-nix works in two shapes, and the same scaffolding command picks the right one by looking at the target directory. The target is the current directory when it is a bench or a Frappe app, or the directory you name on the command line.
| You have | Mode | What is committed |
|---|---|---|
| A new or empty target directory | New bench | flake.nix, pyproject.toml, uv.lock, sites/apps.txt, sites/apps.json, apps/* as submodules |
An existing bench init bench | Migrate in place | The same files as a new bench, added by the migrator |
| A single Frappe app's repository | App mode | flake.nix, nix/uv.lock, and nix/node-locks/ only if an app ships no yarn.lock |
In a bench, the repository is the uv workspace. In app mode there is no bench to commit: frappe-nix assembles one from flake inputs into .frappe-nix/bench/, which is gitignored and safe to delete.
Create a new bench
Run the scaffolder from a parent directory with no target and it prompts for a directory name. It asks for a Frappe version and a set of apps, writes the wrapper flake, adds frappe and the apps as git submodules on the matching branch, and runs uv lock.
nix run github:Avunu/frappe-nixFor a scripted run, pass everything as flags:
nix run github:Avunu/frappe-nix -- \
--frappe-version version-15 --apps erpnext,hrms --name frappe-bench frappe-benchThe --frappe-version flag chooses a preset that fixes the interpreter versions:
| Preset | Python | Node |
|---|---|---|
develop | python314 | nodejs_24 |
version-16 | python314 | nodejs_24 |
version-15 | python312 | nodejs_20 |
--apps takes bare names (resolved to frappe/<name>), owner/repo, or full git URLs. --site sets the default site name.
Then enter the directory and start the stack:
cd frappe-bench
direnv allow
devenv upMigrate an existing bench
Run the same command from inside a classic bench. Do a dry run first to read the plan.
nix run github:Avunu/frappe-nix -- --dry-run
nix run github:Avunu/frappe-nixThe migrator adds what is missing and never deletes. It stages its work but does not commit, so git diff --cached is your review. Add --commit if you want it committed for you.
Three behaviors are worth knowing before you run it:
Apps with a usable remote become submodules pinned at their current commit. Apps with no usable remote are vendored: the source is committed into the bench and the nested
.gitmoves to.frappe-nix-backup/.A classic
env/virtualenv is moved to.frappe-nix-backup/, because a realenv/directory defeats the dev shell.If a
sites/*/site_config.jsonis already tracked by git, the migrator reports it and prints thegit rm --cachedcommand. That file holds the site's encryption key and database password, so untrack it and rotate the credentials.
Note
If any app ships a package.json but no yarn.lock, run bench-update --node-locks inside the dev shell before your first nix build. The migration does not generate that fallback lock, because it needs the network.
Develop a single app
From inside an existing Frappe app repository, run the same command.
cd <APP_REPO>
nix run github:Avunu/frappe-nix
direnv allow
devenv upIt writes flake.nix, .envrc and a managed .gitignore block, then runs nix run .#relock to create nix/uv.lock. It never edits your app's own pyproject.toml.
The resulting flake pins Frappe as a non-flake input and sets a handful of options:
inputs = {
frappe-nix.url = "github:Avunu/frappe-nix";
nixpkgs.follows = "frappe-nix/nixpkgs";
frappe = { url = "github:frappe/frappe/version-16"; flake = false; };
};
perSystem = _: {
frappe-nix = {
enable = true;
siteName = "site1.localhost";
app = {
enable = true;
frappeVersion = "version-16";
frappe = inputs.frappe;
# siblings = [ { name = "erpnext"; src = inputs.erpnext; } ];
};
};
};Your repository is symlinked into the generated bench, so edits are live. To move a pin, update the input and re-lock:
nix flake update frappe
nix run .#relockrelock is a flake app rather than a shell command on purpose. A missing or stale lock fails at evaluation time, and the dev shell that carries uv is exactly the thing that will not open.
Write the flake by hand
A bench flake is a thin wrapper. Because frappe-nix.lib.mkFlake merges frappe-nix's own inputs into yours, you only declare frappe-nix and a nixpkgs that follows it.
{
inputs = {
# apps/* are git submodules; expose their contents to the flake source tree.
self.submodules = true;
frappe-nix.url = "github:Avunu/frappe-nix";
# flake-parts takes perSystem pkgs from an input literally named nixpkgs.
nixpkgs.follows = "frappe-nix/nixpkgs";
};
outputs =
{ self, frappe-nix, ... }@inputs:
frappe-nix.lib.mkFlake { inherit inputs; } (
{ ... }:
{
imports = [ frappe-nix.flakeModules.default ];
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
perSystem =
{ pkgs, ... }:
{
frappe-nix = {
enable = true;
benchName = "frappe-bench";
siteName = "site1.localhost";
workspaceRoot = ./.;
python = pkgs.python312;
nodejs = pkgs.nodejs_22;
mariadb.initialDatabases = [ { name = "site1_db"; } ];
};
};
}
);
}The .envrc is a single line, use flake . --no-pure-eval. Avunu/frappe-devenv is the reference bench wired up this way.
Options you will touch most
| Option | Default | Purpose |
|---|---|---|
siteName | "" | Sets FRAPPE_SITE. Leave empty for a multi-tenant bench. |
python, nodejs | pkgs.python312, pkgs.nodejs_22 | Interpreters. In app mode the frappeVersion preset chooses them. |
mariadb.initialDatabases | [] | Databases created on first devenv up. |
watch.apps | null | Apps the watcher rebuilds. null skips apps published by Frappe Technologies. [ ] turns the watcher off. |
mariadb.durable | false | Trades crash durability for much faster commits. |
ports.base | null | First port to try. Defaults to 8000 plus a hash of benchName. |
sockets.enable | true | Unix sockets behind one nginx port. Needs Frappe 15.46 or newer. |
runtime.enable | true | One frappe-runtime process instead of split web, socket.io, worker and scheduler processes. |
extraEnv, extraDevPackages | {}, [] | Extra environment variables and packages in the shell. |
The full option list is in the frappe-nix README.
Start the services and create a site
devenv up starts everything through process-compose. Run it in one terminal and create the site from a second terminal in the same directory.
provision-siteprovision-site creates the site named by FRAPPE_SITE and installs every app listed in sites/apps.txt. It sets the Administrator password to admin unless you pass another as the first argument. If the script prompts for a MariaDB root password, leave it blank and press Enter, because the dev MariaDB root has none.
Warning
provision-site runs bench new-site with --force, which drops an existing database for that site. Do not re-run it to pick up a newly added app. See the next section.
Open http://localhost:<PORT>. The port is 8000 plus a hash of the bench name, and the shell banner prints it.
| Process | Listens on |
|---|---|
| nginx | TCP 8000 plus the hash. The only port your browser needs. |
| runtime (web, realtime, jobs, scheduler) | $DEVENV_RUNTIME/web.sock |
| MariaDB | $DEVENV_RUNTIME/mysql.sock only. No TCP listener. |
| Redis (cache and queue) | $DEVENV_RUNTIME/redis.sock |
| Mailpit | TCP 19000 (SMTP), 20000 (web UI), 21000 (POP3), each plus the hash |
| watch | none |
Because sockets and hashed ports are per bench, you can run several benches at the same time without collisions. The hash comes from the bench name, not the path, so every clone of a bench gets the same ports.
With the default sockets.enable = true, MariaDB starts with skip-networking, so nothing listens on TCP for it. Frappe itself uses the socket and is unaffected. A tool that opens its own connection to the site config's db_host and db_port is refused at the bench's own port number, instead of reaching another bench's database on 3306. If you set sockets.enable = false, every service goes back to TCP and MariaDB listens on loopback only, on a per-bench port that starts at 3306 plus the hash.
Apps added later
A site's installed apps live in its database, and nothing installs a newly added app for you in plain Frappe. frappe-nix closes that gap: when siteName is set, a frappe:apps-reconcile task installs any app from sites/apps.txt that the site lacks on every devenv up. In a multi-tenant bench, run reconcile-apps <SITE_NAME> yourself.
Everyday commands
The shell puts a bench wrapper first on your PATH. Most commands behave as you expect, and a few are redirected to frappe-nix equivalents that understand submodules and the read-only Nix store.
| You run | What happens |
|---|---|
bench update | Runs bench-update: pull submodules, re-lock what moved, migrate, build. Accepts --pull, --migrate, --build or --node-locks, not the stock --reset. |
bench build | Brings node_modules in step with the apps, then builds. |
bench get-app <URL_OR_ALIAS> | Adds a git submodule and registers it in the uv workspace. |
bench new-app | Scaffolds a local app and registers it. |
bench remove-app | Removes the submodule and its workspace entries. |
bench restore | Restores a database dump, or fetches the latest backup when you configured backup access. |
bench migrate, bench console, bench clear-cache | Run against $FRAPPE_SITE. |
bench setup requirements | Verifies node_modules, the yarn cache and the Python lock. --check changes nothing and exits 1 if something is wrong. |
Everything else, such as bench serve or bench install-app, goes to the real bench. Interception is subcommand-first, so bench --site <SITE_NAME> migrate passes straight through.
In app mode, bench-update --pull, bench-get-app and bench-remove-app refuse and point you at the flake equivalents: nix flake update, a new flake input, or editing frappe-nix.app.siblings.
Dependencies and locks
Two lock files carry the dev-to-production contract, and you commit both.
uv.lock(ornix/uv.lockin app mode) drives the Python environment. When an app gains a dependency, runuv lockin the shell.bench-update --pulldoes it for you when apyproject.tomlmoved.Each app's own
yarn.lockdrives itsnode_modulesin the Nix build. An app that ships none gets a generated fallback innode-locks/<APP_NAME>/yarn.lock, created bybench-update --node-locks.
A stale uv.lock shows up as an evaluation error such as attribute 'json-repair' missing. frappe-nix audits the lock first and names the app and the fix. If the shell will not open, run nix run .#relock from outside it.
To pick up a newer frappe-nix, update the pin and reload:
nix flake update frappe-nix
direnv reloadIf the new version needs something added to your workspace root, entering the shell adds it and runs uv lock for you. When it changes files, commit pyproject.toml and uv.lock, then re-enter the shell.
Development guard rails
A bench restored from a production backup carries working production credentials. Left alone, it can send real email, delete files from a storage bucket, call payment or calendar integrations and push backups over production's. The devguard options stop that. They are on by default, and devguard.enable = false turns them all off.
| Guard | What it stops |
|---|---|
| All outgoing mail. SMTP goes to Mailpit, and IMAP and POP3 are refused unless you enable Mailpit's POP3. | |
| backups | Dropbox, S3, Google Drive and Frappe Cloud backup uploads. |
| objectstore | Deleting from or overwriting objects in the configured S3 bucket. |
| integrations | Outbound HTTP through frappe.integrations.utils.make_request, except loopback and hosts you allow. |
| Google Calendar, Contacts and Drive access. | |
| webhooks | Outbound Webhook requests. |
| plaid | Plaid bank synchronization. |
| scheduler | Scheduled jobs that reach production services. |
Mail is caught by Mailpit. Open its web UI at the address printed in the shell banner to see what your site tried to send. Local backups, restores and the Backups page keep working, since only egress is blocked.
You can switch guards off for one command without rebuilding:
FRAPPE_DEVGUARD_DISABLE=backups,google bench console
FRAPPE_DEVGUARD_ENABLED=0 bench consoleWarning
Only the mail guard works at the transport level. The others patch Frappe and app APIs, so an app nobody has reviewed can still find a way out. Treat the guard rails as a large reduction in risk, not as an airgap.
Restoring production data also carries the production encryption key, which can decrypt every stored credential in the dump. bench restore refuses to write that key into a bench with devguard.enable = false.
Troubleshooting
The shell will not open and the error mentions a missing attribute. The lock is stale. Run
nix run .#relock.A
node_moduleserror from a Vite config several apps deep. Runbench setup requirements --node. Shell entry already deletes known-damaged installs, and this runs the scan on demand.Memory pressure while the watcher runs. The default watcher skips apps published by Frappe Technologies. After editing one of those yourself, run
bench build --app <APP_NAME>.A site page for a new app returns 404. The app is importable but not installed on the site. Check
reconcile-apps.
Note
This document was written from a reading of the frappe-nix sources and README, not from a fresh install on a clean machine. Treat exact output as indicative and check the repository for changes since October 2026.
Related documents
Building Production Images with frappe-nix, for the immutable build and OCI images made from the same flake.
Running Frappe as a NixOS Service, for deploying a built bench with the
services.frappemodule.Bench Operations, for backup, restore and branch-switching procedures on a classic bench.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.