Nix Builds#

Goal#

Build Nix-based container images via MyCI and push them to a forge container registry. Support both myci run (local) and server-triggered builds (webhook from Forgejo).

Two Execution Paths#

myci run selects its executor — it is not tied to host execution. myci run --executor host runs steps as local shell commands where Nix is already available. myci run --executor k8s runs steps in Kubernetes pods where Nix must be provided.

The same workflow should work on both paths. The difference is the execution environment, not the workflow definition.

Incremental Slices#

Slice 1: Host executor (works today)#

The host executor runs steps as local shell commands. Nix is available on the developer’s machine. No MyCI changes needed.

jobs:
  build:
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: nix build .#server-image
      - name: Push to registry
        run: |
          ./result | skopeo copy \
            docker-archive:/dev/stdin \
            docker://code.example/myci/server:latest

For local use without a registry, ./result | podman load loads the image into the local container runtime.

Limitation: requires credentials for registry push. See Secrets and Credentials.

Slice 2: Kubernetes with Nix container#

Use container: nixos/nix in the workflow. The nixos/nix image provides Nix in an unprivileged container. Nix sandboxing must be disabled (no nested namespaces in k8s pods).

jobs:
  build:
    container: nixos/nix:latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure Nix
        run: |
          echo "sandbox = false" >> /etc/nix/nix.conf
          echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
      - name: Build image
        run: nix build .#server-image

Limitation: every run starts with an empty Nix store. A nix build of a flake downloads nixpkgs and all dependencies from scratch — easily 10+ minutes of fetching before the actual build starts. This is impractical without caching.

Slice 3: Binary cache#

A Nix binary cache is an HTTP service that serves pre-built store paths. Builds fetch from the cache instead of rebuilding. The cache is read-heavy, write-rare, and designed for concurrent access.

Infrastructure needed:

  • A cache service (attic, harmonia, or similar) deployed in the cluster or accessible from it.

  • Workflow steps configure substituters to point at the cache.

  • After a successful build, push results into the cache with nix copy --to or attic push.

  • Subsequent builds are fast — only new derivations need building.

jobs:
  build:
    container: nixos/nix:latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure Nix
        run: |
          echo "sandbox = false" >> /etc/nix/nix.conf
          echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
          echo "extra-substituters = http://nix-cache.example" >> /etc/nix/nix.conf
          echo "extra-trusted-public-keys = cache.example:..." >> /etc/nix/nix.conf
      - name: Build image
        run: nix build .#server-image
      - name: Push to cache
        run: nix copy --to http://nix-cache.example ./result

This makes k8s Nix builds practical for iterative development.

Slice 4: Native Nix environment (future)#

Jobs could specify a Nix shell instead of a container image:

jobs:
  build:
    nix: .#devShells.default
    steps:
      - run: cargo build

The executor evaluates the flake attribute and provides the resulting environment. On k8s, this could be an init container that runs nix build and mounts the result. On the host executor, it would be nix develop wrapping the shell commands.

This is a longer-term direction. The container-based approach (slices 1-3) covers the immediate need.

Why Not a Shared Nix Store Volume#

A persistent volume mounted at /nix/store across pods seems like a simpler alternative to a binary cache. It is not.

The Nix daemon uses a SQLite database at /nix/var/nix/db/ to track store paths. SQLite on shared filesystems (NFS, most ReadWriteMany providers) is a known source of corruption. Two concurrent nix build runs writing to the same database will corrupt it — even though the store paths themselves are content-addressed and safe to share.

A binary cache is the correct shared caching mechanism for Nix. It is HTTP-based, designed for concurrent access, and separates read (substituter) from write (push) concerns cleanly.

Artifacts#

Registry as artifact store#

For container images, the registry is the artifact store. No separate artifact mechanism is needed — the workflow pushes to the registry, consumers pull from it. The image tag (or digest) is the artifact identifier.

Local artifacts#

When running locally without a forge or registry, the workflow handles output directly:

  • ./result | podman load — loads into local container runtime.

  • cp ./result /some/path — copies the build output.

  • nix copy --to /some/cache — pushes to a local cache.

A formal artifact system (upload/download between jobs, persistent artifact storage) is a separate concern. It is not needed for the Nix image build use case.

Connection to Worker Split#

The worker/agent split enables a pragmatic intermediate path: a host-based worker with Nix installed, connected to the MyCI server.

This gives server-triggered Nix builds (webhook → server → worker → host executor → nix build) without the overhead of running Nix inside k8s containers. The host has a warm Nix store, fast builds, and direct access to local infrastructure.

This is the recommended path while k8s Nix support (slices 2-3) matures. Once a binary cache is deployed and the container approach is proven, k8s workers can take over.