I switched to a Nix monorepo for personal projects
For quite a while now I've been using one git repository per project, together with Nix to have a language-independent way to pull dependencies and create per-project environments. The workflow was roughly like this:
cdinto project directory- Type
nix developwhich drops me into a shell with all dependencies available emacs -nwto edit the codegit commit/git pushto submit the changes
A good example of such project is my custom fuzzy launcher. It is a Python-based wrapper around fzf, which is similar to dmenu or rofi, but supports prefix commands.
The reason Nix worked well for the launcher is that in addition to requiring Python, it also needs fzf itself and chafa for image previews. Nix can install such mixed dependencies and also pull them in when the resulting package itself is installed. This would be way harder to do if I used Python-native packaging, as it would need another OS-level packaging tool.
Problem 1: per-project flake.lock files.
If a project is flake based, it contains a flake.nix to describe the build and dependencies, and a flake.lock which freezes all dependencies so that the build is reproducible. This means that every time I initialize a new project, that project would have slightly different versions of library dependencies. So different Python projects may have different versions of the Python interpreter pulled in, and C++ projects will have different versions of the C++ compiler together with a bunch of standard OS libraries.
This is both a good thing and a bad thing. The positive side is that if the project builds now - it will also build in the future, and I can just leave it there. The negative side is explosion in number of dependencies, and wait times for initial nix develop.
Problem 2: integrating projects to NixOS
In addition to using Nix for my side projects, I also run NixOS which uses the same language to describe OS-level configuration. So for the launcher mentioned above, I also need to integrate it into the window manager and bind it to a keyboard shortcut.
This is usually done by adding it to the flake.nix input in NixOS like this:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/release-25.05";
q-py.url = "git+https://git.knazarov.com/knazarov/q.py.git";
q-py.inputs.nixpkgs.follows = "nixpkgs";
...
};
...
}
And then editing the window manager configuration, which I'll skip for brevity.
The interesting bit here is q-py.inputs.nixpkgs.follows = "nixpkgs";, which means that the Nix package repository version used for the operating system is getting "pushed" to the specific flake during evaluation. So when the launcher depends on fzf and some other software depends on fzf - they would use exactly the same version (the one which is reachable from the OS-level flake.lock file).
This creates an issue with reproducibility: even though I've tested the software with nix develop and nix build, when I install it to the system, I'm getting different dependencies. The only way around it is to bump nixpkgs everywhere synchronously one by one (or write a script that would do that).
Another inconvenience is that every time I want to test a change to the launcher with the rest of the system, I have to make a commit to it, push that commit to remote repository, then go to my NixOS config, bump the input, and then do a rebuild. This debug loop has proven to be very annoying, and the only workarounds are to re-point the .url of the input to a local directory or to use a registry.
Solution: NixOS monorepo
I struggled with multiple repositories for a while until a simple thought popped up in my head: if nixpkgs contains recipes for building all kinds of software in one repository, as well as packages for setting up and configuring system services - then maybe I can do the same? The only difference would be that my repository would contain the code for my apps in addition to build and configuration instructions.
This is what I ended up doing:
- Used my
nixosrepo as a base, but moved the OS-level configuration to thenixos/subdirectory - Added
apps/directory to the top level. Every subdirectory there is a separate project apps/also containsdefault.nixwhich walks every subdirectory and adds any Nix derivations from there to one common overlay- The common overlay with all apps is made available by default via
pkgs., the same way as any other packages
This allows me to define Sway config like this:
wayland.windowManager.sway = {
enable = true;
config = {
keybindings = mkOptionDefault {
"Mod4+space" =
"exec ${pkgs.foot}/bin/foot -T mylauncher -a mylauncher ${pkgs.q-py}/bin/q.py";
};
...
};
};
Here I bind my launcher pkgs.q-py to Mod4+space. This is cool because when the package definition and source code live in the same repo with NixOS configuration, I can change the code of q-py, do ./switch.sh and the desktop will be live-reloaded to use the new launcher.
Also, I no longer need to commit any in-progress work to test things out. Nix is perfectly happy to apply any local changes so long as the files are tracked with git.
On top of that, any of the apps derivations are exposed from the top-level Nix flake in packages, so if I do nix flake show, the output is something like:
git+file:///home/knazarov/dev/nixos
├───nixosConfigurations
│ ├───framework: NixOS configuration
│ ├───knazarovcom: NixOS configuration
│ ├───mira: NixOS configuration
│ └───videos: NixOS configuration
├───overlay: Nixpkgs overlay
└───packages
└───x86_64-linux
├───bandcamp-downloader: package 'bandcamp-downloader-0.1.0'
├───dl-torrent: package 'dl-torrent'
├───feedback: package 'feedback'
├───knazarovcom: package 'knazarov.com-0.1.0'
├───my-qmk: package 'qmk-1.1.7'
├───notes-py: package 'notes-py'
├───nuphy-bin: package 'nuphy-bin'
├───q-py: package 'q-py'
├───rve: package 'rve-0.1.0'
├───tmux-lazygit: package 'tmux-lazygit'
├───tmux-logger: package 'tmux-logger-0.1.0'
├───tmux-session-picker: package 'tmux-session-picker'
└───valeri: package 'lisp-0.1.0'
If I want to work on q-py, I can just do:
nix develop .#q-py
This opens a development shell with all dependencies of q-py in place. What's left is just cd into apps/q-py and launch an editor. But even for this there's a shortcut: direnv in combination with nix-direnv.
In all of my apps I keep an .envrc that looks something like this:
use flake .#q-py
So when I cd into the directory of a particular app, it automatically activates the environment of that app in the current shell. My editor also has a direnv plugin which automatically picks up compilers and linters from the specific app's environment.
Why it works for me but might not work for you
The workflow I described will suit somebody who exlusively uses NixOS and writes software mostly for their own amusement but not for getting viral on GitHub or building a community around something.
On one hand, a monorepo gives me ultimate integration between components and makes starting new experiments really easy. With this setup I don't need a CI service because one command builds everything.
On the other hand, anybody else who wants to check out my code will have to fetch the entire repository and find ways to make things run on their operating system of choice. Because there's no packaging for anything except NixOS, they would have quite a bit of trouble deciphering unfamiliar build instructions. Also, many devs are just used to one project per repository and landing on a monorepo would be at best amusing for them.