โ† back to blog

Building a Nix-Native CI System

2026-04-11 ยท 8 min read

I spent the last month building nixek-ci, a CI system designed around a simple premise: CI jobs should be Nix expressions. Not YAML. Not Groovy. Nix.

Here's what I built, why, and what I learned along the way.

The problem with existing CI

Most CI systems define jobs as a machine image (e.g., ubuntu-22.04) plus a list of shell commands. The machine image is a black box โ€” you hope it has the right tools and the right versions, and you pepper your job with apt-get install calls to patch over the gaps.

With Nix, you can do better. A NixOS machine is fully reproducible โ€” the entire OS, every package, every config โ€” expressed as a pure function. nixek-ci leans into this: the machine is part of the job definition.

How it works

A nixek-ci job is a Nix expression:

{ nixpkgs, ... }: {
  machine = { pkgs, ... }: {
    environment.systemPackages = [ pkgs.rustup pkgs.git ];
  };

  steps = [
    { name = "build"; run = "cargo build --release"; }
    { name = "test";  run = "cargo test"; }
  ];
}

The machine attribute is a standard NixOS module. nixek-ci builds it into an AWS AMI, launches an EC2 instance, and a Rust agent runs your steps on boot.

Architecture

The system has three parts:

The build pipeline

Building a NixOS AMI from a Nix expression involves a few steps that aren't obvious:

  1. Build a raw disk image with nix build .#nixosConfigurations.machine.config.system.build.amazonImage
  2. Upload the raw image to S3 as a snapshot
  3. Import it as an EBS snapshot via aws ec2 import-snapshot (with polling)
  4. Register it as an AMI with the snapshot as the root device

This is all automated in coordinator.sh. The first build takes a few minutes; subsequent jobs with the same machine definition reuse the cached AMI.

Real-time reporting

One thing I wanted from the start: step results streamed back live, not just dumped at the end. The agent reports to the API after each step completes:

POST /api/jobs/{id}/step
Authorization: Bearer <per-job token>

{ "name": "build", "exit_code": 0, "stdout": "...", "stderr": "..." }

Auth is per-job โ€” the coordinator gets a token when it creates the job record, and passes it to the EC2 instance via user-data. The API rejects anything without the right token, so even if two jobs run simultaneously they can't cross-contaminate.

What's next

The E2E flow is working through Phase 4. Remaining work:

It's still rough around the edges, but the core concept holds up: Nix expressions as CI jobs, real EC2 machines, no YAML in sight.

Code is at euank-ai/nixek-ci.