CI for your nixos-config

Why

Say you want to try the latest commit of your favorite package or you want to apply a patch to a software (like when there’s no configuration option to get it to do what you want). I like Nix cause it let’s you do this trivially. But it gets annoying really quickly if you have made such changes to a handful of packages and you have to wait a long time for everything to build when you update.

I use the following justfile so I can “update” my computer with just update and it will push the updated lock files with a new branch update.

update:
    git branch -D update || true
    git switch -c update
    nix flake update
    git add flake.lock
    git commit -s -m "flake update"
    git push -f
    git switch main

Then, I wait and see if the builds succeed in the CI and run just upgrade to actually “upgrade” all of my computers:

upgrade:
    git switch update
    sudo nixos-rebuild switch -L --flake . --use-substitutes
    nixos-rebuild switch -L --flake .#chunk --target-host root@2a0f:85c1:840:2bfb::1 --use-substitutes
    home-manager -L switch --flake .
    git switch main
    git merge update
    git branch -d update

The build won’t take as long cause it will reuse the cache from the CI.

I have been using GitHub Actions and garnix for my needs. I like GitHub Actions because it’s free. It is suitable for most of my needs, but there are some things that garnix does so well. I have found Garnix to be much smarter at reusing caches to make builds faster. And their computers are so much powerful than GitHub’s. On my “tests”, garnix could build chromium in 1h 57m but my github runner would get killed after six hours.

github actions runner getting killed after 300 minutes

Of course, I have no use building chromium so let’s look at an example that actually matters. I tried to build an android kernel for my phone, but my github runner would consistently get killed after about 45 minutes when lld would cause it to go out-of-memory. Even on my own computer (that has 12GB memory), the OOM killer terminated my build after 3-4 hours which was annoying. But, garnix apparently built it in 14 minutes! This is too good to be true and I don’t know if it’s because their machines are actually that powerful or because it was being smart and reusing caches from someone else’s build (the repo I used the template from also uses garnix, but the kernel is obviously different so idk?). This was actually the longest run I could for my repository.

How

Garnix has a free tier that comes with 1,500 CI minutes per month. This has been enough for my needs and will likely be the same for you. I will also show you my setup on GitHub Actions in case that’s the only option available to you. Garnix is very easy to setup because it’s built with nix in mind and comes with a built-in cache. With GitHub Actions, you have to setup the cache separately with something like cachix or a self-hosted one with attic. Cachix is only free if you don’t store more than 5 GB of stuff in your cache. That’s not a lot, but could be usable if you regularly purge the cache. Attic is not too difficult to setup, but has been somewhat hit-or-miss in my usage, but still a solid option. Specifically, I get either random crashes or the cache throws a 5XX error for some artifacts and blocks the entire build because of how nix handles cache failures. To use attic, you need to provide your own backend which can be either an s3(-compatible) bucket or a local filesystem.

Garnix

Go to https://garnix.io/ and sign up with your GitHub account. You will be asked whether you want to add garnix to some specific repos or add it to your account so that it can see all (including privates!) of your repos (current and future ones). It can actually pickup new repos on it’s own and start building if you have a flake.nix file at the root.

You will need to add a garnix.yaml file at the root of your repository. If you don’t provide one, garnix will assume the following defaults:

builds:
  exclude: []
  include:
  - '*.x86_64-linux.*'
  - defaultPackage.x86_64-linux
  - devShell.x86_64-linux
  - homeConfigurations.*
  - darwinConfigurations.*
  - nixosConfigurations.*

As you can see this will build packages and devShells for x86_64-linux and any homeConfigurations, darwinConfigurations, or nixosConfigurations you might have. Adjust this as you see fit. You can read more about the config file here.

To make use of the build cache, you will need to add https://cache.garnix.io to substituters and cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g= to trusted-public-keys in your nix.conf. If you are using NixOS, you can add something like the following to your configuration:

nix = {
    settings = {
      trusted-public-keys = [
        "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="
      ];
      substituters = [
        "https://cache.garnix.io"
      ];
    };
};

Note that garnix uses the same public cache for everyone! (at least on the free tier). I don’t know if this makes you vulnerable to cache poisoning attacks? You may want to keep this mind and if you know more about this please let me know :)

That’s it. Now if garnix can find anything it can build in your flake.nix, it will build and cache it for you.

GitHub Actions

First sign up for a cachix account and create a cache. You will see a public key for your cache on a page like the following:

cachix homepage showing a public key for my cache

Add something like this to your nix.conf like we did above:

nix = {
    settings = {
      trusted-public-keys = [
        "cything.cachix.org-1:xqW1W5NNL+wrM9wfSELb0MLj/harD2ZyB4HbdaMyvPI="
      ];
      substituters = [
        "https://cything.cachix.org"
      ];
    };
};

Now go to the settings page of your cachix account and create an auth token (keep this secret!):

cachix auth token settings page

After you get your auth token, navigate to Settings -> Secrets and variables -> Actions in your GitHub repository’s settings. Add a secret named CACHIX_AUTH_TOKEN with the token you just created as the value. Should look like this (ignore the other tokens):

actions secrets and variables page in github repository settings

Make sure that you add it as a secret and not a variable or else it will be printed in your build logs.

Okay, now your cache should be ready to go. We need to now create a workflow that builds stuff and uploads to our cache. This is going to be somewhat simplified version of what I use myself:

jobs:
  build-machines:
    strategy:
      matrix:
        machine:
          # hostname of your nixosSystem's
          - chunk
          - ytnix
        os:
          - ubuntu-latest
          # add others as you need
          # - macos-latest
          # - ubuntu-24.04-arm

    runs-on: ${{ matrix.os }}
    steps:
      # you might need this if your build causes the runner to run out of disk space
      - name: Maximize build disk space
        uses: easimon/maximize-build-space@v10
        with:
          overprovision-lvm: true
          swap-size-mb: 1024
          remove-dotnet: 'true'
          remove-android: 'true'
          remove-haskell: 'true'
          remove-codeql: 'true'
          remove-docker-images: 'true'
          build-mount-path: /nix

      - name: Install Nix
        uses: cachix/install-nix-action@v30

      - name: Sync repository
        uses: actions/checkout@v4
        with:
          persist-credentials: false

      # this is what uploads our builds to the cache (also makes our CI use the cache from previous runs)!
      - uses: cachix/cachix-action@v14
        with:
          name: cything # replace with the name of your cachix cache
          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
          useDaemon: false
          installCommand: nix profile install nixpkgs#cachix

      # this is equivalent to `nixos-rebuild build`
      - run: nix build -L .#nixosConfigurations.${{ matrix.machine }}.config.system.build.toplevel

For reference, this is what I use for my android kernel build:

jobs:
  build-packages:
    runs-on: ubuntu-latest
    continue-on-error: false
    steps:
      - name: Maximize build disk space
        uses: easimon/maximize-build-space@v10
        with:
          overprovision-lvm: true
          swap-size-mb: 1024
          remove-dotnet: 'true'
          remove-android: 'true'
          remove-haskell: 'true'
          remove-codeql: 'true'
          remove-docker-images: 'true'
          build-mount-path: /nix

      - name: Install Nix
        uses: cachix/install-nix-action@v30
      - name: Sync repository
        uses: actions/checkout@v4
        with:
          persist-credentials: false

      - run: nix build -L .

      - uses: actions/upload-artifact@v4
        with:
          name: result
          path: result/