Blogg

Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn

Callista medarbetare Henrik Starefors

NixOS meets MacOS

// Henrik Starefors

After starting my NixOS journey and embracing Flakes in my previous two posts, it’s now time to set my eyes on a new frontier: MacOS and Nix-Darwin.

Flakes and Nix-Darwin: MacOS joins the Nix family


On my Macbook, I won’t go as far as replacing MacOS entirely with Nix, the OS, but I will be using Nix, the package manager, together with Home-Manager to set up MacOS just like I have with my desktop.

Most of the configuration I want to handle can be done via these two modules, but some system settings, services, and glue code are out of reach when I’m no longer under the banner of NixOS and its configuration.nix file.

To bridge this gap and bring MacOS into the declarative realm of Nix, it’s time to introduce a new module to the Nix mix: Nix-Darwin.

Nix-Darwin is to MacOS what configuration.nix is to NixOS, and it lets Nix declaratively manage my MacOS machine.

There are a lot of fun things you can do with Nix-Darwin, but in my MacOS setup I’m focusing on:

Nix

While I’m installing Nix using Determinate Systems, I want Nix-Darwin to handle the day-to-day operations. Settings like garbage collection, allowUnfree and experimental features are some of the settings Nix-Darwin will handle for me.

Services

At the moment I have three services running as background daemons: Yabai is my tiled Window manager, Skhd is a hotkey daemon for Yabai, and Tailscale is running my personal VPN.

Homebrew

Not all apps I use are available to install via Nix or Home-Manager. It could be that the package does not support ARM architecture, has build issues, or is simply missing from the Nix repository.

Luckily, where Nixpkgs falls short, Homebrew steps in to retrieve the missing pieces.

MacOS defaults

This is probably the most valuable feature Nix-Darwin provides for me. MacOS defaults is used to control all the settings you normally set using the System Settings menus.

With Nix-Darwin, I no longer have to remember my preferences, and I avoid tedious hours clicking through menus to get everything set up.

With Nix, every little tweak can be catalogued and restored with perfect fidelity, whenever I need to.

Glue-code

Aside from the previously mentioned features, some additional system config like fonts, default editor and shell, host and network name etc. These can be handled via Nix-Darwin, just to tie everything together.

Nix-Darwin, Nix serving the apple


Nix-Darwin

Adding Nix to MacOS requires a couple of things to get things going, all the details can be found here.

But the tl:dr is: A new read-only volume is created where all the Nix packages are installed and symlinked to the rest of the machine.

Nix will also require mount options and a launch daemon, along with some CLI tools, to function smoothly.

To set this up, I chose Determinate Systems to handle the installation, just so I don’t have to worry about setting everything up by hand.

I do want Nix-Darwin to handle nix.* settings once installed, so I select No when prompted to install Determinate Nix, but otherwise proceed as told.

Once in place, the Nix tools are at my fingertips and I can start creating my MacOS configuration.

To get started, I grab my previous config from Github, and start by modifying the main flake.nix file, adding Nix-Darwin as an input to the Flake.

darwin = {
  url = "github:lnl7/nix-darwin";
  inputs.nixpkgs.follows = "nixpkgs";
};

The next step is to add a new darwinConfiguration module, which I call mbp (MacBookPro).


inputs: {
    nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
      # Desktop config
    };


    # new MacOS configuration
    darwinConfigurations.mbp = darwin.lib.darwinSystem {
      system = "aarch64-darwin";
      modules = [
        ./modules/darwin/system.nix
        ./modules/darwin/apps.nix

        home-manager.darwinModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.sharedModules = [
            sops-nix.homeManagerModules.sops
          ];

          home-manager.users.hest = import ./home/darwin.nix;
          home-manager.extraSpecialArgs = {
            inherit inputs;
            system = "aarch64-darwin";
          };
        }
      ];
    };
};

