Let me say at the outset: I enjoy using GitHub Actions as a continuous integration system. It has a nice UI, I’m especially fond of the matrix variations feature, and it’s easy to get started—after all, my code is already “there” on GitHub. But there is something that I do not love about the platform: Actions as third-party dependencies. In this post, I’ll argue that Actions are a problematic and often superfluous abstraction and that you should consider using Nix to make your pipelines dramatically more reproducible and ergonomically sound.

The problem with Actions

While there are important exceptions, Actions are typically little more than wrappers around a single executable and yet require substantial boilerplate to work properly. Take creyD/prettier_action as an example. This Action installs Prettier in your CI environment and enables you to specify the commands you want to run with it. Here’s an example configuration:

- name: Prettify repo code
  uses: creyD/[email protected]
		prettier_options: --write **/*.{js,jsx,ts,tsx}
    only_changed: true

This is simple enough to set up—just a few lines of YAML—but have a look at the repo that defines this action. You’ll see an action.yml file that defines which with options are available and how the action is run. The actual logic of the action is then defined in a shell script. This is pretty substantial song-and-dance for what could just be a command invocation if Prettier were already installed in the environment. To be clear, there’s nothing wrong with prettier_action per se and I don’t intend to single it out. I chose it solely because it’s representative.

So why all this boilerplate? It’s basically the cost you pay for easy installation. Need a dependency in your pipeline in just a few lines of code? Boom, you got it. No need to fuss with Homebrew or yum or apt or anything else; the creators of the Action have handled that tangled business for you (hopefully). But easy installation harbors some significant drawbacks:

And worst of all, even if you do find an Action that’s Just Right™️, you have two remaining problems:

The Nix alternative

As promised, I’m going to present Nix as a clear alternative to using using third-party Actions in your GitHub CI pipelines. The key Nix feature I want to showcase here is Nix shell environments. In a nutshell, you can use Nix expressions to declare which dependencies you want to make available inside an isolated shell environment for your project. Here’s an example of a shell environment with Go 1.18, Prettier, Cargo, Python 3.8, and OpenSSL installed:

  devShells.default = pkgs.mkShell {
		buildInputs = with pkgs; [

Nix shell environments have the virtue of being highly replicable across platforms, which means that they’re an ideal solution to the problem of reconciling “works on my machine” with your CI environment. You may not always be able to easily install every tool on every system—some things may not be available on macOS, for example—and that’s something to always be on the lookout for.

When you define a shell environment using Nix (with flakes enabled), you can enter the default local environment (as in the example above) by running nix develop or a more specific environment using nix develop <flake>#<env>, for example nix develop .#node-env. In a CI environment, though, it’s usually better to run commands as if the shell environment were applied but without entering the environment (much like running bash -c <command>). You can do that using the --command option. Here’s an example:

nix develop --command npm run build

If this command were run against a Nix shell environment with npm installed, the npm invocation would use the specific Nix-defined version instead of globally installed npm. This is the approach I use in my example project, as you’ll see below.

Using Nix inside your GitHub Actions pipeline