Nix Expressions
Nix Expressions & Design Patterns
A deep dive into the custom Nix functions and architectural patterns that make this repository highly modular and declarative.
🛠️ Core Library Functions (lib/)
Our custom library simplifies the standard NixOS module system, making it more concise and easier to manage.
1. Unified Option Definition (mkOpt, mkBoolOpt)
Instead of long mkOption blocks, we use:
mkOpt types.str "default": Defines a string option with a default.mkBoolOpt true: Defines a boolean option.mkOpt' types.lines "" "desc": Defines an option with a custom description.
2. Auto-loading Pipeline (mapModules)
We use recursive folder scanning to import Nix files automatically. This eliminates the need for manual imports = [ ./file.nix ... ] lists.
mapModules ./dir import: Imports all.nixfiles in a directory.mapModulesRec: Scans subdirectories as well.
🏗️ Functional Wrappers & Pipelines
To improve software organization and security, we implement custom “pipeline” functions.
The Jailing Wrapper (wrapFakeHome & mkWrapper)
Located in lib/pkgs.nix, these functions create a secure execution environment for apps that refuse to follow XDG standards by redirecting $HOME to a “fake” directory (usually ~/.local/user).
1. wrapFakeHome pkg binName
Creates a minimal, reliable wrapper for a straightforward application, targeting its exact binary.
- Best for: Simple GUI apps (Zoom, QQ) or CLI tools.
- Implementation: It is a thin helper around
mkWrapper.
2. mkWrapper pkg postBuild
The raw wrapping primitive used when complex logic is required.
- Best for: Complex GUI applications (Steam, JetBrains IDEs, VSCode) that need multiple binaries wrapped, specific
--user-data-dirflags added, or icon paths patched. - Why: By using explicit
wrapProgramcalls in the module itself (the “clean-room” approach), we avoid brittle.desktopfile sed-patching and Nix store permission errors that plagued oldersymlinkJoinabstractions.
Desktop Entries (mkLauncherEntry & mkFakeHomeEntry)
mkLauncherEntry
Generates an imperatively created .desktop application entry. Prefixes the name with launcher..
Signature: title: { prefix ?, name, description ?, icon, exec, categories ? [] } -> derivation
mkFakeHomeEntry
Generates an isolated .desktop application entry for an app that needs to run in the Fake Home but doesn’t have a native Nix package (or requires specific launch flags like --private-window).
Signature: title: { name, description ?, icon, exec, categories ? [] } -> derivation
Key Behavior for XDG MIME Bindings:
This function wraps mkLauncherEntry and creates a .desktop file named launcher.<name>.desktop. It also automatically generates a shell script wrapper for the Exec line so that $HOME is correctly set without complex escaping. If you need to set this app as a default handler in modules.xdg.mime.defaultApplications, you must use the launcher. prefix.
# Creating the isolated entry
(mkFakeHomeEntry "Firefox (Private)" {
name = "firefox-private";
exec = "firefox --private-window %U";
icon = "firefox";
})
# Binding it in XDG
modules.xdg.mime.defaultApplications = {
"x-scheme-handler/http" = ["launcher.firefox-private.desktop"];
};
The Module Bridge (mkAliasDefinitions)
We use mkAliasDefinitions to bridge related option namespaces, keeping module config concise.
home.* → home-manager.users.<name>.* (home.nix):
home.file, home.configFile, home.dataFile are aliased to their long-form Home Manager equivalents.
- Benefit: write
home.configFile."app/config".source = ...in any NixOS module.
config.user.* → users.users.<name>.* (default.nix):
All config.user attributes are forwarded to users.users.<name> via
mkAliasDefinitions. Bridge-compatible fields (name, description, home,
uid, extraGroups, packages, openssh) map directly to NixOS user options.
- Benefit: any module can write
user.packages = [...]oruser.openssh.*and have it automatically reach the NixOS user submodule at build time.
config.identity.* (default.nix):
User profile fields that do NOT exist on users.users.<name> (email, fullName,
github, website, vaultwarden, signing, ssh keys) live under a separate
options.identity namespace with freeformType = attrs. This keeps them out of
the mkAliasDefinitions forwarding path while remaining accessible to all modules.
Deploying agenix Secrets: environment.etc vs home.configFile
Agenix decrypts secrets to runtime paths like /run/agenix/<name>. These paths
do not exist during Nix evaluation, so they cannot be used as source in
home.configFile or home.file — Nix pure evaluation forbids access to
absolute paths outside the store:
# BROKEN — fails pure eval with "access to absolute path is forbidden"
home.configFile."vdirsyncer/config".source = config.age.secrets."vdirsyncer_gmail.conf".path;
The fix follows the pattern from modules/profiles/hardware/yubikey.nix: deploy
the secret via environment.etc (which creates a symlink in /etc/), then
point the application at /etc/agenix/<name>:
# Correct — environment.etc creates a symlink at runtime
age.secrets."vdirsyncer_gmail.conf" = { ... };
environment.etc."agenix/vdirsyncer_gmail.conf" = {
source = config.age.secrets."vdirsyncer_gmail.conf".path;
mode = "0400";
user = config.user.name;
group = "users";
};
services.vdirsyncer.jobs.gmail_sync.configFile = "/etc/agenix/vdirsyncer_gmail.conf";
systemd.tmpfiles.rules is an alternative when the target must be inside
$HOME, but environment.etc is preferred for system-level config files.
🧬 Architectural Patterns
The genNginxVhost / genNginxVhostBase Helpers (lib/nginx.nix)
Two-tier vhost generator eliminates boilerplate across all service modules.
genNginxVhost — wraps genNginxVhostBase with a default "/" location containing proxyPass + Alt-Svc. For standard reverse-proxy services:
genNginxVhost :: AttrSet -> AttrSet
genNginxVhost = {
domain, ssl, proxyPass, # required
proxyWebsockets ? false, # optional
quic ? false, kTLS ? false, # from global nginx config
extraConfig ? "", # vhost-level extra nginx directives
locationExtraConfig ? "", # appended to "/" location extraConfig
locations ? {}, # additional locations merged with "/"
oauth2 ? false, oauth2Groups ? [], # per-vhost SSO
}: { ... };
genNginxVhostBase — structural boilerplate only. No default location. For PHP-FPM, fcgiwrap, and services that need custom location logic:
genNginxVhostBase :: AttrSet -> AttrSet
genNginxVhostBase = {
ssl, # required — enables forceSSL + useACMEWildcardHost
quic ? false, kTLS ? false, # from global nginx config
extraConfig ? "", # vhost-level extra nginx directives (appended after real_ip)
oauth2 ? false, oauth2Groups ? [], # per-vhost SSO
locations ? {}, # caller controls all locations
}: { ... };
Both generate: forceSSL, useACMEWildcardHost, quic, http3, kTLS, real_ip headers with Tailscale CIDRs, and the oauth2 submodule. genNginxVhost adds a "/" location with proxyPass + proxyWebsockets + Alt-Svc header; genNginxVhostBase passes through the caller’s locations as-is.
Firewall Helpers (lib/firewall.nix)
Standard RFC 1918 + Tailscale source ranges and helper functions. All service modules that open non-public ports should use these instead of copy-pasting iptables rules.
# Trusted internal networks
lanSources = [
"192.168.0.0/16" # RFC 1918 class C — home routers
"10.0.0.0/8" # RFC 1918 class A
"172.16.0.0/12" # RFC 1918 class B — Docker, mid-size
"100.64.0.0/10" # Tailscale CGNAT
];
mkFirewallFor — standard ACCEPT-from-LAN firewall block:
networking.firewall = mkFirewallFor {
tcp = ["8096"];
udp = ["22000" "21027"];
# For multiport: tcpMulti = ["47984,47989,48010"];
comment = "jellyfin";
};
Generates ACCEPT rules for each protocol×port×source combination, tagged with -m comment. Includes extraStopCommands to clean up on firewall reload.
mkRestrictedPort — whitelist-only for ports exposed beyond localhost (e.g. Ollama API). Inserts ACCEPT rules at the top of the nixos-fw chain followed by a terminal DROP:
networking.firewall = mkIf (exposedToLan) (mkRestrictedPort "11434");
Vhost Submodule Extension (NixOS Type Merging)
Two independent modules extend services.nginx.virtualHosts.<name> with custom options. NixOS automatically merges submodule declarations on the same option path — neither module needs to know about the other:
| Module | Adds to each vhost | Config action |
|---|---|---|
modules/services/web/acme.nix | useACMEWildcardHost (bool) | Sets useACMEHost = mkForce baseDomain + acmeRoot = null |
modules/services/auth/oauth2-proxy.nix | oauth2.enable (bool), oauth2.allowedGroups (list) | Generates auth_request directives + internal auth/redirect locations |
Both declare options.services.nginx.virtualHosts = mkOption { type = types.attrsOf (types.submodule ...); };. When NixOS evaluates the module system, it sees two declarations for the same option path with compatible types and merges the submodule options. Every vhost transparently gets both useACMEWildcardHost AND oauth2.* — service modules just set them:
services.nginx.virtualHosts = mkIf cfg.nginx.enable {
"${cfg.domain}" = genNginxVhost {
...
oauth2 = cfg.oauth2.enable; # from oauth2-proxy.nix extension
}; # useACMEWildcardHost set internally
};
This is the same pattern nixpkgs uses — multiple modules contribute to services.nginx.virtualHosts without coordination. The key constraint: both declarations must use the same type. Mixing types.attrsOf (types.submodule ...) with a plain types.attrs would fail at evaluation time.
The “Decoration” Pattern
Used in our theme system, this pattern ensures that visual changes are non-destructive. A decoration (like a CSS theme) is only applied if the application itself is already enabled.
config = mkIf (config.modules.desktop.browsers.firefox.enable) {
# ... themes apply here ...
}
Declarative Provisioning (ensures)
Used in the PostgreSQL module, this pattern handles the entire database lifecycle (user creation, DB creation, password rotation) at boot time via Systemd post-start hooks. This removes the need for manual SQL initialization scripts.
🧬 Pure Hermetic Evaluation
The flake is fully hermetic — zero --impure flags, zero getEnv calls,
zero HEYENV. Build-time paths use toString self (the flake store path),
which contains all config files, modules, and themes.
Host / User / Theme Resolution
| Value | Source |
|---|---|
| Host name | mapAttrs key from hosts/<name>/ → networking.hostName |
| User identity | options.identity (freeform attrs), set per-user in modules/profiles/user/<name>.nix |
| Theme | modules.theme.active set in host modules.nix |
| Dotfiles path | toString self at build time; $DOTFILES_HOME at runtime (set by shell init) |
Runtime DOTFILES_HOME
DOTFILES_HOME is set by /etc/zshenv (generated by modules/hey.nix) and
points to the flake store path. It is a runtime convenience for Janet CLI tools
and shell scripts — not a build-time input. No --impure needed.
For more context, see Toolchain & System Integration.