Developing on Nix: From Beginner to Freedom

1/2/2026 · nix

I spend most of my time at a terminal.

That’s been true for a long time. I first installed Linux in the early 2000s, and over the years I’ve used a wide range of distributions and approaches, some focused on customization, some on minimalism, some on control, and some just because I liked watching compiler text trill with my morning coffee. Have you even used linux if you haven’t daily driven Gentoo?

Over time, a pattern became clear. The more development tooling I allowed to accumulate directly on the host system, the more that system became part of the problem space instead of the platform underneath it. It simply carried responsibilities that didn’t actually belong at that layer. I’d find myself in positions that I would liken to daily driving a classic car, a system would tickle all my sensibilities so long as I’d be willing to devote some effort into wrenching every once in a while. Somewhere in the wild I’ve seen someone say “Linux is free if you don’t value your time,” and for a while, that was true.

However, there are now many immutable and semi-immutable Linux distributions that address this tension well. What drew me to Nix is that it approaches the same problem from a programmer’s perspective. The system and its environments are described declaratively, using a language, rather than being shaped implicitly over time through locked packages. No configuration drift.

And theres actual code, did I mention theres code?!?!
After all, we are programmers!

Nix is what finally ended my techno-hipster phase. I stopped trying to make the base system expressive and focused on making it stable. Now my core machine is intentionally boring, with almost no language runtimes installed globally, just a big blank canvas. All variability lives in explicit, disposable environments that only exist ephemerally while I’m working in a project directory.

I am not trying to recruit you into the religion of NixOS, however great it may be. Telling someone what Linux distribution they should use is like telling them what they should name their children, it’s not your call. However, Nix offers some compelling cross-platform tools to reduce friction and make context switching a bit quieter, and it is worth taking a peek.

Reducing Dissonance

Most day-to-day friction in development doesn’t come from things being outright broken.

It comes from small mismatches:

On Unix-like systems, a lot of this is hidden by convention. You learn to trust that binaries and libraries will be where you expect them to be, and most of the time that trust holds. When it doesn’t, you’re left uncovering assumptions you didn’t realize you were making while you deep-dive manpages.

Declarative environments help by making those assumptions explicit. Instead of accumulating state over time, you describe what should exist for a project, and let the tooling construct that environment fresh each time.

You can inspect it. You can reproduce it. And you can leave it behind cleanly.

Developer Shells: A Primer

What a Developer Shell Is

A developer shell is a scoped environment:

In Nix, this is typically expressed as a devShell. Conceptually, it’s just a description of what should exist while you’re working here.

You enter it deliberately. You leave it cleanly.

Nothing leaks into the rest of the machine.

A Minimal Example (Intentionally a Toy)

Here’s a deliberately small developer shell flake for Go 1.23:

{
  description = "Developer shell for Go 1.23";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
  };

  outputs = { self, nixpkgs }: {
    devShells.x86_64-linux.default =
      let
        pkgs = nixpkgs.legacyPackages.x86_64-linux;
      in
        pkgs.mkShell {
          packages = [
            pkgs.go_1_23
            pkgs.compile-daemon
          ];
        };
  };
}

flake.nix

You enter it with the shell command:

nix develop

When you exit the shell, the environment disappears. No cleanup, no lingering installs.

Now you might be saying “This looks cumbersome to write a small functional module every time I want to program anything.” and you would be right! We will address that soon enough.

Direnv

Once the shell itself makes sense, remembering to enter it becomes the next obvious source of friction.

direnv removes that step by tying environment activation to directory entry.

With a minimal .envrc:

use flake

The behavior becomes automatic:

After approving it once, you stop thinking about it. The shell is just there when you need it.

This is where context switching starts to feel lightweight instead of disruptive. But this still doesn’t solve the problem of needing to write code just to code! Let’s take this further…

Maintaining a Repository of Developer Shells

After doing this for a while, another pattern shows up.

You don’t write one dev shell. You write the same few shells repeatedly:

Copying flakes between repositories works, but it’s brittle. Small differences creep in. It becomes harder to tell which version is intentional and which is accidental.

At some point, it makes more sense to treat developer shells as maintained artifacts, not project glue.

The Repository Model

The repository is structured in two layers, each with a single responsibility.

