I recently stumbled across Nix again — specifically its declarative dev shells, which turned out to be a good fit for a problem I kept running into.
My use case is pretty straightforward. I have to work with ops tools like Ansible, Terraform, and a handful of supporting utilities, often in different versions across different projects. Anyone who worked with Ansible has likely felt the pain with not matching Python versions or dependencies and I really wanted to get rid of this problem. Luckily, there are two ways to approach this with Nix shells.
The first is storing a dedicated configuration in each repository and activating it manually — or automatically with something like direnv — when entering the project directory.
The second is maintaining a general Nix shell registered in the local Nix registry, which can then be sourced from anywhere.
I'm currently using the second approach, though the per-repository option is interesting, especially when other engineers on the same project are also using Nix, but I have yet to play around with it.
The layout of a custom shell looks like this:
➜ infra-ops git:(main) tree
.
├── flake.lock
├── flake.nix
└── README.md
1 directory, 3 files
flake.lock is the lockfile for pinned versions — comparable to package-lock.json in the JavaScript ecosystem. The interesting part is flake.nix, which declares all packages available in the dev shell. I won't go into details here, because I also don't understand all of it yet (well I just know how to use it). I'm also using flake-utils to abstract over the target architecture (x86_64 or aarch64).
{
description = "Reproducible Infrastructure Environment";
# 1. Inputs: Where do we get the packages from?
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
# 2. Outputs: What does this flake produce?
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config = {
allowUnfree = true;
};
};
# 3. Custom Python Environment
# If your Ansible modules need specific Python libraries,
# add them to this list.
pythonWithPackages = pkgs.python3.withPackages (
p: with p; [
ansible-core # The core Ansible engine
requests # Example: HTTP library
netaddr # Example: IP address manipulation
google-auth
hvac
]
);
in
{
formatter = pkgs.nixfmt-tree;
# 4. The Development Shell
devShells.default = pkgs.mkShell {
buildInputs = [
# The Python environment defined above
pythonWithPackages
# Other non-Python tools
pkgs.ansible-lint
pkgs.sshpass
pkgs.google-cloud-sdk
pkgs.vault
pkgs.packer
pkgs.tenv
pkgs.git
];
# Ansible flags
OBJC_DISABLE_INITIALIZE_FORK_SAFETY = "YES";
OS_ACTIVITY_MODE = "disable";
# 5. Shell Hook: Runs when entering the shell
shellHook = ''
export VAULT_ADDR=https://vault.example.com
export TENV_AUTO_INSTALL=true
echo "🚀 Welcome to the Infra Ops Shell!"
echo "Ansible version: $(ansible --version | head -n1)"
echo "Python location: $(which python)"
'';
};
}
);
}
To use this from anywhere, register the flake in the local Nix registry:
nix registry add infra /path/to/nix-templates/infra-ops
Running nix develop infra from any directory opens the shell with all configured tools available:
$ nix develop infra
🚀 Welcome to the Infra Ops Shell!
Ansible version: ansible [core 2.19.4]
Python location: /nix/store/zbcd1vkil51sfl628pbh2b5z59fibv1r-python3-3.13.12/bin/python
$ which python
/nix/store/zbcd1vkil51sfl628pbh2b5z59fibv1r-python3-3.13.12/bin/python
$ which ansible
/nix/store/q2whihir9x88dgrlz80fh3my44vmsxav-python3-3.13.12-env/bin/ansible
This way I avoid polluting my system with multiple conflicting versions of Ansible, Python, or any other tool. Any other dependency can be added to flake.nix and will be available immediately on next entry.
So far I'm happy with the setup, though build times are longer than I'd like — something I need to investigate further as I get a better understanding of Nix. Expect more posts about Nix in the near future!