Build production images with frappe-nix
How frappe-nix builds an immutable Frappe bench from each app's yarn.lock, which OCI images it produces, and how to build, load, push and run them.
On this page
This document explains how frappe-nix turns a Frappe bench into an immutable build, which container images come out of it, and how to build and run them. Use it when you want a Frappe deployment where nothing is installed or compiled at container start.
How the immutable bench is built
frappe-nix builds one package, builtBench, and every image is assembled from it. The package contains the apps, the production Python environment, Node, and the compiled assets. nix build .#default builds the same thing.
Python
The Python environment comes from uv.lock through uv2nix. Commit uv.lock and the build reads it. Nothing runs uv sync at build or start time.
node_modules from each yarn.lock
Every app with a package.json is a node target. So is each immediate subdirectory of an app that has its own package.json, such as a nested frontend. Each target gets its own node_modules, reproduced from its yarn.lock:
frappe-nix reads the lockfile at evaluation time and creates one
fetchurlper tarball, using the URL andintegritythe lock already records (or the#sha1suffix in older locks). Git dependencies are fetched by commit.The tarballs are linked into an offline mirror.
nixpkgs'
yarnConfigHookrunsyarn install --offline --frozen-lockfile --ignore-scripts --ignore-engines --ignore-platformagainst that mirror.
You commit no hashes. The derivation sees only package.json, yarn.lock, .yarnrc and .npmrc, so editing other files in an app does not rebuild its node_modules.
Lifecycle scripts do not run in the sandbox. The frontends frappe-nix was built against need only prebuilt or platform-specific packages, so this works for them. If an app does need extra build steps, nodeOverrides.<TARGET> lets you add attributes such as postPatch or nativeBuildInputs to that target's derivation.
Which lockfile wins
An app's own yarn.lock always wins. If an app ships a package.json but no yarn.lock, generate a fallback from the bench's dev shell and commit it:
bench-update --node-locksThat writes node-locks/<APP>/yarn.lock in a bench (nix/node-locks/ in app mode, written by nix run .#relock). Evaluation warns when a fallback is older than its package.json, and when one is unused because the app now ships its own lock.
Evaluation also warns about a target with no lock at all. The package then has no node_modules for it, and builtBench fails on any such app that has a build script.
Repairing a lock that does not resolve
Sometimes an upstream yarn.lock does not cover its own package.json. The offline install then fails with a message like this:
Couldn't find any versions for "<PACKAGE>" that matches "<RANGE>" in our cacheYou have two options:
Force a repaired fallback over the upstream lock with
bench-update --node-locks <APP>/<SUBDIR>. The generated lock keeps upstream's pins and fills only the gap.Leave the frontend out with
nodeNestedFrontendExcludes = [ "<APP>/<SUBDIR>" ];. frappe-nix then also removes the parent'sbuildandpostinstallscripts that drive that frontend.
Compiling assets
builtBench copies the unbuilt bench tree into the build directory and runs bench build --production inside the Nix sandbox. It sets ESBUILD_TARGET from the esbuildTarget option, which defaults to es2022, and NODE_OPTIONS=--max-old-space-size=4096. The finished tree, including nested frontend output and rewritten asset links, becomes the package.
Because the environment, node_modules and assets are all fixed at build time, a given set of locks and app commits produces the same bench every time.
The OCI images
Enable the images in your bench flake:
frappe-nix = {
enable = true;
benchName = "<BENCH_NAME>";
containers.enable = true;
};Each image is built with dockerTools.buildLayeredImage, tagged latest, and named <benchName>/<name>. The unified runtime is on by default, which gives you three images.
| Package | Image | Contents |
|---|---|---|
runtime | <BENCH_NAME>/runtime:latest | One frappe-runtime process: web, realtime, background jobs and scheduler. Listens on 8000. |
nginx | <BENCH_NAME>/nginx:latest | nginx plus the built bench. Listens on 80. |
bench-cli | <BENCH_NAME>/bench:latest | The bench command for migrations and one-off tasks. |
Note that the package is bench-cli but the image is named bench.
The split image set
Set runtime.enable = false and frappe-nix builds the classic split instead. You get eight packages instead of three:
| Package | Image | Process |
|---|---|---|
web | <BENCH_NAME>/web:latest | gunicorn on port 8000 |
scheduler | <BENCH_NAME>/scheduler:latest | bench schedule |
worker-default | <BENCH_NAME>/worker-default:latest | bench worker --queue default |
worker-short | <BENCH_NAME>/worker-short:latest | bench worker --queue short |
worker-long | <BENCH_NAME>/worker-long:latest | bench worker --queue long |
socketio | <BENCH_NAME>/socketio:latest | Node realtime server on port 9000 |
nginx | <BENCH_NAME>/nginx:latest | nginx on port 80 |
bench-cli | <BENCH_NAME>/bench:latest | bench |
Note
The "Production containers" section of the frappe-nix README still describes web as the main image. With the default runtime.enable = true, web does not exist, so build runtime instead.
Building, loading and pushing
Build an image from your bench repository:
nix build .#runtime
docker load < resultresult is an image tarball, and docker load registers it as <BENCH_NAME>/runtime:latest. Repeat with .#nginx and .#bench-cli for the other two.
frappe-nix declares a containers.registry option but does not use it to push anything. Pushing is up to you, using ordinary container tooling:
docker tag <BENCH_NAME>/runtime:latest registry.example.com/<BENCH_NAME>/runtime:latest
docker push registry.example.com/<BENCH_NAME>/runtime:latestRunning the images
Every image except nginx and socketio starts through the same entrypoint. It builds the site's configuration from environment variables and mounted secrets, then runs the image's command.
Volumes
Mount two paths:
/bench/sitesis a persistent volume for site state./secretsholds secret files, mounted read-only.
At every start the entrypoint links apps.txt, apps.json and the compiled assets from the image into /bench/sites, so a new image always brings its own app registry and assets. common_site_config.json is copied in once, and from then on it belongs to you.
Environment variables
FRAPPE_SITE is required. Without it the container exits immediately. The rest have defaults you should usually override:
| Variable | Default | Becomes |
|---|---|---|
FRAPPE_DB_HOST | 127.0.0.1 | db_host |
FRAPPE_DB_PORT | 3306 | db_port |
FRAPPE_DB_TYPE | mariadb | db_type |
FRAPPE_DB_NAME | site name, dots replaced by underscores | db_name |
FRAPPE_DB_USER | same as FRAPPE_DB_NAME's default | db_user |
FRAPPE_REDIS_CACHE | redis://127.0.0.1:13000 | redis_cache |
FRAPPE_REDIS_QUEUE | redis://127.0.0.1:13000 | redis_queue |
FRAPPE_REDIS_SOCKETIO | redis://127.0.0.1:13000 | redis_socketio |
FRAPPE_DB_SOCKET | unset | db_socket, when set |
FRAPPE_SOCKETIO_UDS | unset | socketio_uds, when set |
Secrets
The entrypoint merges these files into site_config.json, in this order:
/secrets/db_passwordbecomesdb_password./secrets/encryption_keybecomesencryption_key.Every
/secrets/*.jsonis deep-merged over what is already there, which is the place for object-storage credentials and similar settings.
It writes the result with mode 0600. Secrets never enter the image or the Nix store.
A single-host example
This starts the runtime image against a MariaDB and Redis you already have:
docker run -d --name <BENCH_NAME>-runtime \
-p 8000:8000 \
-e FRAPPE_SITE=site1.example.com \
-e FRAPPE_DB_HOST=<DB_HOST> \
-e FRAPPE_REDIS_CACHE=redis://<REDIS_HOST>:6379 \
-e FRAPPE_REDIS_QUEUE=redis://<REDIS_HOST>:6379 \
-e FRAPPE_REDIS_SOCKETIO=redis://<REDIS_HOST>:6379 \
-v <BENCH_NAME>-sites:/bench/sites \
-v <SECRETS_DIR>:/secrets:ro \
<BENCH_NAME>/runtime:latestThe default command is frappe-runtime --host 0.0.0.0 --port 8000 --job-threads 4, with /bench as the working directory. The runtime is not started with --dev, the flag that makes it serve assets itself, so put nginx in front of it for /assets.
One-off commands
Run bench commands from the bench-cli image with the same variables and volumes:
docker run --rm \
-e FRAPPE_SITE=site1.example.com \
-e FRAPPE_DB_HOST=<DB_HOST> \
-v <BENCH_NAME>-sites:/bench/sites \
-v <SECRETS_DIR>:/secrets:ro \
<BENCH_NAME>/bench:latest bench --site site1.example.com migrateThe nginx image
The nginx image runs nginx -c /bench/config/nginx.conf -g "daemon off;". frappe-nix does not generate that file. The build copies your bench's config/ directory into the image, so you supply config/nginx.conf yourself.
Warning
The .gitignore that frappe-nix scaffolds excludes config/*.conf. A flake only sees tracked files, so add your file with git add -f config/nginx.conf or it never reaches the image.
What the images do not do
The entrypoint writes
site_config.jsononly. It does not create the site's database, so create or restore the site yourself.frappe-nix ships no compose file or orchestration for these images.
To run Frappe directly on a NixOS host without containers, see Running Frappe as a NixOS Service. It consumes the same
builtBenchpackage.
Note
This document is derived from the frappe-nix source (modules/containers.nix, lib/bench.nix, lib/yarn-lock.nix) and its README. The run examples have not been executed against a freshly built image, so check them against your own build.
Related documents
Frappe Development Environment with frappe-nix, for creating the bench flake these images are built from.
Running Frappe as a NixOS Service, for running the same
builtBenchdirectly on a NixOS host without containers.Reference Architecture for Self-Hosting Business Systems, for a hosting pattern that runs apps in per-site containers.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.