Nix flake package the Duha API and the Asr web app, with checks
One flake, two packages, one image, and checks that fail for honest reasons.
One flake, two packages, one image, and checks that fail for honest reasons. Then make them stop failing for dishonest ones.
§I. Frame
Sunday shipped two artifacts: the Tokio health binary (Duha) and a Vite 8 React app (Asr). Neither is deployable yet. This lesson packages both in one flake, adds a Linux OCI image for EKS, and wires nix flake check so CI can gate them.
Done bar: nix build .#api .#web produces both outputs; nix flake check exits 0 for a reason you can defend.
§II. Three techniques
One Lock per Language (named technique). Nix builds offline in a sandbox, so each package needs a lockfile Nix can read up front.
| Package | Builder | Lock input |
|---|---|---|
api | rustPlatform.buildRustPackage | cargoLock.lockFile = ./api/Cargo.lock |
web | buildNpmPackage | package-lock.json plus npmDepsHash |
The Asr app installs with Bun, but buildNpmPackage reads package-lock.json, so the lab generated one with npm install --package-lock-only. Two lockfiles for one app is a cost; write it down rather than hide it.
Fake Hash, Real Hash (named technique). Put a placeholder in npmDepsHash, build, and copy the hash Nix reports. Real output from the first build:
specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got: sha256-zh3sKXdOw8aTk3ITUnlY9f5C1k9lO8IuGEBfeiNmeW0=
The hash pins the dependency set. Change package-lock.json and the build fails until you update it.
A Check Must Fail for a Reason (named technique). A red check is useful only if the cause is the code. Read the failure before touching the flake.
§III. The flake
packages = forAll (pkgs: rec {
api = pkgs.rustPlatform.buildRustPackage {
pname = "duha-health"; version = "0.1.0";
src = ./api;
cargoLock.lockFile = ./api/Cargo.lock;
cargoBuildFlags = [ "--bin" "health" ];
doCheck = false;
meta.mainProgram = "health";
};
web = pkgs.buildNpmPackage {
pname = "asr-web"; version = "0.0.0";
src = ./web;
npmDepsHash = "sha256-zh3sKXdOw8aTk3ITUnlY9f5C1k9lO8IuGEBfeiNmeW0=";
installPhase = "cp -r dist $out";
};
default = api;
} // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
image = pkgs.dockerTools.buildLayeredImage {
name = "duha-health"; tag = "0.1.0";
contents = [ self.packages.${pkgs.stdenv.hostPlatform.system}.api ];
config = { Cmd = [ "/bin/health" ]; Env = [ "HEALTH_ADDR=0.0.0.0:8787" ];
ExposedPorts."8787/tcp" = { }; };
};
});
nix build .#web .#api exited 0 on the lab Mac. result holds index.html and assets/; result-1/bin/health is the binary. The image output exists only on Linux systems, so on darwin it is absent from nix flake show (the lab Mac omits the Linux systems). On a Linux builder, nix build .#image gives a tarball for docker load and then ECR. HEALTH_ADDR=0.0.0.0:8787 matters in a pod: the binary binds 127.0.0.1 by default, which a Service cannot reach.
§IV. The check that lied
checks.api-tests reruns the package with doCheck = true. First nix flake check (real):
duha-health> test tests::async_sleep_overlaps_on_one_thread ... FAILED
duha-health> test tests::spawn_blocking_frees_the_one_thread ... FAILED
duha-health> took 191 ms
duha-health> took 198 ms
These tests assert "eight 50 ms async sleeps finish under 100 ms". They pass under cargo test on the same machine (61 ms and 57 ms on Sunday). Under the Nix builder they took about 200 ms. Adding --test-threads=1 did not help (201 ms and 199 ms), so parallel tests were not the cause. The upper bound depends on how the build host schedules timers, and the Nix builder is a different host from your shell.
The two lower-bound tests ("blocking takes at least 400 ms") passed every time. The fix keeps those in the gate and leaves the upper bounds to the dev loop:
checkFlags = [ "--test-threads=1" "--skip" "async_sleep_overlaps" "--skip" "spawn_blocking_frees" ];
Third run: 2 passed; 0 failed; 2 filtered out, nix flake check exit 0. Skipping a test is a decision; the comment in the flake records why.
§V. Drills
buildNpmPackagefails with a hash mismatch after a dependency bump. Fix? (Copy thegot:hash intonpmDepsHash.)- The pod is Running but the Service gets connection refused. First suspect? (The binary bound
127.0.0.1; setHEALTH_ADDR=0.0.0.0:8787.) - Why is
imagemissing on the lab Mac? (It is gated onhostPlatform.isLinux.) - Which kind of timing assertion is safe in a Nix check: "at least" or "at most"? (At least.)
Close
One Lock per Language, Fake Hash Real Hash, A Check Must Fail for a Reason. Package both apps, gate on checks, and when a check fails, prove the cause before you change the gate.
Related
- Tome: Nix language basics (grounded-in), Nix Pills (referenced)
- Web: nixpkgs Rust · buildNpmPackage · dockerTools · nix flake check
- Inputs: Duha and Asr bundles of 2026-10-11 · Paired Cert: CKA etcd snapshot and restore