Layer 1: Template Wrapper

This layer exists only to support nix flake init.

For Go 1.23:

go_1_23_template/
└── flake.nix
{
  outputs = { self }: {
    templates.default = {
      description = "Developer shell flake for Go 1.23";
      path = ./go_1_23;
      welcomeText = ''
        Initialized Go 1.23 developer shell
      '';
    };
  };
}

This file does not define an environment.

Its job is simply to tell Nix to copy the contents of ./go_1_23 into the current directory when the template is selected. Think of it as a transport wrapper.

Layer 2: The Actual Developer Shell

The real environment lives here:

go_1_23/
├── flake.nix
└── .envrc

The shell definition:

{
  description = "Developer shell flake for Go 1.23 (supported for two releases)";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
  };

  outputs = { self, nixpkgs }: {
    devShells.x86_64-linux.default =
      let
        pkgs = nixpkgs.legacyPackages.x86_64-linux;
      in
        pkgs.mkShell {
          packages = [
            pkgs.go_1_23
            pkgs.compile-daemon
          ];
        };
  };
}

And .envrc ensures it activates automatically:

use flake

Each shell is intentionally small and specific. One runtime. One version. No project assumptions.

That’s what makes it reusable.

Distributing and Pinning Templates

At the root of the repository, a single flake.nix exposes these templates so they can be initialized remotely.

In practice, I pin the repository to a specific commit hash when initializing a project. That way, starting a project today and revisiting it later doesn’t quietly change the environment underneath it.

It’s the same idea as pinning a container image digest, applied to developer environments.

Automating Initialization

To make this cheap to use in practice, I wrap the initialization in a small local helper that lives in PATH.

Conceptually, it does four things:

  1. Selects a template
  2. Pins the repository
  3. Initializes the files into the current directory
  4. Enables the environment immediately

Usage looks like this:

mkdir my-service
cd my-service
goshell go_1_23

Under the hood, this expands to:

nix flake init -t github:cbdeane/dev_flakes/<commit>?dir=go/go_1_23_template
direnv allow

Since these templates live in a repo I maintain and are pinned to a known commit, the initializer also approves the environment automatically. I would not do this for third-party code and you shouldn’t either.

From that point on direnv acts as we would expect:

Nothing to clean up. Nothing to remember.

A Few Practical Notes (About the Nix Store)

One thing that often surprises people, even long-time Linux users, is how Nix lays out the filesystem.

On most systems, binaries and libraries live in shared, predictable locations like /usr/bin or /usr/lib. Dynamic linking relies on that predictability. When a program starts, the dynamic linker looks for shared libraries using a mix of hardcoded paths, system defaults, and environment variables like LD_LIBRARY_PATH.

Most of the time, you don’t think about this. The filesystem just works.

Nix intentionally breaks that assumption.

In Nix, packages live in the Nix store under paths like:

/nix/store/<hash>-glibc-2.38/lib

Each package gets its own isolated prefix. Nothing is implicitly shared. Tools only find each other if the environment explicitly wires them together.

This is part of what makes Nix environments reproducible, and also why some third-party tooling stumbles. Many Node and Python tools assume global layouts and familiar linker behavior. When those assumptions don’t hold, things can fail in ways that aren’t immediately obvious.

Usually the fix is straightforward, explicit environment variables, wrappers, or known patterns, but it does mean slowing down enough to see what a tool is actually assuming about the system.

Whether that tradeoff is worth it depends on the project. For me, the clarity and stability have been worth the occasional friction, but it’s not an all-upside situation and it doesn’t need to be.

Where This Fits (and Where It Doesn’t)

Developer shells don’t replace containers.

Containers are still useful for:

Developer shells shine earlier:

Used together, they tend to complement each other rather than compete.

Closing

This setup didn’t change how I write code. It changed how much attention the environment demands while I’m writing it.

Once the system is stable and predictable, it fades into the background. You enter a directory, work, and leave. If a machine dies or a disk gets replaced, the path back to a working environment is clear.

It’s not a philosophy. It’s just a way of tuning a development environment that already occupies a lot of my day to match my preferred terminal workflow.

If you want to try it, take one piece of this and see if it helps. You don’t have to adopt the whole model for it to be useful.