Nix language primer
You do not need to write much Nix to use Ternix, but being able to read it makes editing a generated config painless. This page covers the constructs you will actually encounter in a NixOS or Home-Manager file, with a runnable example for each. Every construct is drawn from the official Nix language tutorial .
Types at a glance
The constructs below all operate on a small set of value types. Here is what each one looks like.
"hello" # string
''
multi
line
'' # multi-line string (leading indentation is stripped)
42 # integer
3.14 # float
true # boolean
null # null
/etc/nixos # path (no quotes needed)
./relative # path relative to the filePaths are first-class values in Nix, which is why you see bare /etc/nixos
without quotes in import statements.
Attribute sets
The core data structure is the attribute set, built from name-value pairs inside braces, each terminated with a semicolon. A configuration file is, at its top level, one large attribute set.
{
hostName = "nixos-host";
enable = true;
port = 8080;
}Nested attributes can be written with dots. The two forms below are identical.
{ services.openssh.enable = true; }
{ services = { openssh = { enable = true; }; }; }Both styles appear in real configs. The dotted form is common for setting a single leaf, and the nested brace form is common when configuring several sibling attributes at once.
Recursive sets
A rec set lets attributes refer to each other by name.
rec {
port = 8080;
url = "http://localhost:${toString port}";
}Without rec, port would be out of scope inside the same set. You will see
rec most often in module-level definitions that build one value from another.
Lists
Lists are space-separated values inside square brackets. There are no commas.
environment.systemPackages = [ pkgs.git pkgs.firefox pkgs.htop ];A multi-line list is fine too, and often easier to diff.
environment.systemPackages = [
pkgs.git
pkgs.firefox
pkgs.htop
];let ... in
let binds local names that are in scope for the expression after in. It is
the main way to avoid repeating yourself.
let
user = "ada";
home = "/home/${user}";
shell = pkgs.bash;
in {
users.users.${user} = {
isNormalUser = true;
home = home;
shell = shell;
};
}let bindings are mutually recursive. Every name is in scope for every other
binding in the same let, no matter what order they are written in. So
let a = b + 1; b = 5; in a evaluates to 6 without error.
Functions
A Nix function is written argument: body. Functions take exactly one argument. Chain them for more.
x: x + 1 # one argument
x: y: x + y # two arguments, applied one at a time (curried)Calling a function is juxtaposition, with no parentheses needed for simple args.
(x: x + 1) 5 # evaluates to 6Module functions
A NixOS or Home-Manager module is a function whose argument is an attribute set. This destructuring form is the signature you see at the top of almost every config file.
{ config, pkgs, lib, ... }:
{
environment.systemPackages = [ pkgs.vim pkgs.git ];
}pkgs is the package collection, config is the fully merged system
configuration, lib is the standard helper library, and ... means "accept and
ignore any additional named arguments". The ... is required whenever the
module system passes arguments your file does not name.
You can give a destructured argument a default with ?.
{ enable ? true, port ? 8080, ... }:
{
services.foo = { inherit enable port; };
}This sets a default for the function argument itself. It is not how you set a
module option's default. Those are declared with lib.mkOption and default =
(see Module option declarations below).
with
with expr; brings the names of an attribute set into scope for the expression
that follows. Package lists commonly use it to drop the pkgs. prefix.
environment.systemPackages = with pkgs; [
git
firefox
ripgrep
fd
];with is lexically scoped. It applies only to the expression immediately after
the semicolon. Nested with bindings shadow outer ones.
inherit
inherit x; is shorthand for x = x;. It copies a name from the surrounding
scope into the current attribute set.
let user = "ada"; in
{
inherit user; # same as: user = "ada";
}inherit (src) a b; copies a and b from src.
inherit (pkgs) git firefox; # same as: git = pkgs.git; firefox = pkgs.firefox;This second form appears frequently when constructing package lists or passing specific attributes to sub-expressions.
String interpolation
${...} embeds any expression inside a string. Numbers and paths need to be
converted to strings first.
let
name = "world";
port = 3000;
in
{
greeting = "hello ${name}";
listenAddr = "0.0.0.0:${toString port}";
configPath = "${./config}/settings.toml"; # path to string
}Multi-line strings use double single-quotes and strip leading indentation automatically, making them useful for embedded scripts or configuration blobs.
services.nginx.extraConfig = ''
server_tokens off;
gzip on;
gzip_types text/plain application/json;
'';The option pattern in practice
Most config editing is setting options that NixOS or Home-Manager modules
already define. By convention, enable turns a feature on, and sibling
attributes configure the details.
services.nginx = {
enable = true;
recommendedGzipSettings = true;
virtualHosts."example.com" = {
root = "/var/www/example";
forceSSL = true;
enableACME = true;
};
};When you see an unfamiliar option, the NixOS option search lists every option, its type, its default, and which module defines it.
Advanced
map
map applies a function to every element of a list and returns a new list. It
appears in configs that build option values programmatically.
let
offsets = [ 0 1 2 ];
in
map (n: 8000 + n) offsets # => [ 8000 8001 8002 ]One realistic use is generating per-user account definitions for users.users from
a list of usernames.
let
users = [ "ada" "bob" ];
in
builtins.listToAttrs (map (u: {
name = u;
value = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
}) users)lib.mkIf
lib.mkIf condition value includes a block only when condition is true. This
is how modules conditionally activate behavior without runtime errors from
undefined options.
{ config, lib, pkgs, ... }:
{
services.openssh.enable = true;
environment.systemPackages = lib.mkIf config.services.openssh.enable [
pkgs.mosh
];
}You will also see lib.mkIf used inside option definitions to conditionally set
entire blocks.
# cfg is the module's own option block, bound near the top of the file
# with `let cfg = config.services.myapp; in { ... }`.
config = lib.mkIf cfg.enable {
services.postgresql.enable = true;
services.postgresql.package = pkgs.postgresql_15;
};Module option declarations
When reading upstream NixOS or Home-Manager module source files, you will
encounter lib.mkOption declarations. These define what options a module
accepts, including their type and default.
options.services.myapp = {
enable = lib.mkEnableOption "myapp service";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port the service listens on.";
};
};You rarely write option declarations when using Ternix. Recognising them lets you read module source without confusion.
Next
For the full language specification, the Nix manual language reference is authoritative. Continue to Flakes or jump straight to Using a config.