Scaffolding
Migrate an existing bench
Convert a classic bench init bench, or a half-converted one, into a frappe-nix repository in place, without deleting anything.
On this page
The same entry point converts a classic bench init bench, or a half-converted one, into a frappe-nix repository. It detects the mode from the directory, so from inside a bench you run the same command as for a new one. Do a dry run first to read the plan.
cd ~/frappe-bench
nix run github:Avunu/frappe-nix -- --dry-run # inspect the plan first
nix run github:Avunu/frappe-nix # then migrateIt is a reconciler, not a converter. It probes what the bench already has, adds only what is missing, repairs drift and never deletes. Running it on an already-migrated bench is a no-op.
Nothing is committed. The result is staged, so git diff --cached is your review. Add --commit to commit it.
The migrator supports version-15 and newer. Older apps ship a setup.py with no pyproject.toml, so they cannot be uv workspace members, and the Python and Node pins differ. Upgrade the bench first with bench switch-to-branch version-15, or pass --frappe-version version-15 to proceed anyway.
What it does
Concretely, it:
Detects the Frappe version from the branch, then the bench's
sites/apps.json, thenfrappe.__version__, and pins Python and Node from the matching preset.Initializes git at the bench root if needed, and registers each app under
apps/as a git submodule pinned at its current commit. Nothing is fast-forwarded. It records the app's actual branch in.gitmodules, without whichbench-update --pullsilently skips the app, and adds anoriginalias when the app only hasupstream.Vendors apps with no usable remote. The nested
.gitmoves to.frappe-nix-backup/with a provenance JSON, and the source is committed into the bench. An untracked nested repository is invisible to the flake and would vanish from the build.Writes the project files:
flake.nix,pyproject.toml,.envrcanduv.lock. It merges into an existingpyproject.tomlrather than replacing it, and shims apyproject.tomlfor vendored apps that only shipsetup.py.Regenerates
sites/apps.txtandsites/apps.jsonfrom the workspace members it just registered. See Apps in a bench. An app that could not become a member is onPYTHONPATHbut not registered, and the report says so.Reconciles
sites/common_site_config.json. It forces the per-bench web and socketio port that the dev shell derives from the bench name and preserves everything else. It drops production-only keys (host_name,http_port,restart_*) and keys the socket setup supersedes (db_host,db_port, theredis_*URLs,file_watcher_port). It blanksmariadb_root_password, since the file is about to be committed.Extends
.gitignorewith a managed block sosites/*/site_config.json, siteprivate/andpublic/data,Procfile,patches.txt,config/*.confandnode_modulesstay out of git. It then verifies withgit check-ignorethat nothing the build needs got excluded.Moves a classic
env/virtualenv to.frappe-nix-backup/. A realenv/directory silently defeats the dev shell'sln -sfnand leavesbenchon the stale interpreter.
The migrator reports a tracked sites/*/site_config.json. That file holds the site's encryption key, database password and object-storage credentials. The managed .gitignore block excludes the path, but git keeps honoring an index entry regardless, so adding the rule changes nothing until the file is untracked. The migrator never deletes, so it prints the git rm --cached command and leaves the decision to you. Treat every credential the file has held as disclosed: the values are in the repository's history, not only its worktree, so untracking protects the next commit and nothing before it. Rotate them.
Flags
| Flag | Effect |
|---|---|
--dry-run | Print the full plan (per-app disposition, warnings) and exit. |
-y, --yes | Skip the confirmation. Required to migrate without a terminal. |
--frappe-version <V> | Override version detection. |
--migrate, --init | Force the mode instead of detecting it. |
--vendor <A,B> | Vendor these apps even though they have a remote. |
--no-vendor | Abort instead of vendoring an app with no usable remote. |
--allow-file-remotes | Accept filesystem paths as submodule URLs. This breaks other clones. |
--legacy-apps <POLICY> | shim, skip or abort, for apps with only a setup.py. A skipped app is not a workspace member, so it is not in sites/apps.txt. |
--strict | Treat dirty or unpushed apps as errors. |
--keep-db-root-password | Do not blank mariadb_root_password. |
--commit[=<MESSAGE>] | Commit the migration instead of only staging it. |
--absorb-gitdirs | Run git submodule absorbgitdirs after registering the apps. |
--site <SITE> | Default site. Needed without a terminal when the bench has several sites and no default_site. |
The complete list is in Scaffolder reference.
After the migration
Review what was staged:
git diff --cached --stat.If any app ships a
package.jsonwithout ayarn.lock, runbench-update --node-locksinside the dev shell before the firstnix build. The fallback lock is not generated by the migration, because it needs the network and yarn. Evaluation tells you which apps, if any.Enter the shell with
direnv allowand start the stack withdevenv up. If the bench has no site yet,provision-sitecreates one. If you declare secrets first,bench restorecan clone the site from the latest production backup instead.
What it cannot fix
An app pinned at a commit that is on no remote branch builds on your machine and nowhere else.
An app whose remote is unreachable will fail on a fresh clone of the bench.
Both are reported as warnings, and --strict turns the first into an error.
A setup.py-only app that is a submodule cannot be shimmed, because a generated pyproject.toml would sit outside the pinned commit. Vendor it with --vendor <APP>, or fix it upstream.
The bench root must be its own git repository. A bench nested inside another repository is reported, and --force is needed to create a nested repository anyway.