Building and Privately Caching x86 and aarch64 NixOS Systems with Github Actions
In the previous article we walked through how to set up our very own Nix binary cache.
It’s great being able to run attic push system /run/current-system
on
whichever machine we are currently using, but the the chances are that if you
use Nix to manage your system configurations, you have a system configuration
monorepo, and depending on how many machines and architectures you are
targeting, it can quickly become tiresome to manually push to the cache from
each of them.
My configuration repo presently has 4 targets:
- NixOS VM that runs on WSL (x86_64)
- Bare metal Hetzner server (x86_64)
- Raspberry Pi running in my home network (aarch64)
- Production instance of https://notado.app (x86_64)
Building NixOS system configurations⌗
NixOS system configurations are essentially build artifacts. Since they are build artifacts, it makes sense to build, cache and push them in CI pipelines, right? Right!
Although GitHub Actions is
awful, it can be made
siginificantly less awful by installing Nix immediately after we run
actions/checkout@v4
. Once we have Nix installed, we can side-step the poorly
maintained npm-esque “actions” ecosystem almost entirely.
If you have subscribed to my YouTube channel you may have seen a video where I built the system configurations for my NixOS starter templates on GitHub Actions.
Here is an example build.yml
from the
nixos-hetzner-robot-starter
template.
name: "build"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build -L .#deploy.nodes.robot.profiles.system.path
Painless, right? We just checkout the repo, install Nix, optionally install
magic-nix-cache
, and then run a nix build
command!
Building more complex NixOS system configurations⌗
Starter templates are pretty easy to build, but once we start adding things
like secrets and packages from private repositories, different architecture
targets etc. to our system configurations, naturally we’ll need to adapt our
build.yml
Handling private repositories⌗
The nix-installer-action
has a handy input where you can put a generated
GitHub token with access to the private repositories you need. This will be
used whenever the build needs to pull something like a flake input from one of
your private repos. When you add this as a GitHub Actions secret, note that you
won’t be able to use the GITHUB_
prefix for the name as that is reserved.
Additionally, you can also configure git
to use this token, which is useful
if you need to check out any private submodules in your configuration repo
before you can run nix build
.
- uses: DeterminateSystems/nix-installer-action@main
with:
github-token: ${{ secrets.GH_TOKEN }}
- run: |
git config --global url."https://${{ secrets.GH_TOKEN }}@github.com".insteadOf https://github.com
Handling secrets encrypted with git-crypt⌗
While secrets encrypted with sops-nix
are skipped over gracefully during a
system build, git-crypt
ed files that are used in a system configuration will
result in a build error if they are not decrypted.
We can handle this scenario by exporting a symmetric key, encoding it in base64, adding it as a secret variable in GitHub Actions and then decoding it to decrypt the relevant files in our build job.
git-crypt export-key ./exported-key
cat exported-key | base64 --encode > encoded-key
# Store the value of `encoded-key` as a secret variable in GitHub Actions
With the encoded key in place as a GitHub Actions secret, we can add a step to
decode it and decrypt any git-crypt
ed files in our repo.
- run: |
echo "${{ secrets.GIT_CRYPT_KEY }}" | base64 --decode > key
nix profile install nixpkgs#git-crypt
git-crypt unlock key
Handling different architectures⌗
Typically if you want to build packages for an aarch64-linux
machine on an
x86_64-linux
machine, you just need to specify that architecture as an
emulated system.
{
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
}
I came across an article by David
Wagner
which showed how to replicate this in GitHub Actions with a mixture of
docker-setup-qemu
and specifying aarch64-linux
as an extra-platform
in nix.conf
.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: |
extra-platforms = aarch64-linux
This is great, but we don’t really need to run docker-setup-qemu
when we are
building x86_64-linux
systems, so let’s tweak this a little.
We can introduce a matrix which contains each of our machines and their
respective platforms, and use that information to only run the
docker-setup-qemu
step if we are building an aarch64
system.
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
machine:
- host: nixsl
platform: x86-64-linux
- host: hetzner
platform: x86-64-linux
- host: notado
platform: x86-64-linux
- host: pi3
platform: aarch64-linux
steps:
- uses: actions/checkout@v4
- if: matrix.machine.platform == 'aarch64-linux'
uses: docker/setup-qemu-action@v3
Configuring access to our Attic binary cache⌗
If you recall from the previous article, in order to interact with our Attic binary cache, we had to do three things.
- Log in with our token to be able to push
- run: |
nix run github:zhaofengli/attic#default login fly https://<your fly app name>.fly.dev ${{ secrets.ATTIC_TOKEN }}
- Add our token to a
netrc
file to be authorized to query
- run: |
sudo mkdir -p /etc/nix
echo "machine <your fly app name>.fly.dev password ${{ secrets.ATTIC_TOKEN }}" | sudo tee /etc/nix/netrc > /dev/null
- Update our
substituters
andtrusted-public-keys
to be able to query during builds
- uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: |
substituters = https://<your fly app name>.fly.dev/system?priority=43 https://nix-community.cachix.org?priority=41 https://numtide.cachix.org?priority=42 https://cache.nixos.org/
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= system:5M8uBPjS68HTadSbeCs0Jiu0Z1tJBNdahtKBCXhl+Z0= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=
Putting it all together⌗
name: "build"
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
# Here we specify the matrix of our hosts and their target platform architectures
matrix:
machine:
- host: nixsl
platform: x86-64-linux
- host: hetzner
platform: x86-64-linux
- host: notado
platform: x86-64-linux
- host: pi3
platform: aarch64-linux
steps:
- uses: actions/checkout@v4
# We only run this if we are building an aarch64-linux system
- if: matrix.machine.platform == 'aarch64-linux'
uses: docker/setup-qemu-action@v3
# We make our netrc file that is used to make authorized requests to Attic
# We also make sure that we use our custom GitHub token if we need to clone submodules or anything like that
- run: |
sudo mkdir -p /etc/nix
echo "machine <your fly app name>.fly.dev password ${{ secrets.ATTIC_TOKEN }}" | sudo tee /etc/nix/netrc > /dev/null
git config --global url."https://${{ secrets.GH_TOKEN }}@github.com".insteadOf https://github.com
- uses: DeterminateSystems/nix-installer-action@main
with:
# We set our custom GitHub token for any private flake inputs we might have
github-token: ${{ secrets.GH_TOKEN }}
# We add all the config for extra platforms, other binary caches and to raise the number of connections that can be made
extra-conf: |
fallback = true
http-connections = 128
max-substitution-jobs = 128
extra-platforms = aarch64-linux
substituters = https://<your fly app name>.fly.dev/system?priority=43 https://nix-community.cachix.org?priority=41 https://numtide.cachix.org?priority=42 https://cache.nixos.org/
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= system:5M8uBPjS68HTadSbeCs0Jiu0Z1tJBNdahtKBCXhl+Z0= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=
- uses: DeterminateSystems/magic-nix-cache-action@main
# We make sure that any git-crypted files are decrypted before we begin to build
- run: |
echo "${{ secrets.GIT_CRYPT_KEY }}" | base64 --decode > key
nix profile install nixpkgs#git-crypt
git-crypt unlock key
# We build each system in a separate job, targeting the configuration using matrix.machine.host
# Once built, we login to Attic and push the built system to our `system` cache!
- name: Build and push system
run: |
nix build .#nixosConfigurations.${{ matrix.machine.host }}.config.system.build.toplevel
nix run github:zhaofengli/attic#default login fly https://<your fly app name>.fly.dev ${{ secrets.ATTIC_TOKEN }}
nix run github:zhaofengli/attic#default push system result -j 2
If you have any questions or comments you can reach out to me on Twitter and Mastodon.
If you’re interested in what I read to come up with solutions like this one, you can subscribe to my Software Development RSS feed.
If you’d like to watch me writing code while explaining what I’m doing, you can also subscribe to my YouTube channel.
If you found this content valuable, or if you are a happy user of the
komorebi
tiling window manager or my
NixOS starter
templates,
please consider sponsoring me on GitHub
or tipping me on Ko-fi.