This post has been retroactively added to my "BTW" series, which is a collection of posts about the tools I use and why I use them.
Like most people, I started my computing journey on Windows.
I heard about Linux in middle school, but was honestly completely happy with Windows and never looked into it.
That all changed when a friend introduced me to:
I fell deep into the ricing
community, run mainly on deviantart at the time, and I started to learn about Linux.
The term "ricing" refers to the practice of customizing the appearance of your desktop environment.
It originated from the car culture, where "ricing" refers to modifying a car for aesthetics rather than performance.
While cygwin
was great, eventually I jumped ship to Linux proper and became a terminal distro hopper.
Circa June 2020, I was between projects at my job and a co-worker was talking about nix and I was intruiged.
After a few false starts, needless to say I was hooked.
Goals
Unlike a lot of posts out there commending nix
, I'm by no means an expert.
Theoretically, I barely know enough nix
to be dangerous... but I have used nix
in anger for a few years now and I think I have a pretty good grasp why I like it.
If you're looking for a deep dive into nix
, this post isn't for you, but do check out:
In my experience, you can get away with using nix
without knowing much about the language itself, and I think that's a pretty good thing.
So, let's talk about nix
!
What is Nix?
In short, nix
is a set of tools that solve a lot of problems!
One problem it doesn't solve is being clearly defined, as "nix" can refer to:
For the purposes of this post, I'll try to be clear about which one of these I'm talking about!
If its not clear, reach out and I'll edit this post to clarify!
Installation
If you want to install NixOS, you can do so via the NixOS installation guide.
You can do the same thing for nix
, but this might cause issues on MacOS and requires some configuration.
I recommend this installer instead, which:
- is the easiest way to install
nix
on MacOS. - fixes a lot of MacOS specific issues with the default installer.
- opts into experimental features by default, which is widely recommended.
See this post for more information on why you should enable experimental features.
This post will assume you have the experimental features enabled, but if you don't, you can always enable them later.
The Nix Package Manager
The nix
package manager is a CLI tool that allows you to install and manage packages on your system.
You can think about nix
as being an equivalent to brew
or apt
, but with a few key differences:
- Functional: There is "no global state" to mutate.
- Declarative: You can define your system configuration in a file.
- Cross-platform: It works on Linux, Mac, and Windows (via WSL).
The term "package manager" also refers to a few different concepts in the development world.
There are fuzzy boundaries here, but they feel distinct to me:
- System package management: like
apt
,brew
,pacman
, etc. - Language package management: like
npm
,pip
,gem
, etc. - Project package management: like
asdf
,rvm
,pyenv
, etc.
The cool thing about nix
is that it can do all of these things, and more!
Basic Usage
If you're new to nix
, the following commands should feel familiar:
# To search for a package
nix search nixpkgs firefox
# To install a package
nix-env --install firefox
# To uninstall a package
nix-env --uninstall firefox
Whenever you install packages via nix
, the following things happen:
- looks up package in
nixpkgs
. - builds the package in
/nix/store/
. - updates your
PATH
to include the package.
Do note that using nix-env
to install packages it a bit of an anti-pattern.
You're opting out of the functional and declarative nature of nix
by doing so.
Let's talk a bit about what that means.
Functional Package Management
The functional nature of nix
gives you three things:
- Reproducibility: package versions, dependencies, configuration will always be the same.
- Isolation: packages are installed in a way that they don't interfere with each other.
- Rollbacks: if you install a package that breaks your system, you can roll back to a previous generation of your system configuration.
Note that this is a bit of a simplification, but it should be enough to get you started.
Basically, when nix
installs a package, the path the package is installed to is unique to that package and version.
This is done by hashing the package name, version, and dependencies together to create a unique path.
Because of this, you get some superpowers:
- You can have multiple versions of the same package installed at once.
- The current "state" of your system is managed via symlinks.
- Rolling back is just changing symlinks to point to a different version of the package.
You can even "preview" or temporarily installing packages in a transient shell without impacting anything else.
Try running
nix-shell -p <package>
to see what I mean!This throws you into a new
bash
shell with your package installed.
Declarative Package Management
In addition to being functional, nix
is also declarative.
Basically: you tell nix
what you want, and it figures out how to get there.
There are two main mechanisms for doing this:
- via a
shell.nix
file - via an experimental feature called
flakes
Nix Shells
You can think about a shell.nix
file as a way to define a "project" or "environment" that you want to work in.
For example, if you're working on a project that requires nodejs
, python
, and git
, you can create a shell.nix
file that looks like this:
{ pkgs ? import <nixpkgs> {} }:
with pkgs; mkShell {
buildInputs = [
nodejs
python3
git
];
}
Then, when you run nix-shell
, it will create a new shell with those packages installed.
You can also add environment variables, shell hooks, and other configuration to the shell.nix
file.
I'd recommend using direnv and lorri to automatically enter the shell whenever you
cd
into a directory with ashell.nix
.
The issue with using this approach is that it relies on something called channels
.
A channel is a way to point nix
to a specific version of nixpkgs
, which is the package repository for nix
.
The problem is that channels are imperatively managed global state. If you update your channel, it will update all the packages in your system:
# List your channels (check channel versions, third party repos)
nix-channel --list
# Update your channels (like running apt-get update && apt-get upgrade)
nix-channel --update
Thankfully, flakes
are the solution to this!
Nix Flakes
flakes
are a new way to manage nix
packages and configurations.
The main differences for package management are:
- They specify inputs, i.e. the repositories you want to use.
- They come with a lock file that locks the exact versions of the packages you're using.
This means that any two people using the same flake.nix
and flake.lock
will get the same versions of the packages, regardless of what version of nixpkgs
they have installed.
An example flake.nix
file for installing packages might look like:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs, ... }:
let
pkgs = import nixpkgs { system = "x86_64-linux"; };
in
{
packages.x86_64-linux.default = pkgs.mkShell {
buildInputs = [
pkgs.firefox
pkgs.git
];
};
};
}
This flake.nix
file will create a shell with firefox
and git
installed, and the versions of those packages will be locked in the flake.lock
file.
You can run nix develop
to enter the shell, and nix run
to run a command in the shell.
Likewise, direnv will automatically enter the shell whenever you
cd
into a directory with aflake.nix
.This basically allows you to use
nix
as a replacement forasdf
,rvm
,pyenv
, etc.
That leads us nicely on to the next topic: NixOS.
The NixOS Operating System
Because the nix
package manager can declaratively manage packages, it can also declaratively manage your entire system configuration.
This is the entire premise of NixOS, for example:
{ config, lib, pkgs, ... }:
{
imports = [ hardware-configuration.nix ];
environment.systemPackages = with pkgs; [ firefox git ];
networking.hostName = "my-hostname";
services.sshd.enable = true;
}
This configuration.nix
file will install firefox
and git
, set the hostname to my-hostname
, and enable the SSH daemon.
Just like the package management examples, this builds your new system configuration into the /nix/store/
and symlinks the current state into /etc/
.
The nixos-rebuild switch
command will then apply the configuration and update your system.
Note:
nixos-rebuild
is a bit of a misnomer, as it doesn't actually rebuild your entire system every time you run it.It will only rebuild the parts of your system that have changed, and it will use the cached versions of the packages if they haven't changed.
Because nix-lang
is a fully-fledged programming language, you can extend or abstract your config via modules.
Some modules I recommend include:
- home-manager for declarative dotfiles.
- nix-darwin to manage MacOS declaratively.
Using modules, I've been able to abstract and share modules between my laptops, workstations, home servers, and even my family's machines.
I can even push updates to my modules and update my family's machines remotely.
Check out my dotfiles.
Because of how NixOS manages your system, you can also roll back to a previous generation of your system configuration with ease.
# List your generations
nixos-rebuild list-generations
# Rollback to a previous generation
nixos-rebuild switch --rollback
However, this leads to one little problem...
The Filesystem Hierarchy Standard
The Filesystem Hierarchy Standard (FHS) is a set of guidelines that define the directory structure and directory contents in Unix-like operating systems.
The FHS defines where things should go on the filesystem, and gives some interoperability guarantees when it comes to installing packages.
By this, I mean you can install a package from the internet (i.e.
curl | sh
) and expect it to work.Assuming you have the right dependencies installed, of course.
This... isn't the case with NixOS. There is no real /bin
, /lib
, etc. on NixOS.
Instead, everything is managed via the /nix/store/
directory, which is symlinked into the filesystem as needed.
Generally speaking, that means non-NixOS packages won't work out of the box, and you'll need to use
nix
to install them.You can use hacks like
steam-run
which provides a FHS-like environment for running non-NixOS packages, but YMMV.
The good news is that nixpkgs
is the largest software repository in the world, and writing your own package definitions is pretty easy.
In my experience, I've never had to write a package definition. I have, however, had to write a few nix
modules to manage services and configuration.
Modules
On my various servers, one component I always reach for is a reverse proxy.
This is a great use case for NixOS modules, as I've written a simple module that:
- Installs nginx.
- Manages my firewall rules.
- Generates reverse proxy rules for me.
It means that in any of my servers, I can configure a reverse proxy as simply as:
imports = [
modules/services/proxy.nix
]
modules.proxy = {
enable = true;
openFirewall = true;
proxies = {
"sonarr.vereis.com" = 8989;
"radarr.vereis.com" = 7878;
"prowlarr.vereis.com" = 9696;
"transmission.vereis.com" = 9091;
"lidarr.vereis.com" = 8686;
"readarr.vereis.com" = 8787;
"printer.vereis.com" = 631;
};
};
The implementation of the module is pretty simple too, as it's a simply wrapper around the standard nginx
module in nixpkgs
.
{ pkgs, lib, config, ... }:
with lib;
{
options.modules.proxy = {
enable = mkOption { type = types.bool; default = false; };
openFirewall = mkOption { type = types.bool; default = false; };
firewallPorts = mkOption { type = types.listOf types.port; default = [ 80 443 ]; };
proxies = mkOption { type = types.attrsOf types.port; default = { }; };
};
config = mkIf config.modules.proxy.enable {
networking.firewall.allowedTCPPorts = mkIf config.modules.proxy.openFirewall config.modules.proxy.firewallPorts;
security.acme.acceptTerms = true;
security.acme.defaults.email = "contact@vereis.com"
services.nginx = {
enable = true;
virtualHosts =
builtins.mapAttrs (host: port: {
enableACME = true;
forceSSL = true;
locations."/".proxyPass = "http://127.0.0.1:${toString port}/";
}) config.modules.proxy.proxies;
};
};
}
One interesting thing to note is that
nix-lang
is lazily evaluated.If multiple modules set the same configuration, say,
allowedTCPPorts
,nix
will merge them together into a single system config.
Managing Multiple Machines
One of the great things about NixOS is the ability to manage and reuse your configuration across multiple machines.
For this post, I'll be focusing on doing this with
flakes
.If you're not using
flakes
, you can still copy the module system I talk about later, but you'll need to manually symlink differentconfiguration.nix
files to/etc/nixos/configuration.nix
for each machine.
For example, I have a few different machines:
- My main workstation
madoka
currently running Windows. - My Dell XPS 15
homura
running NixOS. - My homelab server
kyubey
running NixOS. - My MacBook Pro
iroha
running MacOS. - My Surface Pro X
mami
running Windows ARM.
I use nix
to manage all of these machines, and I can easily share my configuration between them.
Thanks to WSL
, the configuration for all my Windows machines is practically the same as the configuration for my Linux machines.
And thanks to nix-darwin
, I can use the same configuration for my MacOS machine.
The only differences are using
nixos-wsl
for WSL andnix-darwin
for MacOS to manage the system configuration.For example,
nix-darwin
let's you managebrew
casks and MacOS services declaratively.
You can check out what my top-level flake.nix
looks like:
{
description = "Vereis' NixOS configuration";
inputs =
{
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nix-homebrew.url = "github:zhaofengli-wip/nix-homebrew";
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-wsl = {
url = "github:nix-community/NixOS-WSL";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs @ { self, nixpkgs, nixos-wsl, home-manager, ... }:
let
username = "vereis";
in
{
darwinConfigurations = (
import ./machines/darwin { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager nix-darwin, nix-homebrew; }
)
nixosConfigurations = (
import ./machines/linux { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager username; }
import ./machines/windows { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager nixos-wsl username; }
);
};
}
Note: when importing a directory,
nix
will assume the directory contains adefault.nix
file.
Here, you can see I'm importing:
nixpkgs
for the package repository.nix-darwin
for managing MacOS.nix-homebrew
forbrew
integration.nixos-wsl
for managing WSL.home-manager
for managing user configuration.
For my personal machines, I differentiate between machines/
configuration, modules/
configuration.
The former is responsible for configuring each machine, and is the entrypoint where I'll start pulling in modules.
The latter is where said modules live. I also differentiate between home/
and services/
modules which are normal applications versus services respectively.
That way, I can easily share modules between machines and reuse them as needed.
Note: you don't have to split things up at all if you don't want to!
I just like ensuring my modules come with all the configuration I need to run them, and I find it easier to manage when they're split up.
Likewise, I tend to re-use my configs between machines so splitting it up makes that easier.
Machine Configuration
The default.nix
file in machines/*
is responsible for delegating configuration based on hostname to a specific config.
{ lib, inputs, nixpkgs, nix-darwin, home-manager, nixos-wsl, username, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
lib = nixpkgs.lib;
in
{
madoka = lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs username; };
modules = [
./madoka
./configuration.nix
nixos-wsl.nixosModules.wsl
home-manager.nixosModules.home-manager {
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.extraSpecialArgs = { inherit username; };
home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./madoka/home.nix)];
}
];
};
homura = lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs username; };
modules = [
./homura
./configuration.nix
home-manager.nixosModules.home-manager {
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.extraSpecialArgs = { inherit username; };
home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./homura/home.nix)];
}
];
};
kyubey = lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs username; };
modules = [
./kyubey
./configuration.nix
home-manager.nixosModules.home-manager {
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.extraSpecialArgs = { inherit username; };
home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./kyubey/home.nix)];
}
];
};
...
}
Note: I've omitted some of the boilerplate for brevity.
In fact, the most recent version of my configuration simplifies this a little so check it out!
The key thing this does is import:
- Global system configuration:
machines/*/configuration.nix
- Configures
nix
itself. - Configures timezone, locales, etc.
- Configures
- Host system configuration:
machines/*/$host/default.nix
- Configures services, hardware, kernel modules, etc.
- Imports my
services
modules.
- Host user configuration:
machines/*/$host/home.nix
- Configures user packages, dotfiles, etc.
- Imports my
home
modules.
The most important part of my own config is the home.nix
files per host, so let's get into that!
Home Manager
As mentioned earlier, home-manager
is a tool for managing your user configuration, dotfiles, and packages.
I use it to install any package that doesn't require a daemon or service to run.
When you use home-manager
to install packages, you get access to declarative configuration, for example:
{ config, lib, pkgs, ... }:
with lib;
{
options.modules.zsh = {
enable = mkOption { type = types.bool; default = false; };
};
config = mkIf config.modules.zsh.enable {
home.packages = with pkgs; [ zsh ];
programs.zsh = {
enable = true;
autocd = true;
enableCompletion = true;
autosuggestion.enable = true;
prezto = {
enable = true;
prompt.theme = "powerlevel10k";
editor.keymap = "vi";
};
};
}
Installing a package is then as simple as importing your module and adding modules.zsh.enable = true;
to your configuration.nix
file.
You can also declaratively source files into your home directory if what you want to do is low-level or not supported by home-manager
.
You can use the following to find supported packages and configuration options! Home Manager Search.
For example, I might want my neovim
config on hosts where I don't have access to nix
, so I source it instead:
{ config, lib, pkgs, ... }:
with lib;
{
options.modules.neovim = {
enable = mkOption { type = types.bool; default = false; };
};
config = mkIf config.modules.neovim.enable {
home.packages = with pkgs; [
stylua sumneko-lua-language-server shellcheck shfmt
vale deno nodePackages.prettier
];
programs.fzf.enable = true;
programs.fzf.enableZshIntegration = true;
home.sessionVariables = {
FZF_DEFAULT_COMMAND = "rg --files | sort -u";
EDITOR = "nvim";
VISUAL = "nvim";
};
home.file.".vale.ini".source = ./neovim/.vale.ini;
home.file.".config/nvim/lua/config.lua".source = ./neovim/config.lua;
home.file.".local/share/nvim/site/pack/packer/start/packer.nvim" = {
source = builtins.fetchGit {
url = "https://github.com/wbthomason/packer.nvim";
ref = "master";
rev = "afab89594f4f702dc3368769c95b782dbdaeaf0a";
};
};
programs.neovim = {
enable = true;
viAlias = true;
vimAlias = true;
vimdiffAlias = true;
withNodeJs = true;
withPython3 = true;
extraConfig = ''
lua require('config')
'';
};
};
}
You can see the home.file."name".source = ...
syntax is used to copy files into the home directory.
This way I can keep specific dotfiles reusable outside of using nix
-- not that I've ever needed this.
However, not everything can be installed via home-manager
...
Service Modules
If you need to install a service or daemon, you'll need to use nixos
modules instead.
Despite the name,
nixos
modules can be used on any system that usesnix
, including MacOS and WSL.
My previously shared proxy.nix
module above is a good example of this. Note that this is enabled the same way as home/
modules,
namely:
imports = [
modules/services/proxy.nix
]
modules.proxy = {
enable = true;
openFirewall = true;
proxies = {
"sonarr.vereis.com" = 8989;
"radarr.vereis.com" = 7878;
"prowlarr.vereis.com" = 9696;
"transmission.vereis.com" = 9091;
"lidarr.vereis.com" = 8686;
"readarr.vereis.com" = 8787;
"printer.vereis.com" = 631;
};
};
Superpowers
For me, the ability to manage my entire system configuration in a declarative way is the killer feature of nix
.
The fact that it replaces the need for asdf
, rvm
, pyenv
, etc. is just icing on the cake.
To end off this post, this is all it takes to bootstrap a new machine:
# Install Nix via Determinate Systems
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --determinate
# Temporarily install `git` so I can clone my dotfiles
nix-shell -p git
# Clone my dotfiles
git clone https://github.com/vereis/nix-config
cd nix-config
sudo nixos-rebuild switch --flake .#$HOSTNAME
If my projects or job have a flake.nix
in them, entering those directories automatically bootstraps a dev environment for me.
And is literally just works no questions asked. I genuinely believe nix
is the future.
I hope you try it out! If nothing else, it's a new thing to learn!