← All TILs · nixos

A minimal IaC toolbox on WSL2 with standalone Home Manager: Nix owns Python and OpenTofu, uv owns Ansible

nixos - 2026-10-08

WSL2 (Windows Subsystem for Linux) runs a real Linux in a lightweight virtual machine on Windows. This entry builds a small admin and IaC (infrastructure as code) toolbox inside it, from one Git repository: a system Python 3, OpenTofu, uv, and Ansible with ansible-lint. Home Manager installs it. It's a tool that declares a user's packages and dotfiles in the Nix language (see the NixOS-module entry for how it works inside nixos-rebuild). uv is a fast Python package and project manager that replaces pip, venv, pipx and pyenv.

The same flake then drives a .devcontainer, for projects that should carry their own environment. Built and activated with Home Manager release-26.05, nixpkgs nixos-26.05, uv 0.11 and ansible-core 2.20 on x86_64 Linux. WSL itself wasn't available here: the activation ran in a plain Linux container with Nix installed, in a scratch home directory. The one NixOS-only failure, below, is from the documentation and wasn't reproduced.

Why standalone, and why two tools

The other entry uses Home Manager as a NixOS module, which only works on a NixOS machine. Standalone Home Manager is a home-manager command that manages one user's home and needs only Nix. So the same file works on NixOS-WSL and on an Ubuntu or Debian WSL distribution with Nix installed. That is handy when work gives you Ubuntu and the homelab runs NixOS.

The split is by who updates what:

Tool Installed by Why
Python 3, OpenTofu, Git, uv Home Manager (Nix) Pinned by flake.lock (explained below), rolled back with a generation, one saved state of the profile
ansible-core, ansible-lint uv tool install They're Python packages, and Ansible releases move faster than nixpkgs
Project libraries (jinja2, netaddr…) uv add in each project Per-project, locked in uv.lock

The flake

A flake is a Git repository with a flake.nix that declares its inputs and outputs, and a flake.lock that pins every input to a commit. Both Home Manager and nixpkgs sit on the same release:

{
  inputs = {
    nixpkgs.url = "git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1";
    home-manager = {
      url = "git+https://github.com/nix-community/home-manager?ref=release-26.05&shallow=1";
      inputs.nixpkgs.follows = "nixpkgs";   # one nixpkgs, not two
    };
  };

  outputs = { nixpkgs, home-manager, ... }: {
    homeConfigurations."nixos@wsl" = home-manager.lib.homeManagerConfiguration {
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
      modules = [ ./home.nix ];
    };
  };
}

home.nix

{ config, lib, pkgs, ... }:
{
  home.username = "nixos";            # `whoami` inside WSL
  home.homeDirectory = "/home/nixos";
  home.stateVersion = "26.05";        # set once, leave it
  home.sessionPath = [ "${config.home.homeDirectory}/.local/bin" ];  # where uv puts tools

  home.packages = with pkgs; [ python3 opentofu git ];

  programs.uv = {
    enable = true;
    settings = {
      python-downloads = "never";       # never fetch a Python of its own
      python-preference = "only-system"; # use the one on PATH: Nix's
    };
  };

  # Ansible from PyPI, after the packages (and so uv) are in the profile.
  home.activation.uvTools = lib.hm.dag.entryAfter [ "installPackages" ] ''
    export PATH="${config.home.profileDirectory}/bin:$PATH"
    run uv tool install "ansible-core==2.20.*" --with-executables-from ansible-lint
  '';
}

programs.uv.settings writes ~/.config/uv/uv.toml. I confirmed the file holds exactly those two lines. home.activation entries run as shell during home-manager switch; entryAfter [ "installPackages" ] orders it after the profile is built, and run skips the command on a dry run.

Activate it

First time, with no home-manager command yet:

nix run home-manager/release-26.05 -- switch --flake .#nixos@wsl

After that, home-manager switch --flake .#nixos@wsl. The result in my test:

$ tofu version        # OpenTofu v1.11.14
$ uv tool list
ansible-core v2.20.10
- ansible
- ansible-config
- ansible-lint
- ansible-playbook
...
$ ansible-lint --version
ansible-lint 26.9.0 using ansible-core:2.20.10 ...

ansible-core and ansible-lint share one environment, so the linter always runs against the Ansible you run.

Pitfalls I hit

Why python-downloads = "never" matters on NixOS

By default uv downloads prebuilt Python builds when none matches. NixOS has no standard dynamic loader at /lib64/ld-linux-x86-64.so.2, so these binaries refuse to start with a Could not start dynamically linked executable message from NixOS's stub-ld. I did not reproduce that here, since the container isn't NixOS. Telling uv to only use the Nix-provided Python avoids the problem. The cost: Python versions come from nixpkgs, not from uv. On an Ubuntu WSL distribution, the setting is optional, but it keeps both machines behaving the same.

A project on top

mkdir lab && cd lab
uv init --no-workspace
uv add netaddr jinja2         # recorded in pyproject.toml and locked in uv.lock
uv run python -c "import jinja2"

Commit pyproject.toml and uv.lock; .venv/ stays out of Git. A clone anywhere does uv sync. In my test uv run used .venv/bin/python3 created from the Nix Python.

The same toolbox as a .devcontainer

A Dev Container is a container that VS Code (through the Dev Containers extension) or the @devcontainers/cli opens a project in. The editor server and every tool run inside it, and the configuration is a devcontainer.json file in the repository. The ADT Dev Container entry uses a ready-made Ansible image. Here the image is a plain Ubuntu one, and the toolbox above is installed into it by the same Home Manager flake, so the container and the WSL distribution can't drift apart. On WSL2 the container engine is Docker Desktop with its WSL 2 backend, or Docker Engine or Podman inside the distribution (Podman setting).

Three files, under .devcontainer/:

.devcontainer/
├── devcontainer.json
└── home/
    ├── flake.nix      # the flake from above, with username "vscode"
    ├── flake.lock     # created by the first run; commit it
    └── home.nix       # the home.nix from above, with home.username = "vscode"
{
  "name": "iac-toolbox",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",
  "features": {
    "ghcr.io/devcontainers/features/nix:1": {
      "extraNixConfig": "experimental-features = nix-command flakes"
    }
  },
  "remoteUser": "vscode",
  "postCreateCommand": "USER=$(id -un) nix run \"git+https://github.com/nix-community/home-manager?ref=release-26.05&shallow=1\" -- switch --flake .devcontainer/home#vscode -b backup",
  "customizations": {
    "vscode": {
      "extensions": ["redhat.ansible", "ms-python.python", "charliermarsh.ruff", "opentofu.vscode-opentofu"]
    }
  }
}

I ran it with @devcontainers/cli 0.89.0 on Docker 29.8.2. After devcontainer up, a plain devcontainer exec shell found tofu (OpenTofu 1.11.14), uv 0.11.21, ansible 2.20.10 and ansible-lint on the PATH. The extensions list wasn't exercised, because there was no VS Code. In this sandbox I had to add the proxy's CA certificate to a copy of the base image so that the Nix feature could clone from GitHub; on a normal network the file above works as written.

Three things failed on the way, and each has an easy fix:

Home Manager in a container costs more on the first build than a ready-made image: Nix evaluates and downloads the closure (the packages and everything they depend on) once. In return, the Python, OpenTofu and uv versions come from the lock file, not from an image tag.

Keep it in Git

Put flake.nix, flake.lock and home.nix in a repository under your WSL home (on the Linux side, not /mnt/c, which is much slower). nix flake update moves every pin, git diff flake.lock shows what changed, and home-manager generations lists the generations you can go back to.

Sources

Created 2026-10-08T19:15:07+02:00 · Edit