Introduction
NixOS is the evolution & natural outcome of Nix — where if you can build
software reliably, reproducibly, & across different machines, but instead of a
collection of software + a bootstraping process can build an operating system.
One issue beginners struggle with is the nix-channel command[*]_ which is
stateful to the machine in question which can lead to drift that either hard to
reproduce on this specific machine, but becomes next to impossible when talking
about multiple machine building or being deployed. The solution goes back to the
dawn of Nixpkgs: input pinning. While this can be done with something as
simple as builtins.fetchTarball — which requires no experimental Nix
features or even Nixpkgs — it becomes tedious to update this over time, which is
why ‘input pinner’ exist for Nix. The input pinner demonstrated in this post
will be Nixtamal, which offer a unique feature set compared to the
alternatives, & does not require any experimental, unsound features so you can
rely on it[*].
The code mentioned here reflects a specific to the point in time that this is written. Without the lockfile, we can expect things to be slightly different in the future as well as slight differences to the Nixtamal APIs.
The non-trivial NixOS install
For this build, we want a VM that includes
|
Program/service |
Description |
Why? |
|---|---|---|
|
managing user-level program + services |
Common project, enables home-manager modules |
|
|
compact, locally-run language model (can’t be bothered to care more than that for this demo) |
Just a file, shows off
|
|
|
minimal coding agent TUI |
Not in Nixpkgs, contains an overlay + a home-manager module, can be used to ‘test’ the LLM |
|
|
Nixtamal |
Nix input pinner with
unstable features
(recommend |
Shows Darcs fetching (impossible with current alternative input pinners) |
NixOS 26.05 brings us system.nix
In the Nix 26.05 release notes, system.nix is introduced as
an alternative entry point to configuration.nix (and flake.nix) that allows configuring NixOS without using
nix-channel. This file must evaluate to a NixOS system derivation or an attribute set of such derivations, in which case the attribute to build has to be selected with the--attroption ofnixos-rebuildornixos-install.
This is just the sort of thing that anyone looking for a simple setup would need to make our their system entrypoint!
There were other ways to achieve similar end outcomes prior to system.nix,
but system.nix is the better supported, shipped-with-nixpkgs, thought out,
recommended way to do this.
Setting up Nixtamal
Let’s start with picking a VCS (there are many options besides Git). I will go with Pijul for this project.
$ pijul init Repository created at $XDG_PROJECTS_DIR/nixcfg
Now time to set up Nixtamal…
$ nixtamal set-up --use-mirrors --nixpkgs-channel nixpkgs-unstable ┏┓╻+╻ ╱┏┳┓┏┓┏┳┓┏┓╻ ┃┃┃┃┗━┓╹┃╹┣┫┃┃┃┣┫┃ ╹┗┛╹╱ ╹ ╹ ╹╹╹ ╹╹╹┗┛ Fetching fresh value for 「nixpkgs」 … Prefetching input 「nixpkgs」 … (this may take a while) Prefetched 「nixpkgs」. Making manifest file @ version:1.5.0
A basic VM setup
With just nixpkgs we can already have the power to build many types of
outputs. Of course there is actual system setups, ISO images, but also various
types of images. Let’s start by building a QEMU image on our host machine.
$ mkdir -p host/vm $ touch host/vm/{configuration,hardware-configuration,system}.nix
Let’s start with our system.nix which is used as our entrypoint
# host/vm/system.nix { system ? builtins.currentSystem, specialArgs ? { }, }: let inputs = import ../../nix/tamal { }; in import "${inputs.nixpkgs}/nixos" { inherit system; configuration = { imports = [ ./configuration.nix ]; # To get a nice nixos-rebuild experience from anywhere # https://discourse.nixos.org/t/curious-about-system-nix/77977/9 nix.nixPath = [ "nixos-config=${./configuration.nix}" "nixos-system=${./system.nix}" "nixpkgs=/run/current-system/nixpkgs" ]; system.systemBuilderCommands = ''ln -s "${inputs.nixpkgs}" "$out/nixpkgs"''; }; }
Our configuration.nix is where we put our main configuration for our system.
In our case, we will set the default keyboard layout & shell + allow SSH
access to the VM.
# host/vm/configuration.nix { pkgs, lib, ... }: { imports = [ ./hardware-configuration.nix ]; system.stateVersion = "26.11"; networking.hostName = "nixtamal-system"; # QWERTY is for non-nerds 🤢 console.keyMap = "dvorak"; programs.fish.enable = true; users.users.toastal = { isNormalUser = true; createHome = true; extraGroups = [ "power" "wheel" ]; initialPassword = "pa$$word"; shell = pkgs.fish; openssh.authorizedKeys.keyFiles = [ (pkgs.fetchurl { url = "https://codeberg.org/toastal.keys"; hash = "sha256-DAir5keAHM13XDi2dKe5rS97DSeTSnqmOZODsTgUgvw="; }).outPath ]; }; services.openssh = { enable = true; openFirewall = true; settings = { PasswordAuthentication = false; KbdInteractiveAuthentication = false; PermitRootLogin = "no"; }; }; # Settings for the VM only virtualisation.vmVariant = { virtualisation.forwardPorts = [ { from = "host"; host.address = "127.0.0.1"; host.port = 2222; guest.port = 22; } ]; security.sudo.wheelNeedsPassword = false; }; }
As a stand in for what would normally be the NixOS hardware scan, we will put virtualization constraints…
# host/vm/hardware-configuration.nix { ... }: { virtualisation.vmVariant = { virtualisation = { memorySize = 8192; cores = 8; diskSize = 4096; }; }; }
Lastly, we build + run the VM
$ nixos-rebuild build-vm --file host/vm building the system configuration... Done. The virtual machine can be started by running /nix/store/xb37cz4naf8m2skxa3qa2gf7579zbgjd-nixos-vm/bin/run-nixtamal-system-vm $ /result/bin/run-nixtamal-system-vm Starting virtiofs daemons... $ kitty +kitten ssh -p 2222 toastal@localhost uname -a Linux nixtamal-system 6.18.59 x86_64
Whose output is different than the host:
$ uname -srmn Linux manju 7.2.13 x86_64
Adding Home Manager + Nixtamal
Home Manager is a common tool in in the Nix community which
is a Nix-powered tool for reproducible management of the contents of users’ home directories. This includes programs, configuration files, environment variables and, well… arbitrary files.
In the next repository for Nixtamal there is now a module for Home Manager.
All it does now is enable programs.nixtamal.{enable,package}, but it will be
built on in the future. Let’s test it now by
-
Adding inputs for Home Manager & Nixtamal
-
Wiring them up as
imports+overlays -
Create the Nixtamal demo project from the
releaseblueprint
Adding new inputs
$ nixtamal tweak
This will open our manifest.kdl with $EDITOR, your environment-configured editor
The reason we prefer a text file is that over time, it’s always best to bet on text which is easier to tweak than remembering & query a ton of CLI incantations to all of the possible configuration options.
// ┏┓╻+╻ ╱┏┳┓┏┓┏┳┓┏┓╻ // ┃┃┃┃┗━┓╹┃╹┣┫┃┃┃┣┫┃ Read the manpage: // ╹┗┛╹╱ ╹ ╹ ╹╹╹ ╹╹╹┗┛ $ man nixtamal-manifest version "1.5.0" inputs { nixpkgs { archive { url "https://releases.nixos.org/nixpkgs/{{fresh_value}}/nixexprs.tar.zst" mirrors "https://mirrors.tuna.tsinghua.edu.cn/nix-channels/releases/nixpkgs-unstable%40{{fresh_value}}/nixexprs.tar.zst" extension tar.zst } hash algorithm=SHA-256 fresh-cmd { $ curl -Ls --http2 --compressed -o "/dev/null" -w "%{url_effective}" "https://channels.nixos.org/nixpkgs-unstable" | awk "-F/" "{print $NF}" } } home-manager { archive { url "https://github.com/nix-community/home-manager/archive/{{fresh_value}}.tar.gz" } fresh-cmd { $ git ls-remote --heads "https://github.com/nix-community/home-manager.git" --refs "refs/heads/master" | cut -f1 } } nixtamal { // IMPORTANT: other pinning tools can’t support the Darcs kind! darcs { // next isn’t mirrored, & we will be using the latest code repository "https://darcs.toastal.in.th/nixtamal/next/" } fresh-cmd { $ curl -sL --insecure "https://darcs.toastal.in.th/nixtamal/next/_darcs/weak_hash" } } }
Which after saving + quitting, requires locking…
$ nixtamal lock INPUTS ✓ nixpkgs (Archive) Fresh ✓ home-manager (Archive) Prefetched ✓ nixtamal (Darcs) Prefetched
The wiring
# host/vm/system.nix { system ? builtins.currentSystem, specialArgs ? { }, }: let inputs = import ../../nix/tamal { }; in import "${inputs.nixpkgs}/nixos" { # OMITTING configuration the same as the prior section configuration = { imports = [ (import "${inputs.home-manager}/nixos") ]; nixpkgs.overlays = [ (import "${inputs.nixtamal}/nix/overlay") ]; home-manager = { useGlobalPkgs = true; useUserPackages = true; sharedModules = [ (_: { home.stateVersion = "26.11"; }) (import "${inputs.nixtamal}/nix/module/home-manager.nix") ]; }; }; }
# host/vm/configuration.nix { pkgs, lib, ... }: { # OMITTING configuration the same as the prior section home-manager.users.toastal = { programs = { nixtamal.enable = true; }; }; }
Testing it out
$ nixos-rebuild build-vm --file host/vm building the system configuration... Done. The virtual machine can be started by running /nix/store/b8lzbmrhyxk9259mb8nmiyib3f2njlav-nixos-vm/bin/run-nixtamal-system-vm $ ./result/bin/run-nixtamal-system-vm Starting virtiofs daemons...
TODO: nixtamal demo
Setting up a local LLM
I have some complicated feelings towards LLMs (many of which not positive) but since we can’t put Pandora back in the Box, one step in better direction it to at least self-host & run them locally to stop sharing data with Big Tech.
To do this we will need some new abilities
-
Adding a new, patched input
-
Adding a file input with a
fresh-cmdto avoid needless refetching of a large file -
Threading
inputsthru a system config -
Creating a new module who setting system & home configuration
Adding zerostack + a small LLM
$ nixtamal tweak
version "1.5.0" // OMITTING configuration the same as the prior sections inputs { zerostack { archive { url "https://github.com/gi-dellav/zerostack/archive/{{fresh_value}}.tar.gz" } // Our patch patches zerostack-hm // This gets the latest stable release (doesn’t match: rc, beta, alpha) fresh-cmd { $ git ls-remote --tags "--sort=v:refname" "https://github.com/gi-dellav/zerostack.git" | grep -oP "refs/tags/\\Kv([0-9]+\\.)+[0-9]+(?:\\^{})?$" | tail -n1 | sed "s/\\^{}//" } } LFM2.5-350M-Q4_0 name=LFM2.5-350M-Q4_0.gguf { file { url "https://huggingface.co/LiquidAI/LFM2.5-350M-GGUF/resolve/{{fresh_value}}/LFM2.5-350M-Q4_0.gguf" } fresh-cmd { $ curl -fsL "https://huggingface.co/api/models/LiquidAI/LFM2.5-350M-GGUF/commits/main?path=LFM2.5-350M-Q4_0.gguf" | jq -r ".[0].id" } } } patches { // Open pull request to add a home-manager module zerostack-hm "https://patch-diff.githubusercontent.com/raw/gi-dellav/zerostack/pull/326.patch" }
$ nixtamal lock INPUTS ✓ nixpkgs (Archive) Fresh ✓ LFM2.5-350M-Q4_0 (File) Prefetched ✓ home-manager (Archive) Fresh ✓ nixtamal (Darcs) Fresh ✓ zerostack (Archive) Patched PATCHES ✓ zerostack-hm Prefetched
Wiring it all up
Adding zerostack’s overlay + Home Manager module as well as threading inputs
thru specialArgs + extraSpecialArgs (as a final output, these variables are
fine if not preferred way of doing this)
# host/vm/system.nix { system ? builtins.currentSystem, specialArgs ? { }, }: let inputs = import ../../nix/tamal { }; in import "${inputs.nixpkgs}/nixos" { # OMITTING configuration the same as the prior section specialArgs = specialArgs // { inherit inputs; }; configuration = { nixpkgs.overlays = [ (import "${inputs.zerostack}/nix/overlay") ]; home-manager = { extraSpecialArgs = { inherit inputs; }; sharedModules = [ (import "${inputs.zerostack}/nix/module/home-manager.nix") ]; }; }; }
Then making our new local-llm-stuff.nix file…
# host/vm/local-llm-stuff.nix { config, lib, inputs, ... }: let cfg = config.services.llama-cpp; in { services.llama-cpp = { enable = true; settings = { model = "${inputs."LFM2.5-350M-Q4_0"}"; alias = "lfm2.5-350m"; host = "127.0.0.1"; port = 8550; ctx-size = 8192; jinja = true; temp = 0.1; top-k = 50; repeat-penalty = 1.05; threads = 8; }; }; home-manager.sharedModules = [ (_: { programs.zerostack.settings = { api_keys.openai = lib.mkDefault "sk-no-key-required"; custom_providers.lfm-local = { model = cfg.settings.alias; provider_type = "openai"; base_url = "http://${cfg.settings.host}:${toString cfg.settings.port}/v1"; api_style = "completions"; }; }; }) ]; }
Rounded out by importing the new file & doing our zerostack settings
# host/vm/configuration.nix { pkgs, lib, ... }: { # OMITTING configuration the same as the prior section imports = [ ./local-llm-stuff.nix ]; home-manager.users.toastal = { programs = { zerostack = { enable = true; settings = { provider = "lfm-local"; model = "lfm2.5-350m"; default_prompt = "ask"; context_window = 8192; max_tokens = 512; no_tools = true; no_context_files = true; show_reasoning = true; compact_enabled = false; enable-exa-mcp = false; }; }; }; }; }
Testing out the LLM using llama-cpp + zerostack
Refire up the VM…
$ build-vm --file host/vm building the system configuration... Done. The virtual machine can be started by running /nix/store/kbb433aprga2qwn85r8phvbfi5lb1srb-nixos-vm/bin/run-nixtamal-system-vm
TODO: llama-cpp zerostack
Our final basic project layout
The file structure now looks like this
$ tree . . ├── host │ └── vm │ ├── configuration.nix │ ├── hardware-configuration.nix │ ├── local-llm-stuff.nix │ └── system.nix └─── nix └── tamal ├── darcs_context │ └── nixtamal.txt ├── default.nix ├── lock.json └── manifest.kdl 5 directories, 8 files
With the effictive Nixtamal config looking like:
$ nixtamal show nixpkgs: (archive) name: nixpkgs-src fetch-time: build url: https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1103464.f05535a0efcb/nixexprs.tar.zst mirror: https://mirrors.tuna.tsinghua.edu.cn/nix-channels/releases/nixpkgs-unstable%40nixpkgs-26.11pre1103464.f05535a0efcb/nixexprs.tar.zst fresh-cmd: $ curl -Ls --http2 --compressed -o /dev/null -w %{url_effective} https://channels.nixos.org/nixpkgs-unstable | awk -F/ {print $NF} fresh-value: nixpkgs-26.11pre1103464.f05535a0efcb hash-algorithm: SHA-256 hash-value: sha256-8VGQQhZfWvvWCoqOf5lb8AzUVbKJKvnihiXwJKKPH0s= frozen: false LFM2.5-350M-Q4_0: (file) name: LFM2.5-350M-Q4_0.gguf fetch-time: build url: https://huggingface.co/LiquidAI/LFM2.5-350M-GGUF/resolve/657e078c94084481950a2d555a941481f715536b/LFM2.5-350M-Q4_0.gguf fresh-cmd: $ curl -fsL https://huggingface.co/api/models/LiquidAI/LFM2.5-350M-GGUF/commits/main?path=LFM2.5-350M-Q4_0.gguf | jq -r .[0].id fresh-value: 657e078c94084481950a2d555a941481f715536b hash-algorithm: SHA-256 hash-value: sha256-heMoWNqvrVW3vNa5ehND7gZhGI6ANvmGLRTWtWMUL1A= frozen: false home-manager: (archive) name: home-manager-src fetch-time: build url: https://github.com/nix-community/home-manager/archive/095fef2f265c3c0da4fe315efba8f8a04495ab34.tar.gz fresh-cmd: $ git ls-remote --heads https://github.com/nix-community/home-manager.git --refs refs/heads/master | cut -f1 fresh-value: 095fef2f265c3c0da4fe315efba8f8a04495ab34 hash-algorithm: SHA-256 hash-value: sha256-X6xa6sGA7RnJQuZGbFMC+K+KToWAlBDCCjJXfTwEPsw= frozen: false nixtamal: (darcs) name: nixtamal-src repository: https://darcs.toastal.in.th/nixtamal/next/ context (assumed): ./darcs_context/nixtamal.txt datetime: 2026-10-28T08:59:43Z latest-weak-hash: 32bac0ef5748e74767ca469a94171d125b8d00e1 fresh-cmd: $ curl -sL --insecure https://darcs.toastal.in.th/nixtamal/next/_darcs/weak_hash fresh-value: 32bac0ef5748e74767ca469a94171d125b8d00e1 hash-algorithm: SHA-256 hash-value: sha256-+nKMdhLrAhG4MYn8aFab8c7hFRmZdhSJTxbtLthMdoY= frozen: false zerostack: (archive) name: zerostack-src fetch-time: build url: https://github.com/gi-dellav/zerostack/archive/v1.8.5.tar.gz fresh-cmd: $ git ls-remote --tags --sort=v:refname https://github.com/gi-dellav/zerostack.git | grep -oP refs/tags/\Kv([0-9]+\.)+[0-9]+(?:\^{})?$ | tail -n1 | sed s/\^{}// fresh-value: v1.8.5 hash-algorithm: SHA-256 hash-value: sha256-i6J+7OxXa3tD0Yq/yfHmMPf5kMn51tWvaXYzgRj6YS8= frozen: false patches: zerostack-hm
Version control / source control
Now that we have a working configuration, Nix’s declarative configuration means we can get one of great windfalls of software, version control, where we can track our changes easily over time. Now that we have Nixtamal, we can be trust in using more alternatives VCSs — not limited to only Git. If you recall, we set up Pijul earlier, but if not, let’s set it up & add our files (+ some other project-related files)…
$ pijul init Repository created at $XDG_PROJECTS_DIR/nixcfg $ pijul add README.rst LICENSE.txt .editorconfig .ignore Tracked 4 path(s) $ pijul record -m "project onset" Hash: IOHNVGAOGSGW5G3V3F27JPKRA4JTGMB4Y23BXBMOGBNLNUYUN2TQC $ pijul add -r nix/ host/ Tracked 2 path(s) $ pijul record -m "nixtamal + nixos onset" Hash: QH4JLDSDEJE4XYD7ZDKV7FCV5F4GOV5KJGLCETQZHC33WNBJGQRAC $ pijul push toastal@nest.pijul.com:toastal/nixtamal-nixos-demo ● ● project onset ● nixtamal + nixos onset
Now we can continue to iterate & look back at our changes over time.
Takeaway / flakes aren’t necessary
One way to look at this is we just proved Nixmap, with its heart in the right place, wrong. The Put it in Git route for getting reproducible systems under version control, aside from that task not requiring Git, can totally skip the “Flakes” + “Turn it into a Flake” path. This I often seen is one of the primary reason folks first get into flakes:
-
I want input pinning to make my input reproducible
-
I want my configuration in version control so I can go back to a prior patch when things were working, centralize my Nix configurations to more easily share code/thought, and/or use the VCS to share/deploy my systems …& online discourse incorrectly suggests “then you need a
flake.nix”
There is an alternative path that doesn’t need require experimental features using simpler / more capable tools:
-
Nixtamal can pin our inputs with a ton of features
-
system.nixis an stable-friendly entry point for NixOS