Run Frappe as a NixOS service
Deploy one or more Frappe sites on NixOS with the frappe-nix services.frappe module, including secrets, MariaDB, Redis, nginx, safe migrations and logs.
On this page
This document shows how to run a pre-built Frappe bench on a NixOS host with the services.frappe module from frappe-nix. Use it when you want declarative, multi-tenant Frappe or ERPNext sites with systemd units, automatic migrations and journald logging. You build the bench package as described in Frappe Development Environment with frappe-nix.
How the module works
The module takes one input: a bench package, the builtBench that your bench repository exposes as packages.default. Python, Node, the apps and the compiled assets all come from that package, so the module has no options for interpreters or bench paths.
Each enabled site gets its own systemd units and its own state directory. A new build of the bench package changes the store path, which restarts the units and triggers a migration.
Note
The bench repository exposes only the package. You import the NixOS module from frappe-nix itself, in your host configuration, next to the bench package.
Import the module and declare a site
Add both flakes as inputs to the flake that defines your host, then import the module and point package at the bench build.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
frappe-nix.url = "github:Avunu/frappe-nix";
bench.url = "github:example/frappe-bench";
};
outputs = { nixpkgs, frappe-nix, bench, ... }: {
nixosConfigurations.<HOST_NAME> = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
frappe-nix.nixosModules.default
./frappe.nix
];
specialArgs = { inherit bench; };
};
};
}The module itself goes in frappe.nix. This example keeps secrets as file paths, here from agenix, but any path readable by root works.
{ config, bench, ... }:
{
services.frappe = {
enable = true;
package = bench.packages.x86_64-linux.default;
database.createLocally = true;
redis.createLocally = true;
sites."site1.example.com" = {
enable = true;
database.createLocally = true;
database.passwordFile = config.age.secrets.db-password.path;
encryptionKeyFile = config.age.secrets.encryption-key.path;
nginx.enable = true;
};
};
}Each key under sites is the site name, which is also its domain. Run nixos-rebuild switch to apply it.
Options you will actually use
Option names below were checked against modules/nixos.nix.
Top level (services.frappe)
| Option | Default | Purpose |
|---|---|---|
package | none, required | Default bench package. Sites inherit it. |
user, group | frappe | Service account. The module creates it only when the name is frappe. |
runtime.enable | true | One frappe-runtime process per site, instead of separate web, realtime, worker and scheduler units. |
workers | default, short, long | Background queues. Passed to the runtime as --queue. |
database.createLocally | false | Enable a local MariaDB. Also switched on if any site sets it. |
database.package | pkgs.mariadb | MariaDB package. |
redis.createLocally | false | Run a local Redis. |
redis.port | 13000 | Port for that Redis. |
extraEnv | {} | Extra environment variables for every Frappe unit. |
extraPath | [] | Extra packages on the units' PATH. |
Per site (services.frappe.sites.<NAME>)
| Option | Default | Purpose |
|---|---|---|
enable | false | Turn the site on. |
package | null | Use a different bench build for this site. |
siteDir | /var/lib/frappe/<NAME> | State directory. |
web.port | 8000 | TCP port, ignored when web.socketPath is set. |
web.socketPath | "" | Unix socket instead of a port. |
database.* | see below | Database connection. |
redis.cacheUrl, redis.queueUrl, redis.socketioUrl | redis://127.0.0.1:13000 | Redis URLs. |
encryptionKeyFile | null | File holding the Frappe encryption key. |
extraConfig | {} | Non-secret keys merged into site_config.json. |
extraConfigFiles | [] | JSON files merged into site_config.json, for secrets. |
nginx.enable | false | Create an nginx virtual host. |
nginx.socketPath | "" | Also serve the virtual host on a unix socket. |
With the default unified runtime, socketio.port and socketio.socketPath do nothing, and the module warns if you set a socket path. They apply only when runtime.enable = false.
Run more than one site
Every site needs its own listener. Give each additional site a distinct web.port, or use a socket path.
services.frappe.sites."site2.example.com" = {
enable = true;
package = bench.packages.x86_64-linux.default;
web.port = 8001;
database.createLocally = true;
database.passwordFile = config.age.secrets.site2-db-password.path;
encryptionKeyFile = config.age.secrets.site2-encryption-key.path;
nginx.enable = true;
};Setting package on a site lets you run, for example, a staging site on a newer bench build than production. If you leave runtime.enable = false, also give each site its own socketio.port.
Handle secrets
The module never puts secrets in the Nix store. On every start, the frappe-init-<SITE_NAME> unit builds site_config.json in three layers:
A base file generated from your Nix options (database and Redis settings, plus
extraConfig).The database password and encryption key, read from
database.passwordFileandencryptionKeyFile.Each file in
extraConfigFiles, deep-merged last, so it can override earlier values.
systemd's LoadCredential hands the secret files to the unit, so the service account never needs read access to the originals. The result is written to <siteDir>/sites/<SITE_NAME>/site_config.json with mode 0600.
Use extraConfigFiles for anything else sensitive, such as object-storage credentials. The file must contain a JSON object.
{
"example_integration_key": "<SECRET_VALUE>"
}Warning
site_config.json is regenerated on every start of the init unit. A hand edit does not survive the next restart. Put non-secret settings in extraConfig and secrets in extraConfigFiles. sites/common_site_config.json is different: the module copies it from the package once, if the bench ships one, and never touches it again.
Configure the database and Redis
Local MariaDB
With database.createLocally = true on a site, the module enables MariaDB, creates the database and user, and sets the server to utf8mb4 with the utf8mb4_unicode_ci collation. The database and user names default to the site name with dots and hyphens replaced by underscores. Override them with database.name and database.user.
NixOS creates database users without passwords, so the module runs a small frappe-db-password-<SITE_NAME> unit that sets the password from database.passwordFile. Always set passwordFile when you use a local database.
By default Frappe connects over the socket at /run/mysqld/mysqld.sock.
External MariaDB
Leave createLocally off and describe the server yourself. Setting socket to an empty string disables the socket and makes Frappe use host and port.
database = {
host = "db.example.com";
port = 3306;
socket = "";
name = "site1_example_com";
user = "site1_example_com";
passwordFile = config.age.secrets.db-password.path;
};You manage the account and its password on an external server. The module does not.
Redis
redis.createLocally = true runs a Redis instance named frappe on 127.0.0.1, port 13000, under the unit redis-frappe. All three site Redis URLs default to that address. If you change services.frappe.redis.port, update the site URLs too, because the defaults are fixed strings and do not follow the port.
Put nginx in front
nginx.enable = true creates a virtual host named after the site. It proxies the app and /socket.io to the site's process, serves /assets/ with a one-year cache header, and serves public uploads from /files/. Markup files (.htm, .html, .svg, .xml) under /files/ are forced to download, so an uploaded page cannot run script on your site's origin. Private files are handed back to nginx by Frappe through /protected/.
The module does not set up TLS or open firewall ports. Add those with standard NixOS options on the same virtual host name.
services.nginx.virtualHosts."site1.example.com" = {
enableACME = true;
forceSSL = true;
};
security.acme = {
acceptTerms = true;
defaults.email = "admin@example.com";
};
networking.firewall.allowedTCPPorts = [ 80 443 ];Warning
The runtime and gunicorn bind 0.0.0.0 when you use web.port. Either leave the port closed in the firewall, or use web.socketPath so the app has no TCP listener at all.
Unix sockets behind a tunnel
If a reverse proxy or tunnel connector runs on the same host and terminates TLS, set web.socketPath and nginx.socketPath to paths inside their own directories, for example /run/frappe-site1/web.sock.
The module refuses paths directly in /run or /, and it creates the directory owned by the service user. That directory is the access control, because nginx makes its own socket world-writable. In this mode nginx takes the client address from the CF-Connecting-IP header, so use it only behind a connector that sets that header. nginx.socketPath requires nginx.enable.
Create or restore the site
The module deploys code and configuration. It does not install a site. On the first deploy the database exists but has no tables, so the migrate unit logs that the site is not installed and exits cleanly. Other sites on the host are unaffected.
The module installs a bench wrapper on the system PATH. With exactly one enabled site it selects that site automatically. With several, set FRAPPE_SITE yourself. For example, once the site exists, this runs its migrations by hand:
sudo -u frappe env FRAPPE_SITE=site1.example.com bench migrateRun the wrapper as the service user so the files it creates stay owned by frappe. For restore, migrate, console and clear-cache, it passes --site for you.
To load an existing database, restore an SQL backup.
sudo -u frappe env FRAPPE_SITE=site1.example.com bench restore <SQL_FILE_PATH>To create a brand-new site, run bench new-site on the host, as the module's own migrate message instructs. The exact new-site flags for this layout are not verified here, so follow the Frappe bench documentation. After either route, restart the migrate unit so it brings the new database up to date. A plain nixos-rebuild switch does not re-run it, because systemd only restarts the unit when its definition changes with a new build.
sudo systemctl restart frappe-migrate-site1.example.com.serviceThe migrate unit records a build marker only after a successful migration, and it skips an uninstalled site without recording one, so restarting it after the install migrates the site even if the build has not changed.
Upgrade and migrate safely
To upgrade apps, update the bench input and rebuild.
nix flake update bench
sudo nixos-rebuild switch --flake .#<HOST_NAME>For each site the frappe-migrate-<SITE_NAME> unit then does the following:
Skips everything if the build is the same as the last successful migration.
Takes a gzipped
mysqldumpto<siteDir>/snapshots/premigrate-<SITE_NAME>-<TIMESTAMP>.sql.gz, readable only by its owner. If the snapshot fails, it aborts before migrating.Turns on maintenance mode, then runs
bench migrate.On success, turns maintenance mode off, records the build and prunes old snapshots.
On failure, drops every table, re-imports the snapshot, and leaves the site in maintenance mode.
Frappe migrations change the schema, and MariaDB cannot roll those changes back in a transaction. The snapshot is the real safety net. The unit runs as the service user with the site's own database credentials, so it needs no database root access.
Writes that land between the snapshot and the moment maintenance mode turns on are not in the snapshot. The site's runtime unit is ordered after the migrate unit but does not require it, so a failed migration does not stop the site from starting. Maintenance mode is what keeps visitors off the old schema.
If a migration fails, read the error, fix the problem, and deploy again.
journalctl -u frappe-migrate-site1.example.com -p errYou can also return to the previous system generation with nixos-rebuild switch --rollback.
Tune or disable migration
| Option | Default | Effect |
|---|---|---|
migrate.enable | true | Run bench migrate on each new build. |
migrate.snapshot | true | Take the pre-migrate dump. |
migrate.rollbackOnFailure | true | Restore the dump on failure. Needs snapshot. |
migrate.maintenanceMode | true | Use maintenance mode around the migration. |
migrate.snapshotRetention | 3 | Snapshots kept per site. |
Tip
A snapshot of a very large database on every deploy is expensive in time and disk. Weigh that against losing the safety net before you set migrate.snapshot = false. If you set migrate.enable = false, run bench migrate yourself after each deploy.
Tune the runtime
The unified runtime exposes its limits as options. The defaults are listed here.
| Option | Default | Meaning |
|---|---|---|
runtime.jobThreads | 4 | Concurrent background jobs. |
runtime.webThreads | 0 | Concurrent requests. 0 keeps the runtime's own default. Size the database pool against it. |
runtime.restartAfterRequests | 5000 | Graceful restart after this many requests. 0 disables it. |
runtime.restartAfterJobs | 500 | Graceful restart after this many jobs. 0 disables it. |
runtime.restartIdleSeconds | 300 | Graceful restart after this much idle time. 0 disables it. |
runtime.requestDrainSeconds | 60 | How long a stop waits for in-flight requests. |
runtime.jobDrainSeconds | 600 | How long a stop waits for a running job. |
runtime.extraArgs | [] | Extra arguments for the frappe-runtime command. |
The unit's stop timeout is derived as both drain values plus 30 seconds, 690 seconds with the defaults. A restart during a deploy can therefore wait that long for a running job.
To go back to separate units, set runtime.enable = false. You then get frappe-web-<SITE_NAME> (gunicorn), frappe-scheduler-<SITE_NAME>, frappe-socketio-<SITE_NAME> and one frappe-worker-<QUEUE>-<SITE_NAME> per queue. In that mode web.workers (default 4) sets the number of gunicorn workers.
Read the logs
Everything goes to the journal. The module writes no Frappe, bench or nginx log files, apart from two Frappe features that write their own: the request monitor and the setup wizard log.
With the unified runtime, the site's unit is frappe-<SITE_NAME>.
journalctl -u frappe-site1.example.com -f
journalctl -u frappe-init-site1.example.comEvery unit also carries journald fields, so you can filter across units. APP_SERVICE is one of runtime, web, worker, scheduler, socketio, migrate, init, db, redis or nginx. APP_SITE is set on units that belong to one site.
journalctl APP_SERVICE=migrate APP_SITE=site1.example.com
journalctl APP_SERVICE=runtime -p warningPython log lines carry a syslog priority, so -p filters work as you would expect. services.frappe.logging.level sets the Frappe logger threshold. It accepts debug, info, warning and error, and defaults to warning.
nginx writes its access log to the journal as one JSON object per request. Read it with:
journalctl SYSLOG_IDENTIFIER=nginx_access -o catEach entry has time, site, method, uri, status, bytes, request_time, upstream_time, remote_addr, user_agent and referer. upstream_time is a string, because it is - for requests that never reach the app, such as /assets/.
Note
logging.accessLog is set for nginx as a whole, not per site. Turning it off disables access logging for every virtual host on the machine.
Where things live
| Path | Contents |
|---|---|
/var/lib/frappe/<SITE_NAME>/sites | The site's sites directory, including site_config.json and uploaded files. |
/var/lib/frappe/<SITE_NAME>/bench | The runtime bench tree, linked to the store package. |
/var/lib/frappe/<SITE_NAME>/snapshots | Pre-migrate database dumps. |
Back up the site directory and the database. The service PATH already includes git, gzip, tar, bash and the MariaDB client tools that Frappe's own backup code needs. Add anything else your custom apps call through extraPath, such as pkgs.gnupg if you enable backup encryption.
Related documents
Frappe Development Environment with frappe-nix, for creating the bench and its locks.
Building Production Images with frappe-nix, for the container route to the same build.
Reference Architecture for Self-Hosting Business Systems, for putting these sites behind a tunnel on a small fleet of NixOS hosts.
Bench Operations, for general bench backup and restore procedures.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.