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 file

Paths 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 6

Module 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.