Skip to content

build(nix): modular flake.nix + ./nix static-analysis harness#144

Draft
daveseddon-runpod wants to merge 18 commits into
mainfrom
nix
Draft

build(nix): modular flake.nix + ./nix static-analysis harness#144
daveseddon-runpod wants to merge 18 commits into
mainfrom
nix

Conversation

@daveseddon-runpod

Copy link
Copy Markdown

What

Adds a small root flake.nix that wires up modular ./nix/ modules, giving a reproducible, pinned static-analysis surface for this repo — no global tool installs needed.

Opened as a draft so we can watch the new Nix static analysis workflow run and see what the harness surfaces.

Layout

  • flake.nix — thin entry point (flake-utils.eachSystem), src filter, formatter = nixfmt-rfc-style.
  • nix/lib{,/*} — shared builders: mk-check (gated derivations), mk-lint (per-tool apps), mk-all-app (composites), maybe-tool (graceful skip when a tool is absent from the pin).
  • nix/checks.nix — sandbox-safe gates for nix flake check.
  • nix/lints/* — per-tool apps + domain composites (lint-shell/-yaml/-json/-py/-nix-files/-docker/-md/-secrets/-sast/-supply/-spelling) + lint-extreme + everything.
  • nix/devshell/nix develop toolchain.
  • Configs: .yamllint.yaml, .markdownlint.yaml, pyproject.toml; docs in nix/README.md.
  • .github/actionlint.yaml — declares the repo's Blacksmith self-hosted runner labels.

CI

.github/workflows/nix.yml installs Nix and runs nix flake check --keep-going — the gated, sandbox-safe subset:
nixfmt / statix / deadnix / nil, shellcheck, yamllint, actionlint, hadolint, markdown, taplo, editorconfig-checker, gitleaks.

Network / dependency-heavy tools (trivy, osv-scanner, semgrep, lychee, trufflehog, pyright, pylint) are exposed as nix run .#lint-* apps rather than CI gates.

Usage

nix develop            # shell with every tool
nix flake check        # gated subset (what CI runs)
nix run .#lint-py      # a whole domain
nix run .#everything   # flake check + every manual lint

Heads-up

This runs the linters against the existing tree with pragmatic defaults, so the first CI run is expected to surface pre-existing findings (e.g. hadolint DL3008, editorconfig/whitespace, shellcheck style). That output is the point — it shows coverage. We can then decide what to tune (per-tool configs) vs. fix before making any of it a required check.

Known coverage gaps

HCL (docker-bake.hcl), INI (grafana.ini), and CSV have no dedicated linter wired in (documented in nix/README.md); large generated JSON is validated for well-formedness only.

randomizedcoder and others added 4 commits July 10, 2026 17:46
Small root flake wiring modular ./nix modules (flake-utils.eachSystem):
- nix/lib{,/*}: mk-check, mk-lint, mk-all-app, maybe-tool builders
- nix/checks.nix: sandbox-safe gated checks for `nix flake check`
  (nixfmt/statix/deadnix/nil, shellcheck, yamllint, actionlint,
  hadolint, markdown, taplo, editorconfig, gitleaks)
- nix/lints/*: per-tool apps + domain composites (shell/yaml/json/py/
  nix/docker/md/secrets/sast/supply/spelling) + lint-extreme/everything
- nix/devshell: `nix develop` toolchain
- pragmatic tool defaults; configs: .yamllint.yaml, .markdownlint.yaml,
  pyproject.toml; docs in nix/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .github/workflows/nix.yml: install Nix and run the gated, sandbox-safe
  checks (nix flake check --keep-going) on PR / push-to-main / dispatch.
- .github/actionlint.yaml: register the repo's self-hosted Blacksmith
  runner labels so actionlint stops reporting them as unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aks FP

Two issues the first CI run surfaced:
- actionlint gated check failed with "no project was found" because the
  mkCheck sandbox stages src without .git. `git init` a throwaway repo so
  actionlint can locate workflows + read .github/actionlint.yaml.
- gitleaks flagged .github/actions/smoke-test/action.yml:205 — a
  "-----BEGIN RSA PRIVATE KEY-----" string literal in a case statement that
  validates a user-supplied SSH key, not a committed secret. Add
  .gitleaks.toml allowlisting that file + PEM-header pattern (matchCondition
  = AND) so real keys elsewhere still trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add .hadolint.yaml ignoring DL3006/DL3008/DL3013/DL3022/DL3042 — the same
codes the repo's hadolint-pr/hadolint-push workflows already ignore
per-Dockerfile — so the Nix gate agrees with established policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

First CI runs — results

The Nix static analysis workflow runs nix flake check --keep-going and completes in ~40s (Nix install + all gated checks, uncached). It surfaced findings immediately, and I've pushed fixes for the two that were harness bugs / false positives. Current state:

✅ 10 gated checks passing: nixfmt, statix, deadnix, nil, shellcheck, taplo, editorconfig, actionlint, gitleaks, hadolint

❌ 2 remaining — pre-existing style findings (team decision to fix vs. relax):

What got fixed along the way

  • actionlint — was failing with no project was found: the mkCheck sandbox stages the source without .git, which actionlint needs to locate workflows + read config. Fixed by git init-ing a throwaway repo in the check (nix/checks.nix).
  • gitleaks — flagged .github/actions/smoke-test/action.yml:205, which is a -----BEGIN RSA PRIVATE KEY----- string literal in a case statement that validates a user-supplied SSH key — not a committed secret. Added .gitleaks.toml allowlisting that file + PEM-header pattern (matchCondition = AND, so a real key committed elsewhere still trips it).
  • hadolint — aligned with the repo's existing policy via .hadolint.yaml ignoring DL3006/3008/3013/3022/3042 (the same codes hadolint-pr.yml / hadolint-push.yml already ignore per-Dockerfile).

Remaining findings

yamllint — 19 issues, all trivial hygiene:

  • 13× trailing spaces
  • 5× missing newline at end of file
  • 1× CRLF line ending

markdownlint — 130 issues, style: top rules MD060 table-column-style (40), MD012 multiple-blanks (28), MD032 blanks-around-lists (14), MD022 blanks-around-headings (11), MD040 fenced-code-language (8), …

Recommendation: the yamllint set is a cheap cleanup pass (whitespace only). The markdown set is larger — either a markdownlint-cli2 --fix pass or relax a few more rules in .markdownlint.yaml. Both are pre-existing (not introduced here), so I've left them visible rather than papering over them; happy to do a cleanup commit or dial the configs back, whichever you prefer. Until then these checks stay red by design — the workflow shouldn't be a required gate yet.

randomizedcoder and others added 2 commits July 10, 2026 19:26
Make the Nix gated checks pass on the existing tree:
- yamllint (19): strip trailing whitespace, add final newlines, CRLF→LF
  in .github workflow/action/dependabot YAML.
- markdownlint (130): markdownlint-cli2 --fix for the auto-fixable rules;
  prettier --write to normalize GFM tables (MD060/MD056); add `text`
  language to bare code fences (MD040); fix an invalid heading-anchor
  link fragment (MD051) in tests/README.md.

No behavioral changes — docs/CI YAML formatting only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prototype building runpod/base (CPU) with pkgs.dockerTools.streamLayeredImage
instead of a Dockerfile, to quantify the win. Build-only — nothing is pushed.

- nix/images/base-cpu.nix: Python 3.14 (jupyterlab/notebook/ipywidgets/
  hf_transfer) + uv/filebrowser/nginx/openssh + CLI tooling + start.sh.
- nix/images/{default.nix}: exposed as packages.<system>.base-cpu (Linux
  only); nix flake check does not build it, so gated lint stays fast.
- nix/images/compare-size.sh: builds, measures compressed/uncompressed size,
  asserts stream determinism (sha256 x2), and diffs vs the published image
  via skopeo.
- .github/workflows/nix.yml: add build-only `image-size` job.
- nix/images/README.md: approach, measured numbers, caveats, GPU parity path.

Measured: nix base-cpu 295 MB vs runpod/base:1.0.7-ubuntu2404 714 MB
compressed (~59% smaller); byte-identical across two builds. Caveat: PoC ships
one Python (3.14) vs the base's 3.9-3.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Experimental: Nix-built container image (build-only)

Added a proof-of-concept for building the images with Nix (pkgs.dockerTools.streamLayeredImage) instead of Dockerfiles, purely to measure the win. Nothing is published — a new build-only image-size CI job builds the image and prints this comparison (reproduced on every run):

image compressed (download) uncompressed layers
nix base-cpu (Python 3.14) 295 MB 921 MB ~98
runpod/base:1.0.7-ubuntu2404 (Dockerfile) 714 MB 20

≈ 59% smaller compressed download, and the stream is byte-identical across two builds (deterministic — CI asserts it via sha256 ×2).

What's in it

nix/images/base-cpu.nix → Python 3.14 (jupyterlab/notebook/ipywidgets/hf_transfer) + uv/filebrowser/nginx/openssh + the common CLI tooling + the repo's start.sh. Exposed as packages.<system>.base-cpu (Linux only); nix flake check does not build it, so the gated lint job stays at ~35s.

Fair-comparison caveat

The published base bundles Python 3.9–3.13 + a full apt userland; this PoC ships a single Python (3.14). Some of the delta is "fewer Pythons" — but the determinism, per-store-path layer dedup, and no-apt/no-compilers-in-the-image properties are structural and hold regardless. Adding multi-Python parity would grow it (est. +150–250 MB) and still beat the Dockerfile build.

Bumps

  • Tracks latest nixos-unstable; PoC image uses Python 3.14.

GPU parity (not done here)

Documented in nix/images/README.md. Recommended next step: the hybrid approach — keep NVIDIA's tested CUDA base as fromImage and layer the deterministic Nix userland on top — then re-run compare-size.sh against a CUDA PyTorch tag.

Full write-up, tradeoffs, and local repro in nix/images/README.md.

- nix/images/userland.nix: shared userland (python3.14 + jupyter + tooling)
  factored out of base-cpu; used by both CPU and CUDA images.
- nix/images/base-cuda.nix: HYBRID image — pin nvidia/cuda:12.8.1-cudnn-devel
  base (digest + nix-prefetch-docker hash) as fromImage, layer the Nix
  userland on top. Comparable to runpod/base:*-cuda1281 (torch excluded; it
  pip-installs identically on both).
- nix/images/compare-cuda-size.sh + manual-dispatch `cuda-image-size` CI job
  (heavy: pulls ~5.6 GB base, so not run on every push; frees disk first).
- README: CUDA results + a section on maximizing shared layers across an image
  family (store-path dedup, the per-image customisation-layer subtlety, tiered
  fromImage bases, maxLayers, nix2container, a family "footprint" metric).

Measured: hybrid base-cuda 5846 MB vs runpod/base:cuda1281 6359 MB compressed.
The Nix userland layer is ~67% smaller than the apt layer (253 vs 766 MB);
whole-image win is modest since the shared CUDA base + torch dominate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Hybrid CUDA image (build-only) — result

Pinned nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04 as fromImage and layered the same Nix userland on top. Compared to runpod/base:…-cuda1281 (torch pip-installs identically on both, so excluded — this compares the userland layer). Built + measured in CI (manual-dispatch cuda-image-size job, 15m):

image compressed
nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04 (shared base) 5593 MB
hybrid base-cuda (nvidia + Nix userland) 5846 MB — Nix layer ≈ 253 MB
runpod/base:1.0.7-cuda1281-ubuntu2404 (nvidia + apt) 6359 MB — apt layer ≈ 765 MB
runpod/pytorch:1.0.7-cu1281-torch280 (base + torch) 11211 MB

The Nix userland layer is ~67% smaller than the apt one (253 vs 765 MB). But the shared CUDA base + torch wheels dominate, so the whole-image win is only ~8%. Honest takeaway: for GPU images the big levers are a runtime (non-devel) base and a Nix-built torch, not the userland.

The cuda-image-size job is manual-dispatch only (if: github.event_name == 'workflow_dispatch') — it pulls a 5.6 GB base and streams ~11 GB, so it must not run on every push. Trigger with gh workflow run "Nix static analysis" --ref <branch>.

Cross-image layer sharing (fleet caching)

Since these co-deploy on the same hosts, cross-image Docker layer reuse is the real win. dockerTools layers are keyed by store path → identical paths give byte-identical layers/digests → a host caches shared deps once (base-cpu and base-cuda already share their userland layers). Caveat: streamLayeredImage assigns layers per-image (popularity heuristic, maxLayers cap), so a shared path can land in a dedicated layer in one image and the customisation bundle in another. To force maximal overlap: tiered fromImage bases (common → +CUDA → +torch), raise maxLayers, or use nix2container (purpose-built for family-wide sharing) — plus a "unique-vs-naïve cached bytes" footprint metric as a guardrail. Full write-up in nix/images/README.md.

randomizedcoder and others added 2 commits July 11, 2026 17:38
Prototype the cross-image layer-sharing win (fleet caching):
- userland.nix: add mkContents so family members extend the common userland
  while reusing the exact same shared store paths (=> shared docker layers).
- family.nix: family-base / family-data / family-serve, all sharing the
  userland, each adding only its own Python stack.
- footprint.sh + CI job `family-footprint`: measure unique cached NAR bytes
  (shared layers deduped) vs the naïve per-image sum.

Measured: naïve 3388 MB vs unique 1575 MB across the 3-image family — a host
running all three caches ~1.6 GB instead of ~3.4 GB (53% saved by sharing).
README documents this plus how to push overlap further (tiered fromImage
bases, maxLayers, nix2container).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nix path-info reports narSize only for realized store paths; on a fresh CI
runner the image outputs don't exist yet, so the footprint printed 0 MB. Build
the family (--no-link) first so path-info sees real sizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Prototype: shared-userland image family + layer-sharing footprint

Built the tiered/shared-base idea and a metric for the fleet-caching win. A family of images all reuse the exact same Nix userland store paths and only add their own Python stack, so those layers dedupe on any host running more than one:

  • family-base — plain userland
  • family-data — + numpy/pandas/scikit-learn/matplotlib/scipy
  • family-serve — + fastapi/uvicorn/pydantic/pillow/requests

New CI job family-footprint (footprint.sh) measures what a host actually caches once shared layers dedupe vs the naïve per-image sum:

store closure (NAR)
family-base 875 MB
family-data 1551 MB
family-serve 962 MB
naïve sum (no sharing) 3388 MB
unique cached on a host (deduped) 1575 MB
saved by sharing 1813 MB — 53%

A box running all three flavors caches ~1.6 GB instead of ~3.4 GB. This is automatic in Nix: layers are keyed by store path, so the shared userland + tools are the same layer digests across every image — Dockerfile images only share their exact common prefix. Metric is uncompressed NAR bytes (what the Nix store + Docker dedup key on); compressed is proportional.

Ways to push it further (in nix/images/README.md): tiered fromImage bases (common → +CUDA → +torch), higher maxLayers, or nix2container; and footprint.sh doubles as a CI guardrail so a change that breaks sharing shows up as a regression.

CI status: flake-check (34s), image-size (~3m), family-footprint (41s) all green; cuda-image-size is manual-dispatch (skips on push).

randomizedcoder and others added 2 commits July 11, 2026 19:11
fleet-footprint.sh + manual-dispatch CI job `fleet-footprint`: measure how much
the published runpod fleet (base cpu/cuda/rocm, full pytorch matrix,
autoresearch, nvidia-pytorch = 38 release images) already shares Docker layers
today — unique blob digests vs the naïve per-image sum, via skopeo manifests
(no pulls; authenticates with DOCKERHUB_* if set).

Add a workflow_dispatch `heavy` input (none/cuda/fleet/all) so the two heavy
jobs can be triggered independently instead of always together.

Measured: 38 images, naïve 448.7 GB vs unique 258.6 GB — the Dockerfile fleet
already dedupes ~42% via its shared FROM chain. Reframes the Nix win honestly:
the CUDA base is shared either way; Nix's lever is the userland tier + finer
store-path sharing + determinism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The family footprint previously summed Nix closure NAR sizes, which overstates
sharing: streamLayeredImage only gives the top maxLayers (default 100) store
paths a dedicated layer and bundles the rest into a per-image customisation
layer with a distinct digest. Store-path sharing != OCI-layer sharing.

footprint.sh now streams each image's docker-archive, reads real layer digests
+ sizes via skopeo, and dedupes by digest. Real result: 34% shared at the
default (not 53%). README documents the maxLayers sweep (100→34%, 200→38%,
400→53% but 275 layers/img) and points at nix2container to reach the ceiling
without the layer-count blow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Correction: real OCI layer sharing (not store-path closure)

Good catch on my earlier family-footprint number — it measured Nix closure (store paths), which overstates Docker layer sharing. streamLayeredImage only gives the top maxLayers (default 100) store paths their own layer and bundles the rest into a per-image customisation layer with a distinct digest. So identical store paths do not all become shared OCI layers.

footprint.sh now measures the real OCI layers (stream docker-archive → skopeo inspect → dedupe by blob digest — exactly what a host's Docker cache shares):

real OCI layers
family-base 99 layers, 921 MB
family-data 99 layers, 1623 MB
family-serve 99 layers, 1010 MB
naïve sum 3555 MB
unique on a host (deduped by digest) 2312 MB
saved by sharing 1243 MB — 34% (was mis-reported as 53%)

And the maxLayers lever, measured:

maxLayers real OCI shared layers/image
100 (default) 34% 99
200 38% 199
400 53% 275

So the 53% store-path figure is only a ceiling, reached when every store path is its own layer (~275 layers/image — impractical). Default dockerTools gives 34% real OCI sharing; raising maxLayers trades layer count for sharing; nix2container reaches the ceiling without the layer-count blow-up.

Note: the fleet number (42%, published images) was already real OCI — it used skopeo LayersData digests all along. Only the family metric needed this fix. README updated; family-footprint CI job now green with the corrected metric.

…parison

Add nix2container as a flake input and mirror the family as separate *-n2c
targets (family-base-n2c/family-data-n2c/family-serve-n2c), so the two layering
strategies can be compared without disturbing the dockerTools work. Expose the
patched skopeo-n2c for reading nix2container's `nix:` transport.

footprint.sh now measures BOTH families' real OCI layer sharing side by side.

Findings (measured):
- The sharing ceiling is closure-bound (~53% here), reached by BOTH builders
  only at ~275+ layers/image; neither exceeds it.
- nix2container does NOT raise the ceiling; at its default (100 layers) it
  shares LESS (16%) than dockerTools (34%). Its edge is that many layers are
  cheap to build (lazy store-path refs) — so maxLayers=600 is practical.
- The real lever is deliberate tiering (one shared userland layer via
  buildLayer / fromImage), not cranking maxLayers to ~275 (overlay2/registry
  discourage that many layers). README updated with the full sweep + guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

nix2container as separate targets — head-to-head

Added nix2container as a flake input and mirrored the family as separate *-n2c targets (dockerTools work untouched, easy to compare). footprint.sh now measures both, real OCI layers, side by side. The result corrects a common assumption:

builder maxLayers real OCI shared layers/img
dockerTools 100 (default) 34% 99
dockerTools 200 38% 199
dockerTools 400 53% 275
nix2container 100 16% 100
nix2container 300 48% 283
nix2container 600 53% 288

Two findings:

  1. The sharing ceiling is set by the closure, not the tool — both converge at ~53% (the store-path ceiling), and only when maxLayers ≥ closure size (~275) so every path is its own layer. Neither exceeds it.
  2. nix2container does not raise the ceiling; at its default (100) it shares less (16%) than dockerTools, because it bundles the non-dedicated closure into fewer/larger per-image layers. Its real edge is that many layers are cheap to build (lazy store-path refs, no per-layer tarball), so maxLayers=600 is practical where dockerTools-400 must materialise 275 tarballs.

The catch either way: 53% needs ~275+ layers/image — overlay2 and some registries discourage that many. So per-path auto-layering isn't the answer.

The actual lever is deliberate tiering: put the whole shared userland into one explicit layer every image reuses (nix2container buildLayer, or fromImage chaining) → near-ceiling sharing at a low layer count. That's the natural next step if we pursue this.

CI green: family-footprint now reports both builders (dockerTools 34% / nix2container 53%); everything else stays green; heavy cuda/fleet jobs remain manual-dispatch.

family-tiered.nix (family-*-tiered): put the whole shared userland in ONE
explicit buildLayer every image reuses byte-for-byte, with only a small
per-flavor delta on top. Kept as separate targets alongside the dockerTools
and auto-layered n2c families.

footprint.sh now compares all three approaches on real OCI layers.

Result: tiering reaches ~the store-path sharing ceiling (51%, vs 53% auto) at
~15-21 layers/image instead of ~288 — near-maximal fleet sharing at a
practical layer count. Extends naturally to the GPU tiers (userland -> +CUDA
-> +torch). README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Explicit-tiering prototype — the practical answer

Added family-*-tiered targets: one explicit nix2container buildLayer holds the whole shared userland (~900 MB) that all three images reuse byte-for-byte, with only a small per-flavor delta on top. footprint.sh now compares all three approaches on real OCI layers (CI job family-footprint, green):

approach real OCI shared layers / image
dockerTools (auto, default) 34% 99
nix2container (auto, maxLayers 600) 53% 288
nix2container (tiered buildLayer) 51% ~15–21

Tiering reaches ~the sharing ceiling (51% vs 53%) at ~19 layers/image instead of ~288. The shared userland is one cached blob; family-data / family-serve add only their extra Python stack. That's near-maximal fleet sharing at a layer count overlay2/registries are happy with — the practical win the auto-layering couldn't deliver.

It extends naturally to the GPU tiers: common userland+CUDA (per version)+torch (per combo), each an explicit shared layer, so the whole fleet dedupes down to unique deltas.

Where the image experiment stands (all build-only, nothing published)

  • base-cpu (dockerTools): 295 MB vs runpod/base 714 MB compressed, deterministic.
  • base-cuda hybrid: nvidia base + Nix userland; Nix userland layer ~67% smaller than apt.
  • fleet footprint: current published images already share ~42%.
  • family layer-sharing: dockerTools 34% → n2c auto 53%@288 layers → n2c tiered 51%@~19 layers.

CI: flake-check, image-size, family-footprint green on every push; cuda-image-size + fleet-footprint are manual-dispatch.

randomizedcoder and others added 4 commits July 12, 2026 08:34
New nix/prod-images/ tree (separate from the experimental nix/images/ PoC),
rebuilding base + pytorch entirely from nixpkgs — every apt/pip/uv install as a
Nix package (source-built, hash-pinned), CUDA from nixpkgs cudaPackages (no
vendor nvidia/cuda base):

- pkgs/jupyter-archive.nix: the one requirements.txt entry missing from nixpkgs,
  built from the PyPI sdist (hatchling, hash-pinned).
- userland.nix: python 3.14 env (jupyterlab/notebook/ipywidgets/hf-transfer +
  jupyter-archive) + uv/filebrowser/nginx/openssh + apt-parity dev toolchain.
- runtime.nix: Nix-adapted /start.sh (replaces Debian `service` with direct
  nginx/sshd/jupyter), stages the nginx proxy config + banner.
- base.nix: prod-base-cpu, prod-base-cuda (+ nixpkgs CUDA 12.9/cuDNN).
- pytorch.nix: prod-pytorch-cpu (torch 2.12.0 CPU) + prod-pytorch (cudaSupport).
- flake.nix: expose prod-* under packages (Linux only).

Verified: prod-base-cpu 850 MB gzip, prod-pytorch-cpu 1250 MB gzip; userland
python imports (jupyterlab 4.5.8, hf_transfer, source-built jupyter_archive) OK;
flake check green. README documents review, approach, numbers, version-drift +
CUDA-build-cost caveats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n vendor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
prod-pytorch (CUDA) builds: torch 2.12.0 compiled against nixpkgs cudaPackages
(verified torch.version.cuda=12.9, cudnn 9.22) — no download.pytorch.org wheel,
every input hash-pinned. Image 7791 MB gzip vs vendor runpod/pytorch 11211 MB
(~30% smaller). README updated with the full prod-vs-vendor comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daveseddon-runpod

Copy link
Copy Markdown
Author

Native-parity rebuild: base + pytorch entirely from Nix

New nix/prod-images/ tree (separate from the earlier PoC in nix/images/) rebuilds the containers with no apt/pip/uv installs and no vendor CUDA base — everything source-built from nixpkgs, hash-pinned. All four targets build; torch is genuinely compiled against Nix cudaPackages (verified torch.version.cuda == 12.9, cuDNN 9.22 — no download.pytorch.org wheel).

target gzip (≈ download) vendor equivalent delta
prod-base-cpu 850 MB runpod/base:*-ubuntu2404 714 MB +19% (bundles full gcc/gfortran/ffmpeg toolchain; 1 python vs 5)
prod-base-cuda 3698 MB runpod/base:*-cuda1281 6359 MB −42%
prod-pytorch-cpu 1250 MB
prod-pytorch (CUDA) 7791 MB runpod/pytorch:*-cu1281-torch280 11211 MB −30%

What's Nix-native now: the jupyter-archive dep missing from nixpkgs is packaged from the PyPI sdist (source, hash-pinned); CUDA comes from nixpkgs redistributables (hash-pinned fetches, not the ~5.6 GB nvidia/cuda:*-devel base); torch/torchvision/torchaudio are source-compiled with cudaSupport. A Nix-adapted /start.sh reproduces the runtime contract (nginx/sshd/jupyter) without Debian service.

Honest caveats:

  • Version drift — native nixpkgs gives one torch (2.12.0) + CUDA 12.9, newer than and not matching the repo's 2.6–2.9.1 × cu126–130 matrix. Inherent to source-from-nixpkgs.
  • CUDA torch is a heavy from-source compile (unfree ⇒ absent from cache.nixos.org) — built locally here (~24-core/125 GB box); GH-hosted CI can't, so no CI job for it (mirrors the manual-dispatch cuda-image-size).
  • Python 3.14 vs 3.9–3.13; jupyterlab 4.5.8 vs pinned 4.5.9.
  • Runtime not container-tested in the sandbox (image builds + contents/imports verified; full nginx/sshd/jupyter startup needs a real pod).
  • Out of scope: autoresearch, nvidia-pytorch (NGC), rocm.

Verified: userland imports (jupyterlab/hf_transfer/jupyter_archive), torch CUDA build, flake-check green, all committed on nix. Details in nix/prod-images/README.md.

- prod-base-cpu-lean: runtime-only CPU base (drops the gcc/gfortran/cmake/
  ffmpeg dev toolchain). 299 MB gzip — ~58% smaller than the apt base (714 MB),
  vs the full toolchain base at 850 MB (+19%). Shows the CPU size is a choice,
  not a Nix limit. userland.nix split into coreTools/devTools with a `lean` flag.
- flake.lock: bump nixpkgs 2026-07-08 -> 2026-07-11 (latest nixos-unstable) so
  images get current versions (python 3.14.6, jupyterlab 4.5.8, notebook 7.5.6,
  torch 2.12.0, cuda 12.9). flake check stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants