Fully Removing Nix: Rebuilding the Local Toolchain with mise + uv, and Handing CI Over to a Single Self-Built Image

This article was last updated on: November 9, 2026 pm

Background

My Homelab2 repo used to follow the upstream repo and use nix. But there were two things that felt awkward:

  1. My desktop runs Ubuntu, so using nix is odd;
  2. The nix image is a bit large, and every CI run in China takes several minutes just to pull it.

The goal of this change is just one: remove Nix completely from both the local development and CI paths, and leave no opening for it to creep back.

The old devShell had 31 tools installed, and the CI side depended on the nixos/nix and nixery.dev images. After the migration, either of those names showing up even once in the repo gets blocked by scripts/check-no-nix.

Next, let me describe the planned division of labor between the tools.

1. uv manages Python, mise manages the rest

All the Python stuff goes to uv:

  1. The interpreter version is pinned to 3.13 by .python-version
  2. Virtual environments, dependency resolution, and uv.lock are all committed to the repo
  3. Python CLI tools also go through uv
1
2
# .python-version
3.13

Dependencies are grouped using PEP 735 dependency groups in the root pyproject.toml, four groups in total: scripts, dev, ci, docs. Very clear — the names tell you what each is for.

Non-Python tools go to mise, with versions pinned precisely to the patch level (my habit, for security reasons):

Tool Pinned version
helm 3.20.2
kubectl 1.36.6
kustomize 5.8.3
opentofu 1.11.14
go 1.26.10
shellcheck 0.11.0

The entry point is unified into a single command: mise run setup

1
2
3
4
5
6
[tasks.setup]
description = "Install mise tools and sync uv dependencies"
run = """
mise install --yes
uv sync --all-groups
"""

Why not use mise for everything, or uv for everything?

uv treats the Python interpreter, venv, and lock as one unit; switching to mise for Python would mean assembling all that yourself. mise, conversely, can manage binaries like helm, go, and opentofu, which uv cannot.

📝 Notes: The boundary of this split is the two lines of language ecosystems and general-purpose binaries, not a line between new and old tools. Personal habit, for reference only.

2. CI only pulls the image, it doesn’t build it

The CI approach is a single image plus host-machine builds (host builds are also to save CI time).

The image is defined in .woodpecker/ci/Dockerfile, and the base image debian:13-slim is pinned by digest so it doesn’t drift with the tag:

1
2
# .woodpecker/ci/Dockerfile
FROM debian:13-slim@sha256:<digest> # pinned by digest, does not follow the tag

The image only installs what pre-commit (for static checks) and helm-diff need: helm, dyff (for helm chart selection before diffing), shellcheck, git, diffutils, Python, and pre-commit.

go, opentofu, ansible, kubectl, kustomize, gotestsum, and the docker CLI are not installed at all. The reasons:

  1. Installing them makes the image too large
  2. go, opentofu, ansible, kubectl, kustomize, gotestsum, and the docker CLI are rarely used in normal CI scenarios, because the related code rarely changes

The build is done by me on the host machine running scripts/build-ci-image, which internally is:

1
docker buildx build --push --provenance=false

It pushes both the latest and sha-<short> tags and outputs the digest. The CI side only does pull: true, uses no Woodpecker secrets, and has no privileged steps. The benefit: no image build time in the pipeline, and no need to stuff registry credentials into CI. The cost: rebuilding the image becomes a manual step, which I’ll cover later. (I think it’s acceptable.)

3. The image shrank from 3.57GB to 842MB

The first version was a full image, 3.57GB. (Uncompressed size.)

The k8s agent pull timed out outright and the CI pipeline failed:

1
backoff: maximum elapsed time exceeded (last error: container "wp-01m68s38mskt3yad4bz2yjvh0d" in pod "wp-01m68s38mskt3yad4bz2yjvh0d" is waiting to start: trying and failing to pull image)

Then came round after round of trimming: 3.57GB → 2.36GB → 1.68GB → 842MB, four rounds.

The final version landed at DISK USAGE 219MB, CONTENT SIZE 842MB — about 23% of the first version’s size.

📝 Notes: Image size here is not an aesthetic issue. 3.57GB pushes cold-start pulls straight past the timeout line — that’s the real reason the CI pipeline failed.

4. The performance numbers: from 1174s to 20s

Before the migration, the median duration of the last 5 successful PR pipelines was 1174s🤣🤣🤣 (in China, nothing to be done).

When the image was still large at the beginning:

Pipeline Duration Notes
#2702 (PR #600) 423s full pipeline
#2704 569s full pipeline
#2707 71s pre-commit only

After the final optimization:

  • Full pipeline 60s
  • pre-commit only 20s

This gain can basically be attributed to one thing: the image no longer contains that pile of unused stuff — go, opentofu, kubectl, kustomize, the docker CLI — so pull time dropped dramatically.

5. Preventing regressions: turning conventions into scripts

A one-time migration and long-term non-degradation are two different things.

I added scripts/check-no-nix to the repo, with two scan lines:

  1. Scan file names with git ls-files; a hit on flake.nix, flake.lock, .envrc, or run_nix_develop.sh is an error
  2. Scan contents with git grep; a hit on Nix commands, nixos/nix, nixery.dev, or nixpkgs is an error

The script is also hooked into the local pre-commit hook, giving feedback on local commits and blocking in CI.

CI’s static-checks now only runs the other pre-commit hooks with SKIP=tofu_fmt, 13 hooks. Running tofu_fmt would require installing opentofu, and terraform is almost never modified.

6. Benefits and costs

👍️ Benefits:

  • Local environment in one mise run setup, with versions pinned to the patch level
  • CI cold start down from the 1174s baseline to 20s
  • Image is 219MB, without unused toolchains
  • Nix regressions are caught by a script, not by human memory

👎 Costs:

  • The CI image has to be built by yourself

After the migration, final acceptance on 2026-10-31: local make filter=Tools (11 tests), pre-commit (13 hooks), mkdocs --strict, and scripts/check-no-nix all passed.

Summary

Here’s the summary of this migration:

  • Remove Nix completely from both the local development and CI paths:
    • Python goes to uv, binary tools go to mise,
    • the CI side switches to a single self-built image built on the host machine, with the pipeline only pulling it,
  • and use the check-no-nix script to prevent regressions.

I feel this article may not be of much reference value to readers (the only valuable point may be: given the network conditions in China, prefer a toolchain that is small, reachable from China, or has a Chinese mirror), so you can read it for fun~

📚️ References


Fully Removing Nix: Rebuilding the Local Toolchain with mise + uv, and Handing CI Over to a Single Self-Built Image
https://e-whisper.com/posts/55930/
Author
east4ming
Posted on
November 5, 2026
Licensed under