<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Cobalt&apos;s Homepage</title><description>Homepage and blog of Cobalt</description><link>https://cobalt.rocks</link><item><title>Attic as Nix Cache</title><link>https://cobalt.rocks/posts/attic</link><guid isPermaLink="true">https://cobalt.rocks/posts/attic</guid><description>Guide on setting up attic with PostgreSQL and S3</description><pubDate>Fri, 22 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Attic as Nix Cache&lt;/h1&gt;
&lt;p&gt;This article will be a report/guide on how to setup &lt;a href=&quot;https://github.com/zhaofengli/attic&quot;&gt;attic&lt;/a&gt; in a monolithic setup as &lt;a href=&quot;https://wiki.nixos.org/wiki/Binary_Cache&quot;&gt;nix cache&lt;/a&gt; with S3 and PostgreSQL.&lt;/p&gt;
&lt;p&gt;This setup is used in my homelab to store around 150 GB of cached data in around 5.000.000 chunks of data.
There are still some things that can be improved but this setup has held up well to (ab)use in CI jobs and for distributed builds.&lt;/p&gt;
&lt;h2&gt;Requirements&lt;/h2&gt;
&lt;p&gt;It is recommended to use a NixOS host for at least &lt;code&gt;attic&lt;/code&gt;.
The ressource consumption of attic depends on two factors:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Load, how many paths are you planning to push at peak&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This host should have at least one core and at least 2-4 GB of RAM.
If you plan to have both the PostgreSQL database and attic on the same server, add few GB and a core.&lt;/p&gt;
&lt;h2&gt;Arch&lt;/h2&gt;
&lt;p&gt;As the introduction hints at this setup is intended to run attic in monolithic mode, i.e., one &lt;code&gt;atticd&lt;/code&gt; service fulfills all internal attic roles.
This servers is backed by a PostgreSQL instance for metadata storage and S3 for the actual data storage.&lt;/p&gt;
&lt;p&gt;The S3-compatible service choosen here was &lt;a&gt;garage&lt;/a&gt; but I&apos;ve also used minio for the same role in the past.
Please be aware that some cloud solutions, like CloudFront or R2, have known compatability issues with attic.&lt;/p&gt;
&lt;h3&gt;Notes on other configurations&lt;/h3&gt;
&lt;p&gt;I have previously run a SQLite + local storage setup with attic.
This worked fine during testing, but the setup fell apart when handling load from multiple CI jobs (especially for big uploads).&lt;/p&gt;
&lt;p&gt;In particular, there were a lot of issues with concurrent write contention on SQLite and (not relevant anymore) some bugs with the storage layer.
My recommendation would be to go with at least PostgreSQL and, if available, also stick to S3.
The current setup has been mostly solid with some issues during mass uploads, where basic PostgreSQL tuning[^psql] was required to increases performance.&lt;/p&gt;
&lt;p&gt;[^psql]:
My mass uploads could lead to over 500 QPS, which is not a lot for PostgreSQL but enough to make some tuning worth it.
After viewing collected stats, it also turned out that &amp;gt; 95% of queries were cache hits.
Based on this, I provided the PostgreSQL more cache memory and saw an increases in cache hits and lower CPU load as a, likely direct, result of the former.
I also adjusted some other concurrency related options, based on pgtune, but did not see a significant change in performance based on these.&lt;/p&gt;
&lt;h2&gt;Guide&lt;/h2&gt;
&lt;p&gt;Let&apos;s start with the host for attic itself, you should&lt;/p&gt;
</content:encoded><author>Cobalt</author></item><item><title>Migrating from MinIO to garage</title><link>https://cobalt.rocks/posts/garage</link><guid isPermaLink="true">https://cobalt.rocks/posts/garage</guid><description>Learnings from migrating my homelab from Minio to garage</description><pubDate>Fri, 22 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Migrating to Garage&lt;/h1&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;:::note
If you only care about the technical stuff, you can skip to the next section.
This will be a short summary on why I migrated.
:::&lt;/p&gt;
&lt;p&gt;Since a few years my GitLab instances and some other services required S3 for distributed file storage.&lt;/p&gt;
&lt;p&gt;At the time, MinIO was the obvious choice as it had solid S3 support and a good Web UI.
The data on it has now been migrated over three instances, from a docker container to bare-metal on Debian to the final destination as a NixOS services on my NAS.&lt;/p&gt;
&lt;p&gt;However, they recently yanked the access management via the Web UI from the OSS edition and moved it to their commercial offering.
This part of the WebUI was the one I used the most and at least for me was enough motivation to look for alternatives.&lt;/p&gt;
&lt;p&gt;I do understand the motivation from their side to push people towards their enterprise offering, given how popular the free edition of MinIO has become.
However, the prospect of MinIO pulling access management features in the future lower it&apos;s long-term reliability a lot for me.&lt;/p&gt;
&lt;p&gt;:::tip[Panam Palmer]
Assessment, Assembly, Action
:::&lt;/p&gt;
&lt;h2&gt;Assessment&lt;/h2&gt;
&lt;p&gt;The first step of the migration was assessing:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What consumers are present?&lt;/li&gt;
&lt;li&gt;What do the consumers require?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The 1. was relatively simple thanks to existing documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/object_storage/&quot;&gt;GitLab&lt;/a&gt; (pages, distributed cache, artifacts)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.getoutline.com/&quot;&gt;Outline&lt;/a&gt; (attachements)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://plane.so/&quot;&gt;Plane&lt;/a&gt; (attachements)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/zhaofengli/attic&quot;&gt;Attic&lt;/a&gt; (artifacts)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GitLab is relatively easy to manage as all buckets can stay private and only services need to access it.
However, both outline and plane expose assets to uses, either by signed URLs or via a proxied route.&lt;/p&gt;
&lt;h2&gt;Assembley&lt;/h2&gt;
&lt;h3&gt;Tooling&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/minio/mc&quot;&gt;&lt;code&gt;minio-client&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;mc&lt;/code&gt;, was installed for data migration.
For S3 API interations, specifically CORS policy migration, the &lt;a href=&quot;https://aws.amazon.com/cli/&quot;&gt;&lt;code&gt;awscli&lt;/code&gt;&lt;/a&gt; was installed.&lt;/p&gt;
&lt;p&gt;If you have nix installed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;nix shell nixpkgs#awscli nixpkgs#minio-client&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;or &lt;code&gt;nix-shell -p awscli minio-client&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Reducing scope&lt;/h3&gt;
&lt;p&gt;Both Plane and Outline were reconfigured to use alternative storage methods.
This simplied all other steps as at least plane prefers bucket policies.&lt;/p&gt;
&lt;h3&gt;Preparing the destination&lt;/h3&gt;
&lt;p&gt;The garage service was deployed as outlined in the &lt;a href=&quot;https://garagehq.deuxfleurs.fr/documentation/quick-start/&quot;&gt;Quick Start Guide&lt;/a&gt;.
This was relatively quick on my NixOS server for a single-node installation.&lt;/p&gt;
&lt;h3&gt;Preparing the source&lt;/h3&gt;
&lt;p&gt;For migrating the data, we need to be able to read it.
In this case we want to acquire an access key that can read all MinIO buckets.&lt;/p&gt;
&lt;p&gt;A new identity was created for this and a key with the &lt;code&gt;readonly&lt;/code&gt; policy template worked fine here.
This identity can be removed after the migration.&lt;/p&gt;
&lt;h2&gt;Action&lt;/h2&gt;
&lt;p&gt;The data migration steps below likely will mean some downtime for your services.
It is not covered directly here, but another step was to coordinate service maintenance with this data migration.&lt;/p&gt;
&lt;p&gt;For GitLab this meant:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a maintenance window for a GitLab upgrade was also used to migrate pages data&lt;/li&gt;
&lt;li&gt;all runners were paused and drained of jobs before the cache was migrated&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The draining of runners was likely not required as they can also work with disjunct caches.
It was however the easier step to ensure good cache availability for all jobs.&lt;/p&gt;
&lt;p&gt;For Attic the service had a small downtime while data was being migrated.&lt;/p&gt;
&lt;h3&gt;Configuring the destination&lt;/h3&gt;
&lt;p&gt;All relevant buckets were recreated based on the quick start guide.&lt;/p&gt;
&lt;p&gt;This effectively boiled down to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;garage bucket create &amp;lt;bucket&amp;gt;
garage key create &amp;lt;identity&amp;gt;
# NOTE: down key id/ access key for service
garage bucket allow --read|--write &amp;lt;bucket&amp;gt; --key &amp;lt;identity&amp;gt;
# either --read and/or --write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As bucket/acl policies are not supported this was all steps required for the service identities temselves.&lt;/p&gt;
&lt;p&gt;Another key with read and write priviledges for all buckets was also created for the migration.
It will be used in the next step and can be removed with &lt;code&gt;garage key delete &amp;lt;key&amp;gt; --yes&lt;/code&gt; afterwards.&lt;/p&gt;
&lt;h3&gt;Migrating data&lt;/h3&gt;
&lt;p&gt;For interacting with S3 storages, both minio and garage, the minio CLI was choosen.
Mainly because its &lt;code&gt;mirror&lt;/code&gt; subcommand makes it trivial to fully replicate the contents of a bucket.&lt;/p&gt;
&lt;p&gt;The migration key id/access key for minio is required for the next step, an alias for the &lt;code&gt;mc&lt;/code&gt; cli can be created:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mc alias set source https://&amp;lt;minio&amp;gt; &amp;lt;key id&amp;gt; &amp;lt;secret key&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For garage, another alias with the priviledged migration key was created:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mc alias set garage https://&amp;lt;garage&amp;gt; &amp;lt;migration key id&amp;gt; &amp;lt;migration secret key&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can test both aliases with &lt;code&gt;mc ls &amp;lt;alias&amp;gt;&lt;/code&gt;.
They should both list the buckets for each endpoint.&lt;/p&gt;
&lt;p&gt;The next step is migrating the objects.&lt;/p&gt;
&lt;p&gt;:::warning
During data mirroring the service(s) writing to the bucket should be stopped.
Otherwise, you might run into data consistency issues afterwards.
:::&lt;/p&gt;
&lt;p&gt;You can now mirror each bucket with: &lt;code&gt;mc mirror source/&amp;lt;bucket&amp;gt; garage/&amp;lt;bucket&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;Porting CORS policies&lt;/h3&gt;
&lt;p&gt;Some buckets require &lt;a href=&quot;https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html&quot;&gt;CORS policies&lt;/a&gt;.
Especially when a service uses signed URLs or similiar this needs to be trasferred for functional operation.&lt;/p&gt;
&lt;p&gt;For this step the &lt;code&gt;awscli&lt;/code&gt; was used.
The same credentials as the &lt;code&gt;mc&lt;/code&gt; aliases were used here.&lt;/p&gt;
&lt;p&gt;First, the old policy was pulled from the source bucket and saved to &lt;code&gt;/tmp/cors&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aws --endpoint https://&amp;lt;minio&amp;gt; s3api get-bucket-policy --bucket &amp;lt;bucket&amp;gt; &amp;gt; /tmp/cors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then pushed to the new bucket:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aws --endpoint https://&amp;lt;garage&amp;gt; s3api put-bucket-cors --bucket &amp;lt;bucket&amp;gt; --cors-configuration file:///tmp/cors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This has to be done on a per-bucket basis.&lt;/p&gt;
</content:encoded><author>Cobalt</author></item><item><title>GitLab CI &amp; Nix</title><link>https://cobalt.rocks/posts/nix-gitlab</link><guid isPermaLink="true">https://cobalt.rocks/posts/nix-gitlab</guid><description>Tips &amp; Tricks on using Nix with GitLab CI</description><pubDate>Wed, 06 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Introduction&lt;/h1&gt;
&lt;p&gt;At &lt;code&gt;$work&lt;/code&gt; we have been using GitLab CI in combination with NixOS package builds, checks and NixOS tests for up to 2 years now.
This article will cover a loose collection of learnings and tips starting from optimizing GitLab runners to effective usage of GitLab caches.&lt;/p&gt;
&lt;p&gt;As for scope, this article only concerns itself with self-hosted GitLab CE instances.
All covered topics should also work on EE but are untested.
It is also assumed that you are using &lt;a href=&quot;https://nix.dev/concepts/flakes.html&quot;&gt;flakes&lt;/a&gt; however most guidance should also apply to channel-based workflows.&lt;/p&gt;
&lt;h2&gt;Runners&lt;/h2&gt;
&lt;p&gt;The most common GitLab runner is the &lt;code&gt;docker&lt;/code&gt; runner.
It enables you to run jobs inside an OCI container image.&lt;/p&gt;
&lt;p&gt;There are however some limitations when using &lt;code&gt;nix&lt;/code&gt; inside a normal, unpriviledged &lt;code&gt;docker&lt;/code&gt; runner.
Notably, the &lt;code&gt;sandbox&lt;/code&gt; is disabled as it require elevated priviledges.
This can have unintended side effects, like build time access to the internet.&lt;/p&gt;
&lt;p&gt;There are two runner configuration types that have helped me here:&lt;/p&gt;
&lt;h3&gt;Using the host daemon&lt;/h3&gt;
&lt;p&gt;You can make the host daemon available to the job inside the runner, with the configuration below.
This will also mount the host nix store inside the runner&apos;s container.&lt;/p&gt;
&lt;p&gt;There are advantages to this approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;strong caching, all jobs on the runner share the same store.
This includes all derivations etc. and also makes the use of, e.g., &lt;a href=&quot;https://github.com/nix-community/harmonia&quot;&gt;harmonia&lt;/a&gt;, for native caching-sharing between runners possible.&lt;/li&gt;
&lt;li&gt;sharing of build locks, if two jobs on the same runner try to build the same derivation it will only be built once.&lt;/li&gt;
&lt;li&gt;sandboxing, as the builds are delegated to the host daemon, the builders on the host can use the sandbox of the host daemon.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are however also disadvantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;minor compatability issues, the runner is likely only suitable for use with nix images.
Other images often rely on specific &lt;code&gt;$PATH&lt;/code&gt; or other variables that may be altered in unexpected ways.
The trivial workaround for this is to disable running of untagged jobs.&lt;/li&gt;
&lt;li&gt;runner overload, as all builds are on the host no direct CPU or memory limits can be enforced on the builds of any single job.
This is especially a problem when multiple heavy jobs (NixOS tests) get issued to the same runner.&lt;/li&gt;
&lt;li&gt;priviledged access, the jobs will likely need to connected as &lt;code&gt;trusted-users&lt;/code&gt;.
This means they effectively have control over the host daemon and should trusted as priviledge escalation is significantly easier.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Based on the &lt;a href=&quot;https://nixos.wiki/wiki/Gitlab_runner&quot;&gt;NixOS wiki article on GitLab runners&lt;/a&gt;, below is an adjusted configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ pkgs, lib, config, ... }: {
  boot.kernel.sysctl.&quot;net.ipv4.ip_forward&quot; = true;
  virtualisation.docker = {
    enable = true;

    rootless = {
      enable = true;
      setSocketVariable = true;
    };
  };

  nix.settings.trusted-users = [ &quot;root&quot; ];

  services.gitlab-runner = {
    enable = true;
    clear-docker-cache.enable = true;
    settings.concurrent = 3;

    # runner for building in docker via host&apos;s nix-daemon
    # nix store will be readable in runner, might be insecure
    services.native-runner = {
      # community managed, automatically updated nix image with flakes + commands pre-enabled
      dockerImage = &quot;nixpkgs/nix-flakes:nixos-${config.system.nixos.release}-${pkgs.system}&quot;;
      dockerVolumes = [
        # the items are ro because we write to the store via the daemon
        &quot;/nix/store:/nix/store:ro&quot;
        &quot;/nix/var/nix/db:/nix/var/nix/db:ro&quot;
        &quot;/nix/var/nix/profiles/system/etc/ssl/:/etc/ssl/:ro&quot;
        &quot;/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket:ro&quot;
        &quot;${pkgs.bash}/bin/bash:/usr/bin/sh:ro&quot;
        &quot;${pkgs.bash}/bin/bash:/bin/bash:ro&quot;
        &quot;${pkgs.bash}/bin/bash:/usr/bin/bash:ro&quot;
        &quot;${pkgs.bash}/bin/bash:/bin/sh:ro&quot;
      ];
      dockerDisableCache = true;
      registrationFlags = [
        &quot;--docker-pull-policy=if-not-present&quot;
        &quot;--docker-allowed-pull-policies=if-not-present&quot;
        &quot;--docker-allowed-pull-policies=always&quot;
      ];
      environmentVariables = {
        # we use the shared nix daemon of the host
        NIX_REMOTE = &quot;daemon&quot;;
        ENV = &quot;/etc/profile&quot;;
        USER = &quot;root&quot;;
        # NOTE: we override the nix installation in the container because
        # it is linked to the original nix store from the container.
        # However this store, nor the dynamic libraries in it, can be found
        # because of the overlay mount. Also ensure the nix version is
        # synced to the system nix daemon.
        PATH =
          (pkgs.lib.strings.makeSearchPathOutput &quot;bin&quot; &quot;bin&quot; (
            with pkgs;
            [
              gnugrep
              coreutils
              nix
              openssh
              gitleaks
              bash
              git
            ]
          ))
          + &quot;:/nix/var/nix/profiles/default/bin:/usr/local/bin:/usr/local/sbin:/nix/var/nix/profiles/default/sbin:/bin:/sbin:/usr/bin:/usr/sbin&quot;;
      };

      authenticationTokenConfigFile = &amp;lt;path to token file, use sops-nix or agenix here&amp;gt;;
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Plain runner&lt;/h3&gt;
&lt;p&gt;You can also use the daemon inside the container, the advantages are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;proper ressource limits, you can limit CPU and memory per job&lt;/li&gt;
&lt;li&gt;runners can be trivially reused for other images (can be reliably used for untagged jobs)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The disadvantages are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;caching requires extra work, see &quot;Reducing duplicate work&quot; for strategies to address this&lt;/li&gt;
&lt;li&gt;sandboxing is only possible for priviledged runners&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A sample configuration for a plain runner is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ pkgs, config, ... }: {
  boot.kernel.sysctl.&quot;net.ipv4.ip_forward&quot; = true; # 1
  virtualisation.docker = {
    enable = true;
    enableOnBoot = true;
    autoPrune.enable = true;
  };

  services.gitlab-runner = {
    enable = true;
    settings = {
      concurrent = 4;
      listen_address = &quot;127.0.0.1:9252&quot;;
    };

    services.runner = {
      # community managed, automatically updated nix image with flakes + commands pre-enabled
      dockerImage = &quot;nixpkgs/nix-flakes:nixos-${config.system.nixos.release}-${pkgs.system}&quot;;

      dockerVolumes = [
        # passthrough bash &amp;amp; grep for gitlab ci (used inside the executor, not contained in the base image)
        &quot;${lib.getExe pkgs.pkgsStatic.gnugrep}:/usr/bin/grep:ro&quot;
        &quot;${lib.getExe pkgs.pkgsStatic.bash}:/usr/bin/sh:ro&quot;
        &quot;${lib.getExe pkgs.pkgsStatic.bash}:/usr/bin/bash:ro&quot;
      ];

      registrationFlags = [
        &quot;--docker-pull-policy=if-not-present&quot;
        &quot;--docker-allowed-pull-policies=if-not-present&quot;
        &quot;--docker-allowed-pull-policies=always&quot;
      ];

      authenticationTokenConfigFile = ;
    };
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you want to use sandboxing and are fine with a priviledged runner, add the extra flags and volumes below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ config, pkgs, ... }:
let
  nixJoin = list: builtins.concatStringsSep &quot; &quot; list;
  # enable sandbox &amp;amp; flakes + passthrough of host substituters
  nix-conf = pkgs.writeText &quot;nix.conf&quot; &apos;&apos;
    accept-flake-config = true
    experimental-features = nix-command flakes
    max-jobs = auto
    sandbox = true

    substituters = ${nixJoin config.nix.settings.substituters}
    trusted-public-keys = ${nixJoin config.nix.settings.trusted-public-keys}
    extra-substituters = ${nixJoin config.nix.settings.extra-substituters}
    extra-trusted-public-keys = ${nixJoin config.nix.settings.extra-trusted-public-keys}
  &apos;&apos;;
in
{
  services.gitlab-runner.services.runner = {
    dockerVolumes = [
      # inject host nix conf for substituters etc. into container
      # this ensures our nix cache get&apos;s used for all devshells
      &quot;${nix-conf}:/etc/nix/nix.conf:ro&quot;
      # passthrough bash &amp;amp; grep for gitlab ci (used inside the executor, not contained in the base image)
      ...
    ];
    registrationFlags = [ &quot;--docker-privileged&quot; ];
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Please also note that adjusting the &lt;code&gt;nix.conf&lt;/code&gt; with &lt;code&gt;dockerVolumes&lt;/code&gt; also can enable you to adjust the default &lt;code&gt;trusted-substituter&lt;/code&gt; inside the container.
This can be useful to inject your own public cache even if you don&apos;t enable sandboxing.&lt;/p&gt;
&lt;h3&gt;Runners with S3 cache&lt;/h3&gt;
&lt;p&gt;The official documentation is a bit sparse here, so this serves as an extension to both runners types.
When you have runners on multiple hosts you likely want a shared cache between runners to exchange data between jobs reliably.
For the GitLab runner NixOS module this means you will have to use the CLI registration flags instead of the YAML config, below are the relevant flags:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ ... }:
{
  services.gitlab-runner.services.runner.registrationFlags = [
    &quot;--cache-s3-server-address=s3.example.com&quot;
    &quot;--cache-s3-access-key=$(cat ${access_key.path})&quot;
    &quot;--cache-s3-secret-key=$(cat ${secret_key.path})&quot;
    &quot;--cache-s3-bucket-name=$(cat ${bucket_name.path})&quot;
    &quot;--cache-s3-bucket-location=us-east-1&quot;
    &quot;--cache-s3-authentication_type=access-key&quot;
    &quot;--cache-type=s3&quot;
    &quot;--cache-shared&quot;
  ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
These flags are written to the store, using &lt;code&gt;--cache-s3-secret-key=$(cat ${path to secret})&lt;/code&gt; with agenix or sops-nix is strongly recommended.
:::&lt;/p&gt;
&lt;p&gt;Both &lt;a href=&quot;https://nixos.org/manual/nixos/stable/#module-services-garage&quot;&gt;garage&lt;/a&gt; and &lt;a href=&quot;https://search.nixos.org/options?channel=25.05&amp;amp;from=0&amp;amp;size=50&amp;amp;sort=relevance&amp;amp;type=packages&amp;amp;query=services.minio&quot;&gt;minio&lt;/a&gt; are suitable for self-hosting this s3 cache and available as NixOS modules.&lt;/p&gt;
&lt;h2&gt;Tips: Reducing duplicate work&lt;/h2&gt;
&lt;p&gt;GitLab CI can cache some artifacts but will not cache paths &lt;a href=&quot;https://gitlab.com/gitlab-org/gitlab/-/issues/14151&quot;&gt;outside the project directory&lt;/a&gt;.
This notably affects the nix store.&lt;/p&gt;
&lt;p&gt;If you have many jobs or a complex devshell you will likely spend a significant amount of time just downloading, or worse building, artifacts before even starting the actual job task, e.g., running unit tests.
Below are two ways that have worked for me:&lt;/p&gt;
&lt;h3&gt;Specialized container images&lt;/h3&gt;
&lt;h4&gt;Existing images&lt;/h4&gt;
&lt;p&gt;If you have 10 jobs that need, e.g., &lt;code&gt;devshell&lt;/code&gt;, it is very likely an improvement to use an image where it is already available instead of downloading it on every build.
The advantage here is that on each run you won&apos;t have to download/build the artifact but can instead start directly (ot at least faster).&lt;/p&gt;
&lt;p&gt;Some useful image collections are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nix-community/docker-nixpkgs&quot;&gt;github:nix-community/docker-nixpkgs&lt;/a&gt;: Images for nix and common nix development tooling (&lt;code&gt;cachix&lt;/code&gt;, &lt;code&gt;devenv&lt;/code&gt;, &lt;code&gt;devcontainer&lt;/code&gt;, ...)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hub.docker.com/r/nixos/nix/&quot;&gt;dockerhub nixos/nix&lt;/a&gt;: Official nix container image from &lt;a href=&quot;https://github.com/NixOS/nix/blob/master/docker.nix&quot;&gt;github:NixOS/nix&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Building images&lt;/h4&gt;
&lt;p&gt;You can also build your own container images with nix, see the following documentation for guidance on this topic:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nix.dev/tutorials/nixos/building-and-running-docker-images.html&quot;&gt;nix.dev -- guide on building docker images&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nixos.org/manual/nixpkgs/stable/#sec-pkgs-dockerTools&quot;&gt;dockerTools reference documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/nlewo/nix2container&quot;&gt;nix2container -- archive-less builder&lt;/a&gt; (can be faster than &lt;code&gt;dockerTools&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can upload docker images built with nix to the &lt;a href=&quot;https://docs.gitlab.com/user/packages/container_registry/&quot;&gt;GitLab container registry&lt;/a&gt; with, e.g., &lt;a href=&quot;https://github.com/containers/skopeo&quot;&gt;&lt;code&gt;skopeo&lt;/code&gt;&lt;/a&gt;.
This can either be done on a per project/repository basis or, e.g., in a shared image repository with regularly built images based on &lt;a href=&quot;https://docs.gitlab.com/ci/pipelines/schedules/&quot;&gt;scheduled pipelines&lt;/a&gt;.
The latter may be useful when dealing with multiple repositories that share the same tooling.&lt;/p&gt;
&lt;h5&gt;Streaming image into GitLab registry&lt;/h5&gt;
&lt;p&gt;:::info
This is an adjusted example from the &lt;a href=&quot;https://nixos.org/manual/nixpkgs/stable/#ssec-pkgs-dockerTools-streamNixShellImage-examples&quot;&gt;reference documentation&lt;/a&gt;.
It assumes you want to upload an image to the GitLab registry with name &lt;code&gt;builder&lt;/code&gt; and tag &lt;code&gt;latest&lt;/code&gt;.
:::&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  inputs.nixpkgs.url = &quot;github:nixos/nixpkgs/nixos-unstable&quot;;

  outputs =
    { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
    {
      packages.x86_64-linux.image = pkgs.dockerTools.streamLayeredImage {
        name = &quot;hello&quot;;
        contents = [ pkgs.hello ];
      };
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the job:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-image:
  script:
    - echo &quot;$CI_JOB_TOKEN&quot; | skopeo login --insecure-policy &quot;$CI_REGISTRY&quot; -u gitlab-ci-token --password-stdin
    - $(nix build --no-link --print-out-paths .#image) | gzip --fast | skopeo copy docker-archive:/dev/stdin &quot;docker://$CI_REGISTRY_IMAGE/builder:latest&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This job will in &lt;code&gt;echo ...&lt;/code&gt; log into the registry with the ephemeral job token, this is required for the upload from the runner.
In &lt;code&gt;$(nix ...)&lt;/code&gt; the image is built and streamed to stdout, afterward it is compressed (&lt;code&gt;gzip --fast&lt;/code&gt;) and pushed to the registry (&lt;code&gt;skopeo ...&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;Using binary caches&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://wiki.nixos.org/wiki/Binary_Cache&quot;&gt;Nix binary caches&lt;/a&gt; allow you to cache the built results of an evaluated derivation.&lt;/p&gt;
&lt;p&gt;In a CI/CD pipeline these may be used to push the results of a build to enable derivation-level caching of results.
This means you would, e.g., build an artifact in a job, push it to the cache and let successive jobs download it from the cache again (instead of building it).&lt;/p&gt;
&lt;p&gt;In my experience, with the constraint of self-hosting most things, &lt;a href=&quot;https://github.com/zhaofengli/attic&quot;&gt;attic&lt;/a&gt; is a good fit for this.
But, especially if you want commercial support/a hosted non-free offering, &lt;a href=&quot;https://www.cachix.org/&quot;&gt;&lt;code&gt;cachix&lt;/code&gt;&lt;/a&gt; is a popular solution.&lt;/p&gt;
&lt;p&gt;It should be noted that neither have particularly good GitLab support and appear to be mainly used with GitHub.
They are however both compatible but you may have to put in some extra work.&lt;/p&gt;
&lt;h4&gt;attic&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;attic&lt;/code&gt; authenticates with to the server with a signed token.
If you plan to use this token in a pipeline, give it push/pull permissions and store it in a &lt;a href=&quot;https://docs.gitlab.com/ci/variables/&quot;&gt;CI variable&lt;/a&gt; and adjust permissions as needed.&lt;/p&gt;
&lt;p&gt;In a job you first need to log in and tell it to configure your local &lt;code&gt;nix.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-stuff:
  before_script:
    - attic login upstream https://cache.example.com &quot;$ATTIC_TOKEN&quot;
    - attic use upstream:&amp;lt;your cache&amp;gt;
  script:
    - nix build .#cake
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then either push a derivation output (extending the example from above):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-stuff:
  after_script:
    - attic push upstream:&amp;lt;your cache&amp;gt; ./result
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will make the next run[^inputs] fetch &lt;code&gt;.#cake&lt;/code&gt; from the cache instead of (re)building the derivation.
It may also be helpful to use &lt;a href=&quot;https://docs.gitlab.com/ci/jobs/job_rules/&quot;&gt;&lt;code&gt;rules&lt;/code&gt;&lt;/a&gt; if you only want to rebuild on particular changes.&lt;/p&gt;
&lt;p&gt;[^inputs]: Assuming the same inputs were used and the cache has not been garbage collected.&lt;/p&gt;
&lt;p&gt;:::note
Pushing the result of a build will likely not include all dependencies of the build.
:::&lt;/p&gt;
&lt;p&gt;This may be extended by pushing the direct dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-stuff:
  - attic push ./result upstream:&amp;lt;your cache&amp;gt;
  - nix-store --query --references ./result | attic push upstream:&amp;lt;your cache&amp;gt; --stdin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or the whole store,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-stuff:
  - nix path-info --all | attic push upstream:&amp;lt;your cache&amp;gt; --stdin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::warning
Unless you know you need this, don&apos;t push the whole store.
It will also contain derivations already included in the image and may bloat your cache.
:::&lt;/p&gt;
&lt;h2&gt;Tips: Scripting&lt;/h2&gt;
&lt;p&gt;Your pipelines will often contain some sort of scripts for, e.g., the image upload from above.
These scripts can be made reusable as nix packages.&lt;/p&gt;
&lt;p&gt;One of the most helpful parts for small scripts are the &lt;a href=&quot;https://nixos.org/manual/nixpkgs/stable/#chap-trivial-builders&quot;&gt;Trivial build helpers&lt;/a&gt;, in particular &lt;a href=&quot;https://nixos.org/manual/nixpkgs/stable/#trivial-builder-writeShellApplication&quot;&gt;&lt;code&gt;writeShellApplication&lt;/code&gt;&lt;/a&gt;.
They enable the concise packaging of small scripts.&lt;/p&gt;
&lt;p&gt;The main advantage of this approach is the easy invocation of commands locally.
It also allows you to, e.g. with &lt;code&gt;writeShellApplication&lt;/code&gt;, run &lt;code&gt;shellcheck&lt;/code&gt; over your CI scripts.&lt;/p&gt;
&lt;h3&gt;Extended example&lt;/h3&gt;
&lt;p&gt;Building on the example from &lt;em&gt;Streaming image into GitLab registry&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  inputs.nixpkgs.url = &quot;github:nixos/nixpkgs/nixos-unstable&quot;;

  outputs =
    { self, nixpkgs }:
    let
      system = &quot;x86_64-linux&quot;;
      pkgs = nixpkgs.legacyPackages.${system};
    in
    {
      packages.${system} = {
        image = pkgs.dockerTools.streamLayeredImage {
          name = &quot;hello&quot;;
          contents = [ pkgs.hello ];
        };
        upload-image = pkgs.writeShellApplication {
          name = &quot;upload-image&quot;;
          runtimeInputs = [ pkgs.skopeo pkgs.gzip ];
          text = &apos;&apos;
            echo &quot;$CI_JOB_TOKEN&quot; | skopeo login --insecure-policy &quot;$CI_REGISTRY&quot; -u gitlab-ci-token --password-stdin
            ${self.packages.${system}.image} | gzip --fast | skopeo copy docker-archive:/dev/stdin &quot;docker://$CI_REGISTRY_IMAGE/builder:latest&quot;
          &apos;&apos;;
        };
      };
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which can now be used as:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;build-image:
  script:
    - nix run .#upload-image
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And you can of course also then invoke &lt;code&gt;nix run .#upload-image&lt;/code&gt; locally and test with, e.g., a &lt;a href=&quot;https://docs.gitlab.com/user/profile/personal_access_tokens/&quot;&gt;PAT&lt;/a&gt; instead of a job token when an issue occurs.&lt;/p&gt;
&lt;h2&gt;Tips: Flake Evaluation caching&lt;/h2&gt;
&lt;p&gt;:::warning
Below is experimental, it led to a notable speedup in my pipelines but may have side effects.
:::&lt;/p&gt;
&lt;p&gt;GitLab CI cannot currently cache paths &lt;a href=&quot;https://gitlab.com/gitlab-org/gitlab/-/issues/14151&quot;&gt;outside the project directory&lt;/a&gt;.
This does not just affect the nix store but also the &lt;a href=&quot;https://nix.dev/manual/nix/latest/command-ref/conf-file#conf-eval-cache&quot;&gt;flake eval cache&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A workaround is to store the cache from &lt;code&gt;$NIX_CACHE_HOME&lt;/code&gt; in the &lt;code&gt;after_script&lt;/code&gt; inside the project directory and preseed &lt;code&gt;$NIX_CACHE_HOME&lt;/code&gt; in the &lt;code&gt;before_script&lt;/code&gt; in the same manner.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cached-eval:
  before_script:
    - if [ ! -d .cache ]; then mkdir .cache; fi
    - export NIX_CACHE_HOME=&quot;$HOME/cache&quot;
    - mv .cache &quot;$NIX_CACHE_HOME&quot;
  script:
    - nix build .#...
  after_script:
    - mv &quot;$HOME/cache&quot; .cache
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Tips: KVM&lt;/h2&gt;
&lt;p&gt;:::note
Passing through the KVM device obviously requires a working KVM device on the host.
This requires a CPU with Intel VT-x or AMD SVM on a bare-metal host or nested virtualisation for VMs.&lt;/p&gt;
&lt;p&gt;To check if the device is present, check if &lt;code&gt;/dev/kvm&lt;/code&gt; exists.
If it doesn&apos;t, but your hardware should support it, ensure that the corresponding kernel module &lt;code&gt;kvm_intel&lt;/code&gt; or &lt;code&gt;kvm_amd&lt;/code&gt; is loaded.
:::&lt;/p&gt;
&lt;p&gt;:::warning
As far as I know, this only works with priviledged runners.
:::&lt;/p&gt;
&lt;p&gt;Running NixOS tests efficiently inside a builder requires the builder feature &lt;code&gt;kvm&lt;/code&gt;.
As the name implies, this means &lt;code&gt;/dev/kvm&lt;/code&gt; must be present inside the container AND the kernel module must be loaded.&lt;/p&gt;
&lt;p&gt;To pass through the device, use:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services.gitlab-runner.services.${runner}.registrationFlags = [
  &quot;--docker-devices=/dev/kvm&quot;
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under most circumstances, the kernel module is loaded by &lt;code&gt;book.kernelPackages&lt;/code&gt; when generated from &lt;code&gt;nixos-generate-config&lt;/code&gt;.
If it isn&apos;t, add &lt;code&gt;kvm-amd&lt;/code&gt; or &lt;code&gt;kvm-intel&lt;/code&gt; to &lt;code&gt;boot.kenelPackages&lt;/code&gt; in your host config.&lt;/p&gt;
</content:encoded><author>Cobalt</author></item></channel></rss>