This module will act as the blueprint for my MacOS setup, that tells Nix to create a Darwin flavoured module for the ARM CPU architecture (aarch64-darwin), and points out two files that describe the module configuration:

/modules/darwin/system.nix and /modules/darwin/apps.nix

The Home-Manager.darwinModules.Home-Manager section adds a Home-Manager configuration, pointing out where to look, brings along some input values, and adds a new “shared” Home-Manager module called Sops-Nix, which I will talk about in just a bit.

modules

First, let’s take a look at the module config, starting with apps.nix

{pkgs, ...}: {
  environment.systemPackages = with pkgs; [
    yabai
    skhd
  ];
  environment.variables.EDITOR = "nvim";

  services.tailscale.enable = true;

  # skhd service and config
  services.skhd = {
    enable = true;
    skhdConfig = ''
      ...
      # toggle window split type
      alt - e : yabai -m window --toggle split

      # rotate tree
      alt - r : yabai -m space --rotate 90
      ...
    '';
  };

  # yabai service and config
  services.yabai = {
    enable = true;
    package = pkgs.yabai;
    enableScriptingAddition = true;
    config = {
      focus_follows_mouse = "autoraise";
      window_placement = "second_child";
      window_shadow = "on";
      window_opacity = "off";
      ...
    };
    extraConfig = ''
      sudo yabai --load-sa
      yabai -m signal --add event=dock_did_restart action="sudo yabai --load-sa"
      yabai -m space 1 --label i
      yabai -m space 2 --label ii
      yabai -m space 3 --label iii
      yabai -m space 4 --label iv
      ...
  };

  homebrew = {
    enable = true;

    onActivation = {
      autoUpdate = false;
      cleanup = "zap";
    };

    masApps = {
      Keynote = 409183694;
      MonitorControl-Lite = 1595464182;
      Amphetamine = 937984704;
    };

    brews = [
      "mas"
      "curl" # do not install curl via nixpkgs, it's not working well on MacOS!
    ];

    casks = [
      "mqttx" # not available for ARM CPU
      "microsoft-teams" # old version on Nix
      "microsoft-auto-update"
      "raycast" 
      "sanesidebuttons"
      "keymapp"
      "krita"
      "ghostty"
    ];
  };
}

This is where I put the system-wide packages. I note which services I want running, declare their configurations, and Nix ensures that the configurations for Tailscale, Yabai, and Skhd are enabled and that they all run in the background as daemons.

These services are not managed on a per-user basis but are available for every user on the machine, just like NixOS handles services.

The last section of this file contains a Homebrew module, which is a Nix-Darwin feature that allows Nix to install any missing packages using Homebrew, and even apps from the app store can be installed using the masApps attribute.

Once again, I simply declare what I want, and Nix will use Homebrew to install the packages, and keep them up to date for me.

Next up is the system.nix file:

{
  pkgs,
  lib,
  ...
}: let
  hostname = "mbp";
  username = "hest";
in {
  nix.enable = true;
  nixpkgs.config.allowUnfree = true;
  nix.settings.trusted-users = ["root" username];
  nix.settings.experimental-features = ["nix-command" "flakes"];
  nix.gc = {
    automatic = lib.mkDefault true;
    options = lib.mkDefault "--delete-older-than 1w";
  };

  nix.optimise.automatic = true;

  networking.hostName = hostname;
  networking.computerName = hostname;
  system.defaults.smb.NetBIOSName = hostname;

  users.users."${username}" = {
    home = "/Users/${username}";
    description = username;
    shell = "/etc/profiles/per-user/hest/bin/fish";
  };

  ###################################################################################
  #  MacOS's System configuration
  ##################################################################################
  system = {
    keyboard = {
      enableKeyMapping = true; # enable key mapping so that we can use `option` as `control`
      ...
    };

    defaults = {
      dock = {
        autohide = true;
        show-recents = false; # disable recent apps
        static-only = true;
      };

      finder = {
        ...
      };

      trackpad = {
        ...
      };

      # Customize settings that not supported by nix-darwin directly
      CustomUserPreferences = {
        NSGlobalDomain = {
          # Add a context menu item for showing the Web Inspector in web views
          WebKitDeveloperExtras = true;
        };
        "com.apple.finder" = {
          ...
        };
      };
    };
  };

  programs.fish.enable = true;
  environment.shells = [
    pkgs.fish
  ];
  fonts = {
    packages = with pkgs; [
      material-design-icons
      font-awesome
      fira-code
    ];
  };
}

In this file I handle all the system-wide config not directly affecting specific apps or services.

Here I set up and make sure Nix-Darwin handles all the nix.* config like “allowUnfree”, garbage collection, user setup and more.

Next up is the most important part of this file, the defaults.

The system.defaults (or MacOS defaults ) lets Nix manage all the MacOS system settings, such as the dock, trackpad and much more, and if an option is not implemented in Nix-Darwin, it can be set using the CustomUserPreferences attribute.

For me, being able to declare my preferred system settings and not having to remember them between updates and reinstalls is honestly one of the main appeals of using Nix on MacOS.

Last but not least, the file sets up Fish as a default shell, and installs a couple of fonts, which both should be handled globally to simplify their usage.

Home-Manager

With the base system in place, it’s time to fill it with apps, tools, and other goodies I need in my day-to-day.

While much of the configuration can be shared between my machines, there are exceptions, so in a reasonably vain attempt to be organised, I apply a rough set of rules on how my configuration is grouped.

It’s built upon the structure I talked about in my second post, and the basic idea is to organise apps by answering some questions:

  • Used by all machines and has little or no config? core.nix
  • Used by Linux, but still pretty simple? linux.nix
  • Used by Darwin and little to no config? darwin.nix
  • A bit more complex config? Separate file inside programs/ and imported
  • Extra complex config? raw config files under /dotfiles/* and symlinked

So all I had to do was add a new MacOS-specific file called darwin.nix, resulting in a structure like this:

├── core.nix
├── darwin.nix
├── linux.nix
└── programs
    ├── fish.nix
    ├── ghostty.nix
    ...

Let’s look at what darwin.nix contains:

{
  pkgs,
  inputs,
  config,
  ...
}: let
  username = "hest";
  nvimPath = "${config.home.homeDirectory}/Dev/me/nixos/dotfiles/nvim";
  zellijPath = "${config.home.homeDirectory}/Dev/me/nixos/dotfiles/zellij";
  gitIncludes = [
    {
      condition = "gitdir:~/dev/me/";
      path = "${config.home.homeDirectory}/.config/git/include_me";
    }
  ];
in {
  imports = [
    inputs.sops-nix.homeManagerModules.sops
    ./core.nix
    (import ./programs/git.nix {inherit pkgs gitIncludes;})
    ./programs/k9s.nix
    ./programs/ghostty.nix
    ./programs/fish.nix
  ];

  sops = {
    age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
    defaultSopsFile = "${inputs.dot-secrets}/secrets.yaml";
    secrets = {
      # private ssh
      "me/key".path = "${config.home.homeDirectory}/.ssh/id_me";
      "me/pub".path = "${config.home.homeDirectory}/.ssh/id_me.pub";
      "me/config".path = "${config.home.homeDirectory}/.config/git/include_me";
    };
  };


  # xdg tells nix where to put user config
  xdg.enable = true;
  xdg.configHome = "/Users/${username}/.config";

  xdg.configFile."zellij".source = config.lib.file.mkOutOfStoreSymlink zellijPath;
  xdg.configFile."nvim".source = config.lib.file.mkOutOfStoreSymlink nvimPath;

  home = {
    username = "${username}";
    homeDirectory = "/Users/${username}";

    packages = with pkgs; [
      kubectl
      tinygo
      wasmtime
      docker
      colima
      ollama
      ...
    ];
  };
}

Just as in my linux.nix file, I symlink my complex Nvim and Zellij config, do some magic mapping for git (with an added twist this time), and add some machine specific packages.

One of the new additions for darwin.nix is using xdg.* to point out MacOS’s config location, since it’s located under /Users/USERNAME/.config/ and not /home/USERNAME/.config as it would be on a Linux system.

Another new addition to my Home-Manager is a module called Sops-Nix, which I use to manage secrets out in the open, like the new git config. So with not much left to show around Home-Manager itself, lets take a look at Sops-Nix.

The secrets of managing secrets with Sops-Nix


Sops-Nix

With the essentials in place, the second part of this unification project was adding some secrets management to the Nix mix.

I was looking for a way to configure things like SSH keys, Git configurations, private repository settings, and other sensitive information directly in Nix, without having to hide the entire repository for fear of leaking something sensitive.

With the help of Sops-Nix and some Age Encryption, I’m able to do just that.

Sops-Nix lets me encrypt any file or string I want to keep hidden, allowing my secrets to travel incognito inside my dotfiles, and only revealing their contents on trusted machines.

To add an additional layer of cloak & dagger to the setup, I’m also storing this secret YAML file in a private repository, which I include as a Flake, so not even the encrypted strings are found in the open.

Maybe it’s a bit of “security by obscurity”, but I do feel more snug with everything tucked away, and it takes barely any extra effort, so why not?

setting up Sops-Nix

If you want to follow along the whole shebang, first thing to do is to set up a private repo, otherwise, just start from point two.

  1. create private secrets repo

I have chosen to store my secret in a private repository I call dot-secrets. This repo will contain the encrypted secret file(s), as well as a .sops.yaml file that acts as a blueprint for how to read and write the contents.

This repository will then be included as a Flake, so this setup does force me to manage two repositories to work correctly, and if that feels like too much effort, it’s always possible to just store the secrets file directly in the Nix repository.

mkdir dot-secrets
git init
  1. Generate Age key pair

I opted to use Age as encryption for my secrets, so some initial setup is required on each machine.

It is possible to transform the machine specific ssh key directly into an Age key, which could be useful for remote installation, but since I’m installing everything locally, hand-crafting the key pairs seems to be the easier path for now.

Age uses public-key cryptography, so let’s grab the tools and generate an Age key file.

nix-shell -p sops age
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt # generate key
age-keygen -y ~/.config/sops/age/keys.txt # fetch public key

This will create a file something like this:

# public key: age1p3s0qjvvwvrqzfky9mk9yq3xxw6z8mg9rzm5lp3uh8l5ktz4rdssat5rkq
AGE-SECRET-KEY-1LAP9N75NAKDTNRPF475Z9JMMVMEW0UL98NQVA0WXZXHFTKNUYE2Q3VVPQE

This key pair will be used by Sops-Nix in just a bit to encrypt the secrets I want to move around with my configuration.

  1. Create Sops config

With this Age key in hand, it’s now time to create a blueprint that Sops-Nix needs in order to know what to do.

So in the dot-secrets repo, let’s add .sops.yaml

keys:
  - &macbook  age1p3s0qjvvwvrqzfky9mk9yq3xxw6z8mg9rzm5lp3uh8l5ktz4rdssat5rkq
  - &nixos age1rnjrhup24e9vg53zsw3q43x96yp9cuq9qy4pea6faywylz68a4vsl696yr
creation_rules:
  - path_regex: secrets.yaml$
    key_groups:
    - age:
      - age: [*macbook, *nixos]

This file sets the rules of which users/hosts can access which secrets, and what keys they need to use to unlock the contents.

My setup is simple, allowing all listed hosts to access a single file called secrets.yaml. So I’m not making use of all the rules and stipulations you can add to the file, but for now, this works well enough for me.

  1. Encrypt a secret file

Once the Age file and Sops blueprint is in place, modifying Sops secrets is done using the sops CLI.

nix-shell -p sops --run "sops secrets.yaml"

This creates, or opens, a decrypted version of the secrets file, in a plain-text editor, that can be filled to the brink with secrets as long as Sops keeps it open.

As soon as the Sops editor is closed, the file is encrypted, and with that safe to store or send over the wire, without revealing its contents.

Using Sops-Nix

Back inside the Flake, let’s talk secrets in the open.

  1. Add new Flakes

To make use of the secrets in Nix, two new inputs are introduced:

Sops-Nix:

sops-nix.url = "github:Mic92/sops-nix";

Private repo dot-secrets:

dot-secrets = {
  url = "git+ssh://git@github.com/HestHub/dot-secrets.git";
  flake = false; # repo does not contain any nix code
};

Sops-Nix is included as a shared module within each of my Home Manager configurations (for NixOS and Nix-Darwin). The private repo is baked into the inherited inputs object.

...
home-manager.darwinModules.home-manager
{
  home-manager.useGlobalPkgs = true;
  home-manager.useUserPackages = true;

  # include the Sops-Nix in the Home-Manager module
  # -------------------------------------------
  home-manager.sharedModules = [
    sops-nix.homeManagerModules.sops
  ];
  # -------------------------------------------

  home-manager.users.hest = import ./home/darwin.nix;
  home-manager.extraSpecialArgs = {
    inherit inputs;
    system = "aarch64-darwin";
  };
}
...

Delving deeper into the configuration, down to /home/darwin.nix, the Sops module gets included, and keys can be picked up and written, unencrypted, to the disk as needed.

{
  pkgs,
  inputs,
  config,
  ...
}: let
  # VARIABLES...
  in {
  imports = [
    # include Sops module
    inputs.sops-nix.homeManagerModules.sops
    ...
  ];

  sops = {
    # location of the private key that is used
    # to decrypt the secrets
    age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";

    # location of the file(s) containing the encrypted secrets
    defaultSopsFile = "${inputs.dot-secrets}/secrets.yaml";

    # pick out what secrets to decrypt
    # and where to put them on the disk
    secrets = {
      # private ssh
      "me/key".path = "${config.home.homeDirectory}/.ssh/id_me";
      "me/pub".path = "${config.home.homeDirectory}/.ssh/id_me.pub";
      "me/config".path = "${config.home.homeDirectory}/.config/git/include_me";
    };
  };

Sops-Nix just needs to know where the unlocking key pair can be found on my machine, then just pick any secret from the secrets.yaml file and tell Nix where to write the contents.

So for example, I pick out the secret me/key from the YAML file, write it down to ~/.ssh/id_me

It doesn’t get any easier than that, just point and decrypt.

Updating secrets and keys

Jumping back to the dot-secrets repo, updating secrets or allowed hosts is no herculean task either.

To add or remove a secret, I simply call the Sops CLI and edit whatever secrets I have and save it, and Sops does the rest: saving, encrypting and preparing the secrets.yaml file to be safely moved once again.

If I need to add a new host in the future, a new machine or user that can read the secrets, it escalates from a breezy single step operation to a slightly more involved, but still manageable, two-step process:

  1. Add new host to the .sops.yaml file
keys:
  - &macbook  age1p3s0qjvvwvrqzfky9mk9yq3xxw6z8mg9rzm5lp3uh8l5ktz4rdssat5rkq
  - &nixos age1rnjrhup24e9vg53zsw3q43x96yp9cuq9qy4pea6faywylz68a4vsl696yr
  - &NEWHOST age...
creation_rules:
  - path_regex: secrets.yaml$
    key_groups:
    - age:
      - age: [*macbook, *nixos, *NEWHOST]
`
  1. Refresh sops keys
sops updatekeys secrets.yaml

That’s all it takes, a third host now has access to all the secrets.

So now, the secrets are safe to use and nothing gets stored unsafely where it doesn’t belong: encrypted in Git, nothing in /nix/store and only decrypted locally at build-time, easy as can be.

For me this works great, because now I just need to move around some keys and config, and with Sops-Nix, I can also expand to JSON and even binary files, so for now, I’m sticking to it.

There are alternatives to Sops-Nix: Agenix, which focuses on raw file encryption, or the more password-focused Krops.

Looking around online, Agenix seems to be the community’s darling, with a simple setup and a light maintenance burden, and was a top contender for me as well. But the allure of a single secrets file instead of each secret being its own Age file, did tip the scales in Sops-Nix’s favour in the end.

Reading rumours surrounding Agenix not working great on MacOS did play a role, but I never gave it a shot, so I’d just be talking through my hat if I said anything about that, but still worth mentioning.

So with the setup complete, and secrets secured, there is just one more Nix related feature I would like to introduce to my workflow: per-project development environments.

Development Domains, Discovering Direnv and Devenv


Devenv

Beyond managing the machine itself, another source of configuration that Nix can address is the project-specific environments found inside.

Different projects might need different versions of NodeJS, Java, Go, or other SDKs. There might be tools, system libraries, databases, or other background services required to build and run a project. The list goes on and on.

In the days before Nix, I used to handle this using ASDF or later on Mise, which solves this similar to how Nvm or Pyenv manages separate NodeJS or Python environments, but on a global scale.

But by using Nix, maybe this can be solved more declaratively, and managed by the same Nix suite as the system itself?

With the tools included in the Nix installation, like nix-shell and nix develop, it’s already possible to create a temporary shell environment that includes all the required packages and tools.

As an example, I have a Go project that uses Mockgen to generate some mocks for local development.

If I don’t want to install Mockgen as a global tool, I can instead run it in a nix-shell environment, without “polluting” my machine.

$ hest@mbp ~/repo (master)> mockgen
fish: Unknown command: mockgen
$ hest@mbp ~/repo (master) [127]> nix-shell -p mockgen

$ [nix-shell:~/repo mockgen --version
v0.5.2

$ [nix-shell:~/repo exit
exit
$ hest@mbp ~/repo (master)> mockgen
fish: Unknown command: mockgen

This works great for one-off commands or temporary setups, but I’m far too lazy to remember setting up every project environment manually every time I need it. Enter Direnv and Devenv.

  1. Direnv for automatic activation

Direnv is a neat little shell extension that automatically loads and unloads environment variables when you cd into a directory. It can also run shell commands based on the current directory. It works by looking for a .envrc that contains any variable or script you would like to use.

A quick demo directly from direnv.net

# Create a new folder for demo purposes.
$ mkdir ~/my-project
$ cd ~/my-project

# Show that the FOO environment variable is not loaded.
$ echo ${FOO-nope}
nope

# Create a new .envrc. This file is bash code that is going to be loaded by
# direnv.
$ echo export FOO=foo > .envrc
.envrc is not allowed

# The security mechanism didn't allow to load the .envrc. Since we trust it,
# let's allow its execution.
$ direnv allow .
direnv: reloading
direnv: loading .envrc
direnv export: +FOO

# Show that the FOO environment variable is loaded.
$ echo ${FOO-nope}
foo

# Exit the project
$ cd ..
direnv: unloading

# And now FOO is unset again
$ echo ${FOO-nope}
nope

This is all well and good, adding a nix-shell -p mockgen would initialise a shell every time I move into my Go project, but it gets even better when Direnv is combined with Devenv.

  1. Devenv

Devenv is an abstraction built on top of Nix, designed for creating development environments. It has all the power of Nix, and provides some extra, dedicated options for languages, services, containers and more.

Setting up Devenv is as easy as running devenv init.

hest@mbp ~/repo (master)> devenv init
Creating devenv.nix
Creating devenv.yaml
Creating .envrc
Creating .gitignore
• Building shell ...
warning: creating lock file '/repo/devenv.lock'
• Using Cachix: devenv
warning: creating lock file '/repo/devenv.lock'
✔ Building shell in 4.74s
Running tasks     devenv:enterShell

Succeeded         devenv:enterShell                        9ms
1 Succeeded                         9.12ms
hello from devenv
git version 2.49.0
hest@mbp ~/repo (master)>

As you can tell, this adds a couple of files that Devenv uses, but the one I’m most interested in is the main file called devenv.nix.

It’s here the environment is declared, and by default it looks something like this:


{ pkgs, lib, config, inputs, ... }:

{
  env.GREET = "devenv";

  packages = [ pkgs.git ];

  scripts.hello.exec = ''
    echo hello from $GREET
  '';

  enterShell = ''
    hello
    git --version
  '';

  enterTest = ''
    echo "Running tests"
    git --version | grep --color=auto "${pkgs.git.version}"
  '';
}

Here we can see Devenv is adding a local environment variable, makes sure Git is available, adds a small hello script and declares that it should run when Devenv is activated.

It also shows a test that can be useful to verify that the dev environment is behaving as expected, which is especially useful if it’s used in a CI/CD setting.

devenv init will also setup a direnv component for the project, gluing the two together using a single .envrc file, that direnv will watch for to know what to do.

export DIRENV_WARN_TIMEOUT=20s

eval "$(devenv direnvrc)"

use devenv

This file will tell direnv to evaluate and start up the Devenv environment.

These two pieces together means I can setup any manner of advanced development environment using Nix’s declarative approach and power, and automatically activate it thanks to Direnv.

So let’s take a look at how I setup this environment for my Go project, which needs three things: A Go SDK, a Postgres database and the mockgen tool, all of which can be declared in devenv.nix.

{pkgs, ...}: {
  languages.go.enable = true;
  packages = [pkgs.mockgen];
  services.postgres.enable = true;
}

With this tiny bit of config, I now have all the tools I need as soon as I step into this project directory.

Devenv will also start the Postgres service when I enter, and when leaving, Devenv makes sure everything is closed, shut down and tucked away, leaving the machine clutter-free once again.

This appealingly simple config is all this project requires, but if a project needs some more advanced setup, Devenv can provide almost anything Nix can do.


{ pkgs, lib, config, ... }: {
  packages = [ pkgs.postgresql ];

  # language versions are easy to declare
  languages = {
    go = {
      enable = true;
      packages = pkgs.go_1_23;
    }
  };

  # It's also possible to setup 
  # and initialise services
  services.postgres = {
    enable = true;
    package = pkgs.postgresql_17;
    initialDatabases = [{ name = "mydb"; }];
    extensions = extensions: [
      extensions.postgis
      extensions.timescaledb
    ];
    settings.shared_preload_libraries = "timescaledb";
    initialScript = "CREATE EXTENSION IF NOT EXISTS timescaledb;";
  };

  # if the Mockgen package wasn't available 
  # it's possible to fetch and build it directly from Git
  env.MOCKGEN_VERSION = "v0.5.2";
  packages = [
    # Thanks to the "buildGoModule", its also possible
    # to build the package locally using Go's build tools
    (pkgs.buildGoModule {
      name = "mockgen";
      src = pkgs.fetchurl {
        url = "https://github.com/uber-go/mock/archive/refs/tags/${config.env.MOCKGEN_VERSION}.tar.gz";
        hash = "sha256-0vF+R3c9SjZtA2v7CjSboH6Y9jx7RQJRA6rcZz5rQxk=";
      };
      vendorHash = null;
      modulesOnly = true;
    })
  ];

  # Some simple tests to verify the package versions
  # matches the version the project requires
  enterTest = ''
    postgres --version | grep 17
    mockgen --version | grep v0.5.2
  '';
}

Declarative development environments are great, but so far I have only experimented with them for my local machine.

The real power can be felt when you need to share configuration between developers, or using environments during CI/CD pipelines.

I have not yet reached that peak, but day by day, Devenv finds itself popping up in more and more places on my machine, so I’m guessing it’s just a matter of time before I’m completely engulfed.

That’s it for Nix today, but to wrap things up, I want to talk about one final thing, even if it’s not Nix-specific: Just.

Just make it work


Just Code Runner

The final (or at least the latest) addition to my setup is organising and collecting the different Nix commands I use daily.

A Makefile would do the job just fine, and for a while it did, but I decided to see if Just could… Just simplify my command running.

And I am happy to report that it did! With a Justfile in the repos root, I now have a collection of Make-like commands, or recipes as Just calls them, readily available, to handle everything Nix.

hest@mbp ~/d/m/nixos (main)> just
Available recipes:
    default
    system-info

    [nix]
    bootstrap           # bootstrap darwin system
    build               # rebuild system [alias: b]
    clean               # remove old profiles
    gc                  # gc system and user nix store
    repair-store *paths # Repair Nix Store Objects
    update              # update flakes
    verify-store        # verify nix store Objects

Just even lets me group commands by host, so even if the Nix commands to rebuild the systems are different between machines, I still call just build and the correct command will fire and rebuild Nix.

The syntax of Just is Make-inspired, but somewhat simplified, and with some annotations above the recipes, it’s easy to group them by system.

...
# rebuild system
[group('nix')]
[linux]
build:
  sudo nixos-rebuild switch --flake .

# rebuild system
[group('nix')]
[macos]
build:
  nix build .#darwinConfigurations.mbp.system --extra-experimental-features 'nix-command flakes' --show-trace --verbose
  ./result/sw/bin/darwin-rebuild switch --impure --flake .#mbp --show-trace --verbose
alias b := build
...

A great little tool that really does improve the ergonomics of using Nix, and I’m sure I’ll find great uses for Just in other parts of my workflow too.

Conclusion


There we have it! The Nixification continues. What started as a curious experiment bloomed into a unifying, machine-spanning configuration that handles all my setup needs.

The Mac and Linux machines are getting along just fine now, both marching to the same drum. My secrets are securely locked away, dev environments appear like magic, and updates are just a Just command away.

Is it perfect?

Honestly, no. It’s a journey, and even after several months, the path upwards is steep. Nix has its complexities. Just to name a few:

  • The Learning Curve: It’s significant. Don’t expect to master it overnight.
  • Error Messages: They can be cryptic, often sending you down many different paths instead of pointing to a clear solution.
  • The Nix Language: It has its quirks. Dotted with syntax oddities and a fair bit of boilerplate, which can feel unwieldy.
  • Plenty of Solutions: For many issues, there isn’t just one way to solve it, but many and often varied solutions. This can make finding the best way a task on its own.
  • It’s a DSL: At the end of the day, to do anything more than basic operations, you’re investing time to learn a whole new Domain-Specific Language.

Was it worth it?

Absolutely. Having my entire computer world defined in a single, version-controlled repository is a freeing and empowering feeling.

The reproducibility, consistency and control Nix brings me is unmatched compared to every other solution I’ve tried.

In the past I have tried multiple different config management solutions:

Just to name a few, but none of them could manage the entire system like Nix can, and I’m sure Nix will stick around on my machines for a long time to come, and even spread further in the future.

I’m talking about declaring OCI images, VMs, CI/CD builds, adding more machines and more tools, or even creating my own Flakes, the Nix rabbit hole is deep, if not nearly endless.

But that is a topic for another day, for right now, I’m just going to enjoy the temporary and peaceful lull in my configuration journey, at least until the config itch starts scratching again.

Thank you for sticking around and following along this series, and until next time, stay declarative!

My ever-evolving Github repo

Tack för att du läser Callistas blogg.
Hjälp oss att nå ut med information genom att dela nyheter och artiklar i ditt nätverk.

Kommentarer