Skip to content

Module Authoring Conventions

This guide covers the standards and best practices for writing NixOS modules in the Ghaf framework. Following these conventions ensures consistency, maintainability, and proper error tracing.

modules/
├── common/ # Modules usable in host or VMs
│ ├── my-feature/
│ │ ├── default.nix # Main module (imports others)
│ │ ├── options.nix # Option definitions (if complex)
│ │ └── utils.nix # Helper functions (if needed)
│ └── my-simple-feature.nix # Simple single-file module
├── microvm/
│ ├── sysvms/
│ │ ├── my-vm.nix # VM orchestration (enable, evaluatedConfig)
│ │ └── my-vm-base.nix # VM content (what runs inside the VM)
│ └── my-vm-features/ # Optional features for a VM
│ ├── default.nix
│ └── feature-x.nix
├── hardware/
│ └── my-hardware/ # Hardware-specific modules
│ ├── default.nix
│ └── definitions.nix
└── profiles/
└── my-profile.nix # Configuration profiles
TypePatternExample
Feature modulekebab-case.nixpower-manager.nix
VM orchestration<name>vm.nixguivm.nix
VM base content<name>vm-base.nixguivm-base.nix
VM features dir<name>vm-features/audiovm-features/
Profile<name>.nixlaptop-x86.nix
Hardware<arch>-<type>.nixx86_64-generic.nix

Every module MUST have a _file declaration. This enables proper error tracing during evaluation.

# REQUIRED - enables error messages to show source file
{
_file = ./my-module.nix;
# Rest of module...
}

Without _file, error messages show unhelpful paths:

error: ... defined in `/nix/store/abc123-source/modules/microvm/sysvms/guivm-base.nix'

With _file, you get the actual source location:

error: ... defined in `modules/microvm/sysvms/guivm-base.nix'

Place _file as the first attribute after the opening brace:

{ config, lib, ... }:
{
_file = ./my-module.nix; # First attribute
imports = [ ... ];
options = { ... };
config = { ... };
}
Apache-2.0
# SPDX-FileCopyrightText: 2022-2026 TII (SSRC) and the Ghaf contributors
#
# Brief description of what this module does
#
{
config,
lib,
pkgs,
...
}:
let
cfg = config.ghaf.services.my-service;
in
{
_file = ./my-service.nix;
options.ghaf.services.my-service = {
enable = lib.mkEnableOption "My Service";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port for my service to listen on";
};
package = lib.mkPackageOption pkgs "my-service" { };
};
config = lib.mkIf cfg.enable {
systemd.services.my-service = {
description = "My Service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/my-service --port ${toString cfg.port}";
};
};
};
}
Apache-2.0
# SPDX-FileCopyrightText: 2022-2026 TII (SSRC) and the Ghaf contributors
#
# My VM Base Configuration
#
# This module defines the content that runs inside my-vm.
# It uses globalConfig for inherited settings and hostConfig for VM-specific config.
#
{
config,
lib,
pkgs,
inputs,
globalConfig,
hostConfig,
...
}:
{
_file = ./myvm-base.nix;
imports = [
inputs.self.nixosModules.vm-modules
inputs.self.nixosModules.givc
];
# Global settings from host
ghaf = {
profiles.debug.enable = globalConfig.debug.enable;
development.ssh.daemon.enable = globalConfig.development.ssh.daemon.enable;
logging = {
inherit (globalConfig.logging) enable listener;
client.enable = globalConfig.logging.enable;
};
};
# VM-specific settings
networking.hostName = hostConfig.vmName;
# VM's own configuration
services.myVmService.enable = true;
system.stateVersion = lib.trivial.release;
}
Apache-2.0
# SPDX-FileCopyrightText: 2022-2026 TII (SSRC) and the Ghaf contributors
#
# My VM Orchestration Module
#
# This module manages the my-vm lifecycle on the host.
# The actual VM content is defined in myvm-base.nix.
#
{
config,
lib,
inputs,
...
}:
let
vmName = "my-vm";
cfg = config.ghaf.virtualization.microvm.myvm;
in
{
_file = ./myvm.nix;
options.ghaf.virtualization.microvm.myvm = {
enable = lib.mkEnableOption "My VM";
evaluatedConfig = lib.mkOption {
type = lib.types.nullOr lib.types.unspecified;
default = null;
description = ''
Pre-evaluated NixOS configuration for My VM.
Set this via a profile's myvmBase.extendModules.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.evaluatedConfig != null;
message = ''
ghaf.virtualization.microvm.myvm.evaluatedConfig must be set.
Use myvmBase.extendModules from a profile.
'';
}
];
microvm.vms.${vmName} = {
autostart = true;
inherit (inputs) nixpkgs;
inherit (cfg) evaluatedConfig;
};
};
}

Use mkEnableOption for boolean feature flags:

