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:
- My desktop runs Ubuntu, so using nix is odd;
- 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:
- The interpreter version is pinned to 3.13 by
.python-version - Virtual environments, dependency resolution, and
uv.lockare all committed to the repo - Python CLI tools also go through uv
1 | |
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 | |
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 | |
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:
- Installing them makes the image too large
- 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 | |
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 | |
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:
- Scan file names with
git ls-files; a hit onflake.nix,flake.lock,.envrc, orrun_nix_develop.shis an error - Scan contents with
git grep; a hit on Nix commands,nixos/nix,nixery.dev, ornixpkgsis 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~