| name | flake-module-creator |
| description | Create new NixOS, Darwin, or Home Manager modules following the dendritic flake pattern. Use when the user wants to add a new feature module (service, program, system configuration, etc.) to their flake. Triggers include requests like "add [feature] module", "create a module for [feature]", "set up [feature] in the flake", or "add nginx/git/firefox/etc." |
Flake Module Creator
Create new NixOS, Darwin, or Home Manager modules following the dendritic pattern with flake-parts.
Module Design Mindset
Before creating any module, ask yourself:
-
Scope: Is this ONE feature or multiple features bundled together?
- If multiple → Split into separate modules
- If unclear → Start narrow, expand later
-
Context: Where does this feature live?
- Machine state or generally available packages (NixOS/Darwin) → Single class module
- User files, services, session state, or material user configuration (Home Manager) → Single class module
- Both system + user lifecycles → Multi-context module
-
Sharing: How many hosts will use this?
- One host → Options can be minimal or none
- Multiple hosts with variations → Define options with
lib.mkDefault
- All hosts identically → No options needed
-
Dependencies: What does this feature require?
- Other modules → Import via
config.flake.modules
- Secrets → SOPS integration required
- Nothing → Standalone module
-
Lifetime: Is this stable or experimental?
- Stable → Normal filename
- Work-in-progress → Prefix with
_ to exclude from auto-import
Core Principles (CRITICAL)
These rules are non-negotiable for valid dendritic code:
- Module Classes: Every module belongs to
nixos, darwin, homeManager, or generic
- NO Conditional Imports: Never use
lib.mkIf with imports (causes recursion)
- NO Cross-Class Imports: Can't import nixos module into darwin (use generic class)
- MUST Use lib.mkMerge: Always use
lib.mkMerge not // for merging
- Import to Enable: Features activate when imported, not via enable options
- Collector Merging: Multiple files can define same aspect name - configs merge
- Package Ownership: Packages are system-owned unless they directly support Home Manager-owned lifecycle behavior or are intentionally user-scoped
Enabling programs.<name> often installs its package. Use a Home Manager program module only when Home Manager also owns material user configuration, generated files, services, session state, or another concrete user lifecycle. Package installation and configuration ownership are separate decisions; do not use Home Manager as a general package installer.
See ../dendritic-pattern/references/validation-rules.md for complete details.
Workflow
1. Determine Module Information
Ask the user for:
- Feature name (e.g., "nginx", "firefox", "git")
- Module type: NixOS service, Home Manager program, system config, etc.
- Module class:
nixos, homeManager, darwin, or generic
- Category: Where it belongs (services, applications, development, system, etc.)
- Configuration details:
- Options to expose (ports, paths, settings)
- Default values
- Dependencies on other modules
- Whether it needs secrets (SOPS integration)
2. Choose Module Pattern
Based on the feature, select the appropriate pattern:
Simple Module - Single-purpose feature for one context
- Example: A NixOS service, a Home Manager program
- File:
modules/<category>/<name>.nix
- Exports:
flake.modules.nixos."<category>/<name>"
Multi-Context Module - Feature spanning NixOS + Home Manager
- Example: GNOME (system packages + user settings)
- Files: Same file exports both classes
- Exports: Both
flake.modules.nixos.<name> and flake.modules.homeManager.<name>
- NixOS/Darwin owns package availability and machine state; Home Manager owns user configuration, files, services, and session state
Collector Module - Aggregates config from multiple sources
- Example: Syncthing devices across hosts
- Multiple files export same aspect name
- Configs merge automatically
Generic Module - Platform-agnostic, importable anywhere
- Example: Constants, shared configurations
- File:
modules/<category>/<name>.nix
- Exports:
flake.modules.generic.<name>
See references/module-patterns.md for detailed patterns.
3. Load Template and Create Module File
MANDATORY: Based on module type, load the appropriate template:
For NixOS Services:
MUST read assets/nixos-service-template.nix
For Containerized Services:
MUST read assets/containerized-service-template.nix
For Home Manager Programs:
MUST read assets/home-manager-program-template.nix
For System Configuration:
MUST read assets/system-config-template.nix
For Multi-Context Modules:
MUST read assets/multi-context-template.nix
For Generic Modules:
MUST read assets/generic-template.nix
Do NOT load references/sops-integration.md unless module explicitly needs secrets.
Do NOT load references/module-patterns.md unless user asks about patterns or you need clarification.
File Location: modules/<category>/<name>.nix
Replace template placeholders:
<SERVICE-NAME> / <PROGRAM-NAME> / <NAME> → actual feature name
<PACKAGE-NAME> → nixpkgs package name
<IMAGE> / <TAG> → container image details
<category> → services, applications, development, etc.
4. Handle Dependencies
Import other modules:
{ config, ... }:
{
flake.modules.nixos."services/myapp" = {
imports = [
config.flake.modules.nixos."virtualization/podman"
];
};
}
Reference flake metadata:
let
synologyDomain = config.flake.meta.synology.domain;
in {
services.myapp.url = "https://app.${synologyDomain}";
}
5. Handle Secrets (SOPS)
If the module needs secrets, load references/sops-integration.md for complete patterns.
Basic pattern:
config = lib.mkIf cfg.enable {
sops.secrets.myapp-token = {
mode = "0400";
owner = cfg.user;
};
services.myapp.tokenFile = config.sops.secrets.myapp-token.path;
};
6. Add to Host Configuration
Guide the user to add the named aspect to the host declaration:
Via hosts.<name>.modules:
# In modules/hosts/<hostname>/default.nix
hosts.<hostname>.modules = [
"services/myapp"
];
The host collector resolves the named aspect for the host platform and adds its
matching flake.modules.homeManager aspect to home-manager.sharedModules
when one exists. Do not resolve host entries manually or pair same-named
Home Manager siblings in the feature module.
Override defaults if needed:
hosts.<hostname>.modules = [
"services/myapp"
{ services.myapp.port = 9090; }
];
Dendritic-Specific Edge Cases
Edge Case 1: Collector Pattern Conflicts
Problem: Two modules define same aspect with conflicting values
# Module A
flake.modules.nixos.myapp = { services.myapp.port = 8080; };
# Module B
flake.modules.nixos.myapp = { services.myapp.port = 9090; };
Result: Last import wins, but order is undefined in flake-parts
Fix: Use lib.mkDefault in base module, override in host:
# Base module (modules/services/myapp.nix)
flake.modules.nixos.myapp = {
services.myapp.port = lib.mkDefault 8080;
};
# Host override (in modules/hosts/server/default.nix)
hosts.server.modules = [
"services/myapp"
{ services.myapp.port = 9090; }
];
Why this matters: Collector merging is for additive config (devices, hosts, routes), not for overriding values.
Edge Case 2: Multi-Context Aspect Matching
Problem: Manually adding a same-named Home Manager sibling duplicates the
host collector's work.
# WRONG: do not wire a matching sibling from its NixOS aspect
flake.modules.nixos.gnome = {
services.xserver.desktopManager.gnome.enable = true;
home-manager.sharedModules = [
config.flake.modules.homeManager.gnome
];
};
flake.modules.homeManager.gnome = {
dconf.settings = { };
};
Correct approach: Declare same-named system and Home Manager aspects, then
list the aspect name once in hosts.<name>.modules:
hosts.<hostname>.modules = [ "gnome" ];
How it works:
- The host collector resolves
gnome in the host's NixOS or Darwin module set.
- When
flake.modules.homeManager.gnome exists, the collector adds it to
home-manager.sharedModules once.
- A Home-Manager-only aspect may also appear in
hosts.<name>.modules; it is
added to Home Manager without requiring a platform sibling.
Use home-manager.sharedModules directly only for modules that named-aspect
matching cannot resolve, such as an external Home Manager module from an input.
Edge Case 3: Conditional Config vs Conditional Imports
Problem: Confusing when to use lib.mkIf
# ❌ WRONG - Conditional imports cause recursion
{
imports = lib.mkIf someCondition [
config.flake.modules.nixos.someFeature
];
}
# ✅ CORRECT - Always import, make CONTENT conditional
{
imports = [
config.flake.modules.nixos.someFeature
];
config = lib.mkIf someCondition {
services.someFeature.extraConfig = "...";
};
}
Why conditional imports fail:
- Nix evaluates
imports to determine available options
- Condition may depend on options from imported module
- Creates circular dependency: "need options to evaluate condition, need condition to import options"
- Results in infinite recursion error
The rule: imports is always unconditional. Use lib.mkIf in config block instead.
Edge Case 4: lib.mkMerge vs // Operator
Problem: Using // for merging seems simpler, but breaks nested configs
# ❌ WRONG - Shallow merge loses data
let
baseConfig = {
services.nginx.virtualHosts.siteA = { ... };
};
extraConfig = {
services.nginx.virtualHosts.siteB = { ... };
};
in
baseConfig // extraConfig
# Result: Only siteB exists! siteA was overwritten!
Why this breaks:
// does shallow merge: overwrites entire attribute at first level
baseConfig.services.nginx.virtualHosts = { siteA = {...}; }
extraConfig.services.nginx.virtualHosts = { siteB = {...}; }
// replaces entire virtualHosts attribute, deleting siteA
Correct approach:
# ✅ CORRECT - Deep merge preserves all data
lib.mkMerge [
baseConfig
extraConfig
(lib.mkIf condition { ... })
]
# Result: Both siteA and siteB exist
When to use what:
lib.mkMerge - Always use for NixOS/Home Manager configs (deep merge)
// - Only for flat attribute sets with no nested structures
Edge Case 5: Options Without lib.mkDefault
Problem: Hard-coded values prevent host overrides
# ❌ WRONG - Can't override
flake.modules.nixos.myapp = {
services.myapp.port = 8080; # Fixed value
};
# In host config:
services.myapp.port = 9090;
# Error: "The option `services.myapp.port` is defined multiple times"
Why this fails: NixOS treats both as definitions at same priority, conflicts.
Fix:
# ✅ CORRECT - Host can override
flake.modules.nixos.myapp = {
services.myapp.port = lib.mkDefault 8080; # Default value
};
# In host config:
services.myapp.port = 9090; # Override works!
Priority hierarchy:
lib.mkOverride 10 - Highest priority
- Regular definition - High priority (what hosts use)
lib.mkDefault - Low priority (what modules use)
lib.mkOverride 1500 - Lowest priority
The rule: Modules use lib.mkDefault, hosts use regular definitions.
NEVER Do When Creating Modules
NEVER use enable options in dendritic pattern
# ❌ WRONG - This is NOT the dendritic way
options.myapp.enable = lib.mkEnableOption "myapp";
config = lib.mkIf cfg.enable { ... };
Why this is wrong:
- Breaks dendritic import-to-enable contract
- Requires two actions: import + enable (cognitive overhead)
- Can't use collector pattern (multiple files defining same aspect)
- Contradicts flake-parts philosophy of composition via imports
- Forces every consumer to write boilerplate:
myapp.enable = true;
Exception: Containerized services conventionally use enable option (see termix.nix example). This is because container lifecycle (start/stop) is heavier than package installation.
NEVER manually pair matching Home Manager siblings
# WRONG: the collector already adds the matching sibling
flake.modules.nixos."desktop/gnome" = {
home-manager.sharedModules = [
config.flake.modules.homeManager."desktop/gnome"
];
};
Correct approach: Declare both siblings and list the aspect name in the
host declaration:
hosts.<hostname>.modules = [ "desktop/gnome" ];
For an unmatched external Home Manager module, use sharedModules explicitly:
home-manager.sharedModules = [
inputs.some-input.homeModules.default
];
NEVER use // for merging configs
See Edge Case 4 above for detailed explanation.
NEVER define options without lib.mkDefault
See Edge Case 5 above for detailed explanation.
NEVER use conditional imports
See Edge Case 3 above for detailed explanation.
NEVER import modules from different classes
# ❌ WRONG - Importing NixOS module into Darwin
flake.modules.darwin.myFeature = {
imports = [ config.flake.modules.nixos.someFeature ]; # ERROR
};
Why this fails:
- Module classes are typed contexts
- NixOS modules use
services.*, systemd.*, boot.* (Linux-specific)
- Darwin modules use different service system, no systemd, no Linux boot
- Results in "option does not exist" errors
Fix: Use generic class for shared code:
# Shared in generic class
flake.modules.generic.sharedConfig = { ... };
# Import into both
flake.modules.nixos.myFeature = {
imports = [ config.flake.modules.generic.sharedConfig ];
};
flake.modules.darwin.myFeature = {
imports = [ config.flake.modules.generic.sharedConfig ];
};
NEVER use specialArgs for passing values
# ❌ WRONG - Using specialArgs
specialArgs = { myDomain = "example.com"; };
Why this is wrong:
- Not dendritic (breaks declarative tree structure)
- Can't see where values come from (invisible data flow)
- Can't override at flake level
- Discourages use of
config.flake.meta
Fix - Use flake metadata:
# At flake level
config.flake.meta.domain = "example.com";
# In modules
services.myapp.url = "https://app.${config.flake.meta.domain}";
Validation Checklist
Before finalizing the module, verify:
Reference Documentation
Load these references only when explicitly needed:
Safety
- Ask user for confirmation before creating files
- Validate module structure against dendritic rules
- Check for existing modules with similar names using glob/grep
- Suggest appropriate category and naming conventions
- Warn about common anti-patterns if user's requirements suggest them