Skip to content
Skip to the article
In NixOS: 13 articles
NixOS

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.

Updated
Applies to
  • frappe-nix (main branch
  • October 2026)
  • Frappe develop
  • Frappe version-16
  • Frappe version-15
Tags
  • nixos
  • frappe
  • devenv
  • bench
Reading time
12 min

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_modules tree for every app. The Nix build makes it from each app's own yarn.lock. The dev shell runs a normal online yarn install instead, as upstream tooling expects.

  • A builtBench package (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-eval does 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 haveModeWhat is committed
A new or empty target directoryNew benchflake.nix, pyproject.toml, uv.lock, sites/apps.txt, sites/apps.json, apps/* as submodules
An existing bench init benchMigrate in placeThe same files as a new bench, added by the migrator
A single Frappe app's repositoryApp modeflake.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-nix

For a scripted run, pass everything as flags:

nix run github:Avunu/frappe-nix -- \
  --frappe-version version-15 --apps erpnext,hrms --name frappe-bench frappe-bench

The --frappe-version flag chooses a preset that fixes the interpreter versions:

PresetPythonNode
developpython314nodejs_24
version-16python314nodejs_24
version-15python312nodejs_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 up

Migrate 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-nix

The 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 .git moves to .frappe-nix-backup/.

  • A classic env/ virtualenv is moved to .frappe-nix-backup/, because a real env/ directory defeats the dev shell.

  • If a sites/*/site_config.json is already tracked by git, the migrator reports it and prints the git rm --cached command. 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 up

It 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 .#relock

relock 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

OptionDefaultPurpose
siteName""Sets FRAPPE_SITE. Leave empty for a multi-tenant bench.
python, nodejspkgs.python312, pkgs.nodejs_22Interpreters. In app mode the frappeVersion preset chooses them.
mariadb.initialDatabases[]Databases created on first devenv up.
watch.appsnullApps the watcher rebuilds. null skips apps published by Frappe Technologies. [ ] turns the watcher off.
mariadb.durablefalseTrades crash durability for much faster commits.
ports.basenullFirst port to try. Defaults to 8000 plus a hash of benchName.
sockets.enabletrueUnix sockets behind one nginx port. Needs Frappe 15.46 or newer.
runtime.enabletrueOne 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-site

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

ProcessListens on
nginxTCP 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
MailpitTCP 19000 (SMTP), 20000 (web UI), 21000 (POP3), each plus the hash
watchnone

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 runWhat happens
bench updateRuns bench-update: pull submodules, re-lock what moved, migrate, build. Accepts --pull, --migrate, --build or --node-locks, not the stock --reset.
bench buildBrings 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-appScaffolds a local app and registers it.
bench remove-appRemoves the submodule and its workspace entries.
bench restoreRestores a database dump, or fetches the latest backup when you configured backup access.
bench migrate, bench console, bench clear-cacheRun against $FRAPPE_SITE.
bench setup requirementsVerifies 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 (or nix/uv.lock in app mode) drives the Python environment. When an app gains a dependency, run uv lock in the shell. bench-update --pull does it for you when a pyproject.toml moved.

  • Each app's own yarn.lock drives its node_modules in the Nix build. An app that ships none gets a generated fallback in node-locks/<APP_NAME>/yarn.lock, created by bench-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 reload

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

GuardWhat it stops
mailAll outgoing mail. SMTP goes to Mailpit, and IMAP and POP3 are refused unless you enable Mailpit's POP3.
backupsDropbox, S3, Google Drive and Frappe Cloud backup uploads.
objectstoreDeleting from or overwriting objects in the configured S3 bucket.
integrationsOutbound HTTP through frappe.integrations.utils.make_request, except loopback and hosts you allow.
googleGoogle Calendar, Contacts and Drive access.
webhooksOutbound Webhook requests.
plaidPlaid bank synchronization.
schedulerScheduled 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 console

Warning

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_modules error from a Vite config several apps deep. Run bench 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.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.