options.ghaf.services.my-feature = {
enable = lib.mkEnableOption "my feature";
};

This creates an option with:

  • Type: bool
  • Default: false
  • Description: “Whether to enable my feature.”

Use mkPackageOption for package selections:

options.ghaf.services.my-service = {
package = lib.mkPackageOption pkgs "my-service" {
default = [ "my-service" ]; # Optional: path in pkgs
example = "pkgs.my-service-unstable";
};
};

Always specify types:

options.ghaf.services.my-service = {
# Port number
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port number";
};
# List of strings
allowedHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "localhost" ];
description = "Allowed hostnames";
};
# Enum
logLevel = lib.mkOption {
type = lib.types.enum [ "debug" "info" "warn" "error" ];
default = "info";
description = "Log level";
};
# Optional with null
configFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Custom config file, or null for default";
};
# Attribute set
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Additional settings as key-value pairs";
};
};

Organize related options under a common namespace:

# Good: Clear hierarchy
options.ghaf.services.my-service = {
enable = lib.mkEnableOption "my service";
server = {
host = lib.mkOption { ... };
port = lib.mkOption { ... };
};
client = {
timeout = lib.mkOption { ... };
retries = lib.mkOption { ... };
};
};
# Access: config.ghaf.services.my-service.server.port

Use mkIf to conditionally apply configuration:

config = lib.mkIf cfg.enable {
# Only applied if enable = true
systemd.services.my-service = { ... };
};

Use mkMerge to combine multiple config blocks:

config = lib.mkMerge [
# Always applied
{
environment.systemPackages = [ pkgs.common-tool ];
}
# Conditionally applied
(lib.mkIf cfg.enable {
systemd.services.my-service = { ... };
})
# Another condition
(lib.mkIf cfg.debug {
environment.systemPackages = [ pkgs.debug-tools ];
})
];

Use mkDefault for overridable defaults, mkForce to override:

# In base module - can be overridden
config.services.openssh.enable = lib.mkDefault true;
# In extension - overrides default
config.services.openssh.enable = lib.mkForce false;
lib.mkOverride 100 value # Same as mkDefault
lib.mkOverride 50 value # Higher priority than mkDefault
lib.mkDefault value # Priority 1000
lib.mkForce value # Priority 50
{
imports = [
./submodule-a.nix
./submodule-b.nix
];
}
{ inputs, ... }:
{
imports = [
inputs.self.nixosModules.my-module
inputs.other-flake.nixosModules.their-module
];
}
{ config, lib, ... }:
{
imports = [
./always-imported.nix
] ++ lib.optionals config.ghaf.features.myFeature.enable [
./feature-specific.nix
];
}
{ config, lib, globalConfig, ... }:
{
config = {
# Direct access
ghaf.profiles.debug.enable = globalConfig.debug.enable;
# With inherit
logging = {
inherit (globalConfig.logging) enable listener;
};
# With fallback
security.audit.enable = globalConfig.security.audit.enable or false;
};
}
{ config, lib, hostConfig, ... }:
{
config = {
# VM identity
networking.hostName = hostConfig.vmName;
# Network config
networking.interfaces.ethint0.ipv4.addresses = [{
address = hostConfig.networking.thisVm.ipv4;
prefixLength = 24;
}];
# With fallback
users.users = hostConfig.users.managed or { };
};
}
{ config, lib, globalConfig, ... }:
{
config = lib.mkIf (lib.ghaf.features.isEnabledFor globalConfig "myFeature" "this-vm") {
services.myFeature.enable = true;
};
}

Every file must have SPDX license headers:

Apache-2.0
# SPDX-FileCopyrightText: 2022-2026 TII (SSRC) and the Ghaf contributors

For documentation files (MDX):

{/*
SPDX-FileCopyrightText: 2022-2026 TII (SSRC) and the Ghaf contributors
SPDX-License-Identifier: CC-BY-SA-4.0
*/}

Ghaf uses treefmt with nixfmt-rfc-style. Always format before committing:

Terminal window
nix fmt
# Or to check without changing:
nix fmt -- --fail-on-change
  • File has SPDX license header
  • Module has _file declaration
  • Options are under ghaf.* namespace
  • Options have types and descriptions
  • Config uses mkIf for conditional application
  • Uses globalConfig/hostConfig instead of configHost
  • No hardcoded values that should be configurable
  • Module is added to appropriate default.nix imports
  • Module is exported in flake.nix if needed
  • Code is formatted with nix fmt
  • nix flake check passes
ConventionRequirement
_file declarationRequired in every module
SPDX headersRequired in every file
Option namespaceghaf.*
Enable optionsUse mkEnableOption
Package optionsUse mkPackageOption
Conditional configUse mkIf
Host settingsUse globalConfig / hostConfig
FormattingRun nix fmt before commit