21 KiB
Frappe Bench Infrastructure Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Convert the current Frappe LMS container setup into a production-style, multisite-capable Frappe Bench backed by host MariaDB, host Redis, and agenix-managed secrets.
Architecture: MariaDB and Redis become host infrastructure modules under hosts/AZ-CLD-1/services/. The Frappe runtime becomes one shared Bench module under hosts/AZ-CLD-1/services/containers/frappe.nix, with separate backend, frontend, websocket, worker, and scheduler containers. Secrets are provided through agenix and consumed via config.age.secrets.frappe-env.path.
Tech Stack: NixOS modules, Podman OCI containers, MariaDB, Redis, Frappe Bench, Traefik, agenix, alejandra, nixos-rebuild build.
Execution constraints: Do not run nixos-rebuild switch. Do not run git push. Build-only validation is allowed.
File Structure
- Create:
hosts/AZ-CLD-1/services/mariadb.nix- Owns host MariaDB configuration, Frappe DB admin bootstrap, firewall rules, and MariaDB dump timer.
- Create:
hosts/AZ-CLD-1/services/redis.nix- Owns host Redis configuration and firewall rules.
- Rename/create:
hosts/AZ-CLD-1/services/containers/frappe.nix- Replaces
frappe-lms.nixas the shared Bench runtime. - Owns Frappe directories, Bench config, site/app initialization, containers, backup timer, Traefik route, and CLI helper.
- Replaces
- Modify:
hosts/AZ-CLD-1/services/default.nix- Imports
mariadb.nixandredis.nix.
- Imports
- Modify:
hosts/AZ-CLD-1/services/containers/default.nix- Imports
frappe.nixinstead of the old LMS-specific file.
- Imports
- Modify:
hosts/common/ports.nix- Renames the internal service port key from
frappe-lmstofrappewhile preserving port3052.
- Renames the internal service port key from
- Modify:
hosts/AZ-CLD-1/secrets.nix- Adds the host-local agenix secret reference
frappe-env.
- Adds the host-local agenix secret reference
- Modify:
secrets.nix- Adds
secrets/frappe-env.ageto the public key registry.
- Adds
- Create:
secrets/frappe-env.age- Encrypted agenix env file containing
FRAPPE_DB_ADMIN_PASSWORDandFRAPPE_ADMIN_PASSWORD.
- Encrypted agenix env file containing
Task 1: Add agenix Secret Plumbing
Files:
-
Create:
secrets/frappe-env.age -
Modify:
secrets.nix -
Modify:
hosts/AZ-CLD-1/secrets.nix -
Step 1: Generate and encrypt the Frappe env secret
Run:
set -euo pipefail
db_secret_value="$(${openssl:-openssl} rand -base64 48 | tr -d '\n')"
admin_secret_value="$(${openssl:-openssl} rand -base64 32 | tr -d '\n')"
tmp_secret="$(mktemp)"
cat > "$tmp_secret" <<EOF_SECRET
FRAPPE_DB_ADMIN_PASSWORD=$db_secret_value
FRAPPE_ADMIN_PASSWORD=$admin_secret_value
EOF_SECRET
export tmp_secret
EDITOR='sh -c '\''cat "$tmp_secret" > "$1"'\'' sh' agenix -e secrets/frappe-env.age
rm -f "$tmp_secret"
Expected: secrets/frappe-env.age exists and contains encrypted data.
If openssl is not in PATH, run inside the dev shell:
nix develop
- Step 2: Register the encrypted secret in the global agenix registry
In secrets.nix, add this line near the other secrets/*.age entries:
"secrets/frappe-env.age".publicKeys = systems ++ users;
- Step 3: Register the secret for
AZ-CLD-1
In hosts/AZ-CLD-1/secrets.nix, add this attribute inside age.secrets:
frappe-env = {
file = ../../secrets/frappe-env.age;
};
- Step 4: Format the changed Nix files
Run:
alejandra secrets.nix hosts/AZ-CLD-1/secrets.nix
Expected: both files format successfully.
- Step 5: Verify the secret path is visible in the host config
Run:
nix eval --raw .#nixosConfigurations.AZ-CLD-1.config.age.secrets.frappe-env.file
Expected output ends with:
secrets/frappe-env.age
- Step 6: Commit the secret plumbing
Run:
git add secrets/frappe-env.age secrets.nix hosts/AZ-CLD-1/secrets.nix
git commit -m "secrets: add frappe env secret"
Task 2: Add Host MariaDB Infrastructure
Files:
-
Create:
hosts/AZ-CLD-1/services/mariadb.nix -
Modify:
hosts/AZ-CLD-1/services/default.nix -
Step 1: Create the MariaDB host module
Create hosts/AZ-CLD-1/services/mariadb.nix with this content:
{
config,
pkgs,
...
}: let
frappeEnvFile = config.age.secrets.frappe-env.path;
frappeDbAdminUser = "frappe_admin";
waitForMariaDb = ''
mariadb_ready=0
for attempt in $(${pkgs.coreutils}/bin/seq 1 60); do
if ${pkgs.mariadb_118}/bin/mysql -u root -N -e 'SELECT 1' >/dev/null 2>&1; then
mariadb_ready=1
break
fi
${pkgs.coreutils}/bin/sleep 2
done
if [ "$mariadb_ready" != "1" ]; then
echo "MariaDB did not become ready in time" >&2
exit 1
fi
'';
in {
services.mysql = {
enable = true;
package = pkgs.mariadb_118;
settings.mysqld = {
bind-address = "0.0.0.0";
character-set-server = "utf8mb4";
collation-server = "utf8mb4_unicode_ci";
skip-character-set-client-handshake = true;
skip-innodb-read-only-compressed = true;
};
};
networking.firewall.extraCommands = ''
iptables -A INPUT -p tcp -s 127.0.0.1 --dport 3306 -j ACCEPT
iptables -A INPUT -p tcp -s 10.89.0.0/24 --dport 3306 -j ACCEPT
'';
systemd.services.mariadb-frappe-admin-init = {
description = "Create shared Frappe MariaDB admin user";
wantedBy = ["multi-user.target"];
after = ["mysql.service"];
requires = ["mysql.service"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
set -a
source ${frappeEnvFile}
set +a
: "''${FRAPPE_DB_ADMIN_PASSWORD:?FRAPPE_DB_ADMIN_PASSWORD is missing in ${frappeEnvFile}}"
${waitForMariaDb}
escaped_db_admin_password=$(${pkgs.coreutils}/bin/printf '%s' "$FRAPPE_DB_ADMIN_PASSWORD" | ${pkgs.python3}/bin/python3 -c 'import sys; print(sys.stdin.read().replace(chr(39), chr(39) * 2), end="")')
${pkgs.mariadb_118}/bin/mysql -u root <<SQL
CREATE USER IF NOT EXISTS '${frappeDbAdminUser}'@'10.89.0.%' IDENTIFIED BY '$escaped_db_admin_password';
ALTER USER '${frappeDbAdminUser}'@'10.89.0.%' IDENTIFIED BY '$escaped_db_admin_password';
GRANT ALL PRIVILEGES ON *.* TO '${frappeDbAdminUser}'@'10.89.0.%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQL
'';
};
systemd.services.mariadb-backup-all = {
description = "Create full MariaDB dump backup";
serviceConfig = {
Type = "oneshot";
User = "root";
Group = "root";
};
script = ''
set -euo pipefail
backup_dir=/var/backup/mariadb
timestamp="$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)"
${pkgs.coreutils}/bin/mkdir -p "$backup_dir"
${pkgs.coreutils}/bin/chown root:root "$backup_dir"
${pkgs.coreutils}/bin/chmod 0750 "$backup_dir"
${pkgs.mariadb_118}/bin/mariadb-dump --all-databases --single-transaction --quick --lock-tables=false \
| ${pkgs.gzip}/bin/gzip -c > "$backup_dir/all-$timestamp.sql.gz"
${pkgs.findutils}/bin/find "$backup_dir" -type f -name 'all-*.sql.gz' -mtime +14 -delete
'';
};
systemd.timers.mariadb-backup-all = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* 03:20:00";
RandomizedDelaySec = "30m";
Persistent = true;
};
};
}
- Step 2: Import the MariaDB module
In hosts/AZ-CLD-1/services/default.nix, add ./mariadb.nix near ./postgres.nix:
./mariadb.nix
./postgres.nix
- Step 3: Format and evaluate MariaDB config
Run:
alejandra hosts/AZ-CLD-1/services/mariadb.nix hosts/AZ-CLD-1/services/default.nix
nix eval .#nixosConfigurations.AZ-CLD-1.config.services.mysql.enable
Expected output:
true
- Step 4: Verify MariaDB is no longer planned to live in Frappe
Run:
rg -n "services\.mysql|mariadb-frappe-admin-init" hosts/AZ-CLD-1/services
Expected:
-
services.mysqlappears inhosts/AZ-CLD-1/services/mariadb.nixonly. -
mariadb-frappe-admin-initappears inmariadb.nixand future Frappe ordering references only. -
Step 5: Commit MariaDB infrastructure
Run:
git add hosts/AZ-CLD-1/services/mariadb.nix hosts/AZ-CLD-1/services/default.nix
git commit -m "services: add host mariadb for frappe"
Task 3: Add Host Redis Infrastructure
Files:
-
Create:
hosts/AZ-CLD-1/services/redis.nix -
Modify:
hosts/AZ-CLD-1/services/default.nix -
Step 1: Create the Redis host module
Create hosts/AZ-CLD-1/services/redis.nix with this content:
{...}: {
services.redis.servers."" = {
enable = true;
port = 6379;
bind = null;
databases = 16;
appendOnly = true;
appendFsync = "everysec";
settings = {
protected-mode = "no";
};
};
networking.firewall.extraCommands = ''
iptables -A INPUT -p tcp -s 127.0.0.1 --dport 6379 -j ACCEPT
iptables -A INPUT -p tcp -s 10.89.0.0/24 --dport 6379 -j ACCEPT
'';
}
- Step 2: Import the Redis module
In hosts/AZ-CLD-1/services/default.nix, add ./redis.nix near the other infrastructure services:
./redis.nix
- Step 3: Format and evaluate Redis config
Run:
alejandra hosts/AZ-CLD-1/services/redis.nix hosts/AZ-CLD-1/services/default.nix
nix eval .#nixosConfigurations.AZ-CLD-1.config.services.redis.servers."".enable
Expected output:
true
- Step 4: Commit Redis infrastructure
Run:
git add hosts/AZ-CLD-1/services/redis.nix hosts/AZ-CLD-1/services/default.nix
git commit -m "services: add host redis"
Task 4: Rename and Reshape the Frappe Bench Module
Files:
-
Rename/create:
hosts/AZ-CLD-1/services/containers/frappe.nix -
Remove from imports:
hosts/AZ-CLD-1/services/containers/frappe-lms.nix -
Modify:
hosts/common/ports.nix -
Step 1: Rename the Frappe module file
Run:
if [ -f hosts/AZ-CLD-1/services/containers/frappe-lms.nix ]; then
mv hosts/AZ-CLD-1/services/containers/frappe-lms.nix hosts/AZ-CLD-1/services/containers/frappe.nix
fi
Expected: hosts/AZ-CLD-1/services/containers/frappe.nix exists.
- Step 2: Rename the service port key
In hosts/common/ports.nix, replace:
frappe-lms = 3052;
with:
frappe = 3052;
- Step 3: Replace the top-level Frappe constants
At the top of hosts/AZ-CLD-1/services/containers/frappe.nix, use this structure in the let block:
serviceName = "frappe";
servicePort = config.m3ta.ports.get serviceName;
primaryDomain = "learn.az-gruppe.com";
image = "ghcr.io/frappe/lms:stable";
dataDir = "/var/lib/${serviceName}";
sitesDir = "${dataDir}/sites";
frappeEnvFile = config.age.secrets.frappe-env.path;
dbHost = "10.89.0.1";
dbPort = "3306";
dbAdminUser = "frappe_admin";
redisCacheUrl = "redis://10.89.0.1:6379/1";
redisQueueUrl = "redis://10.89.0.1:6379/2";
sites = {
"learn.az-gruppe.com" = {
apps = ["lms"];
adminPasswordEnv = "FRAPPE_ADMIN_PASSWORD";
};
};
siteNames = builtins.attrNames sites;
- Step 4: Remove LMS-specific MariaDB and Redis definitions
Delete these pieces from hosts/AZ-CLD-1/services/containers/frappe.nix:
redisCacheIp = "${ipBase}.71";
redisQueueIp = "${ipBase}.72";
redisCacheName = "${serviceName}-redis-cache";
redisQueueName = "${serviceName}-redis-queue";
Delete the entire local services.mysql = { ... }; block.
Delete the container definitions for:
frappe-lms-redis-cache
frappe-lms-redis-queue
- Step 5: Update shared app base dependencies
In appBase, remove container Redis dependencies so it looks like this:
appBase = {
inherit image;
autoStart = true;
volumes = ["${sitesDir}:/home/frappe/frappe-bench/sites"];
};
- Step 6: Change the directory preparation service
Update the directory service so it no longer checks a manual env file under /var/lib. Its script should be:
script = ''
set -euo pipefail
${pkgs.coreutils}/bin/mkdir -p ${dataDir} ${sitesDir}
${pkgs.coreutils}/bin/chown root:root ${dataDir}
${pkgs.coreutils}/bin/chmod 0750 ${dataDir}
${pkgs.coreutils}/bin/chown -R 1000:1000 ${sitesDir}
${pkgs.coreutils}/bin/chmod 0750 ${sitesDir}
'';
- Step 7: Update configurator ordering and Redis config
The configurator service must order after host MariaDB admin init and host Redis:
after = [
"mariadb-frappe-admin-init.service"
"redis.service"
"${serviceName}-directories.service"
];
requires = [
"mariadb-frappe-admin-init.service"
"redis.service"
"${serviceName}-directories.service"
];
Inside the bench set-config commands, use host Redis URLs:
bench set-config -g db_host ${dbHost}
bench set-config -gp db_port ${dbPort}
bench set-config -g redis_cache ${redisCacheUrl}
bench set-config -g redis_queue ${redisQueueUrl}
bench set-config -g redis_socketio ${redisQueueUrl}
bench set-config -gp socketio_port 9000
bench set-config -g chromium_path /usr/bin/chromium-headless-shell
- Step 8: Replace site init with agenix-based multisite init
In ${serviceName}-site-init, source the agenix env file:
set -a
source ${frappeEnvFile}
set +a
: "''${FRAPPE_DB_ADMIN_PASSWORD:?FRAPPE_DB_ADMIN_PASSWORD is missing in ${frappeEnvFile}}"
For each configured site, use this shell pattern generated from the sites attribute set:
: "''${FRAPPE_ADMIN_PASSWORD:?FRAPPE_ADMIN_PASSWORD is missing in ${frappeEnvFile}}"
if [ ! -f ${sitesDir}/learn.az-gruppe.com/site_config.json ]; then
${pkgs.podman}/bin/podman run --rm \
--name ${serviceName}-site-init-learn \
--network web \
--ip ${transientIp} \
--env-file ${frappeEnvFile} \
--env SITE_NAME=learn.az-gruppe.com \
-v ${sitesDir}:/home/frappe/frappe-bench/sites \
${image} \
bash -c '
set -euo pipefail
bench new-site \
--db-host ${dbHost} \
--db-port ${dbPort} \
--mariadb-user-host-login-scope=% \
--db-root-username ${dbAdminUser} \
--db-root-password "$FRAPPE_DB_ADMIN_PASSWORD" \
--admin-password "$FRAPPE_ADMIN_PASSWORD" \
"$SITE_NAME"
'
else
echo "Frappe site learn.az-gruppe.com already exists"
fi
for app in lms; do
${pkgs.podman}/bin/podman run --rm \
--name ${serviceName}-install-app-$app \
--network web \
--ip ${transientIp} \
--env SITE_NAME=learn.az-gruppe.com \
--env APP_NAME="$app" \
-v ${sitesDir}:/home/frappe/frappe-bench/sites \
${image} \
bash -c '
set -euo pipefail
if bench --site "$SITE_NAME" list-apps | grep -Fxq "$APP_NAME"; then
echo "App $APP_NAME already installed on $SITE_NAME"
else
bench --site "$SITE_NAME" install-app "$APP_NAME"
fi
'
done
After this works for learn.az-gruppe.com, convert the repeated site/app shell to generated Nix using lib.concatMapStringsSep and lib.escapeShellArg so future sites can be added by extending sites.
- Step 9: Update container names and service references
Ensure the container names are:
frappe-backend
frappe-frontend
frappe-websocket
frappe-queue-short
frappe-queue-long
frappe-scheduler
Ensure no names retain the frappe-lms- prefix.
- Step 10: Update systemd ordering for all Frappe containers
Every runtime container override must require site init and host Redis:
systemd.services."podman-${backendName}" = {
after = ["${serviceName}-site-init.service" "redis.service"];
requires = ["${serviceName}-site-init.service" "redis.service"];
};
Apply the same pattern to frontend, websocket, queue-short, queue-long, and scheduler.
- Step 11: Update the CLI helper name and status output
The package name must be frappe via serviceName = "frappe". The status command must check:
systemctl status \
mysql.service \
redis.service \
mariadb-frappe-admin-init.service \
${serviceName}-configurator.service \
${serviceName}-site-init.service \
podman-${backendName}.service \
podman-${frontendName}.service \
podman-${websocketName}.service \
podman-${queueShortName}.service \
podman-${queueLongName}.service \
podman-${schedulerName}.service
- Step 12: Format and statically verify Frappe changes
Run:
alejandra hosts/AZ-CLD-1/services/containers/frappe.nix hosts/common/ports.nix
rg -n "frappe-lms|redis-cache|redis-queue|services\.mysql|/var/lib/frappe.*\.env" hosts/AZ-CLD-1/services/containers/frappe.nix hosts/common/ports.nix || true
Expected: no matches for removed local Redis containers, local MySQL service, or manual /var/lib/frappe/*.env secret file.
- Step 13: Commit Frappe reshape
Run:
git add hosts/AZ-CLD-1/services/containers/frappe.nix hosts/AZ-CLD-1/services/containers/frappe-lms.nix hosts/common/ports.nix
git commit -m "services: reshape frappe as shared bench"
Task 5: Enable the Frappe Bench Import and Build-Test
Files:
-
Modify:
hosts/AZ-CLD-1/services/containers/default.nix -
Step 1: Import the new Frappe module
In hosts/AZ-CLD-1/services/containers/default.nix, replace:
# ./frappe-lms.nix
with:
./frappe.nix
- Step 2: Format the container import file
Run:
alejandra hosts/AZ-CLD-1/services/containers/default.nix
Expected: formatting succeeds.
- Step 3: Run focused Nix evaluations
Run:
nix eval --raw .#nixosConfigurations.AZ-CLD-1.config.services.traefik.dynamicConfigOptions.http.routers.frappe.rule
nix eval .#nixosConfigurations.AZ-CLD-1.config.virtualisation.oci-containers.containers.frappe-backend.autoStart
nix eval .#nixosConfigurations.AZ-CLD-1.config.services.redis.servers."".enable
nix eval .#nixosConfigurations.AZ-CLD-1.config.services.mysql.enable
Expected outputs:
Host(`learn.az-gruppe.com`)
true
true
true
- Step 4: Run repository quality checks
Run:
nix flake check
Expected: command exits with status 0.
- Step 5: Run the allowed build test
Run:
nixos-rebuild build --flake .#AZ-CLD-1
Expected: command exits with status 0 and creates/updates ./result.
Do not run nixos-rebuild switch.
- Step 6: Commit the activation and build-tested state
Run:
git add hosts/AZ-CLD-1/services/containers/default.nix
git commit -m "services: enable frappe bench"
Task 6: Final Static Verification and Handoff
Files:
-
Read-only verification across the repository.
-
Step 1: Verify no old Frappe LMS structural names remain
Run:
rg -n "frappe-lms|FRAPPE_LMS|frappe_lms|redis-cache|redis-queue" hosts secrets.nix secrets || true
Expected: no matches except historical documentation under docs/superpowers/specs/ or docs/superpowers/plans/.
- Step 2: Verify no prohibited commands were run
Run:
git log --oneline -5
Expected: recent commits are local only. Do not run git push.
- Step 3: Verify worktree state
Run:
git status --short
Expected: no unstaged implementation changes. result may appear depending on local .gitignore; remove it with rm result if it is an untracked symlink.
- Step 4: Write handoff note in final response
Include:
Implemented host MariaDB, host Redis, agenix Frappe secrets, and shared Frappe Bench module.
Validation run: nix flake check; nixos-rebuild build --flake .#AZ-CLD-1.
Not run: nixos-rebuild switch; git push.
Self-Review
Spec coverage:
- Host Redis: Task 3.
- Host MariaDB: Task 2.
- Compose MariaDB settings: Task 2 Step 1.
- agenix secrets: Task 1 and Task 4 Step 8.
- Rename
frappe-lms.nixtofrappe.nix: Task 4 Step 1. - Multisite-capable Bench: Task 4 Steps 3 and 8.
- Production-style separated containers: Task 4 Steps 9 and 10.
- No
switch, no push, build allowed: header and Task 5 Step 5.
Placeholder scan:
- The plan contains no placeholder markers and no intentionally blank implementation steps.
Type/name consistency:
- Secret name:
frappe-enveverywhere. - DB admin user:
frappe_admineverywhere. - Service name:
frappeeverywhere after rename. - Redis service: default NixOS Redis instance, systemd unit
redis.service.