I wanted to write a bash script. That's it. A bash script that would automate a few Podman commands to save me a few seconds. For some reason—perhaps my coworkers at Antithesis have done a great job at indoctrinating me—my first thought was, "Why not do this in Nix?" This was going to be interesting, considering I don't know how to use Nix beyond the list of 5 or so commands I store in a Google doc to get by on a workday. But I figured it would be a great baby step. After all, how hard could it be?
First attempt: writeShellBin
First, I wanted to prove to myself that the idea of using Nix to write a bash script wasn't completely ridiculous. Nix documentation is awful, so I quickly asked my good friend Claude. A few minutes later, I proved that the idea wasn't ridiculous. I even saw a few benefits around dependency management, version control, isolation, and portability. All that good stuff.
Claude was telling me to use writeShellBin
. The example was showing me how to build the script as a package. However, that wasn’t quite what I was aiming for. I don’t necessarily need nor want to build and run a standalone binary that’s independent of my repository. Instead, I’m looking for a different kind of a solution — a script or command that I can run directly as part of this specific repository, without requiring a separate, independent compilation step. This example wasn’t quite right. Time to find another example.
Second attempt: writeShellApplication
I found another example that, instead of creating a package, created a development environment and injected the script into my PATH. That looked like more of what I wanted, especially since I already was using nix-shell
to get my Go dependency. With this, I could kill two birds with one stone. One thing that confused me about the example was that it used writeShellApplication
. I quickly asked Perplexity (better for direct questions) what the difference between writeShellApplication
and writeShellBin
was. It seems writeShellApplication
is just more modern with functionality like injecting runtimeInputs
(which would soon get me in trouble).
Unconvinced of which one to choose, I found a friend’s OSS Nix examples to see what he uses. He uses writeShellApplication
, so I do too. With that decision out of the way, I tried to do something like this in my default.nix:
{ pkgs ? import <nixpkgs> {} }:
let
myPodmanScript = pkgs.writeShellApplication {
name = "podman-publish";
runtimeInputs = [ pkgs.podman ];
text = ''
podman build
podman push
'';
};
in
pkgs.mkShell {
buildInputs = [
myPodmanScript
pkgs.go_1_22
];
shellHook = ''
echo "Entering development environment with custom podman script"
'';
}
For those new to Nix, this is basically a large declarative function with the following format:
{ arg1 ? defaultValue1, arg2 ? defaultValue2, ... }: body
But, when I tried to run it…
nix-shell # command to enter shell defined in default.nix
…an unexpected complication occurred.
"obtaining default signature policy: open /etc/containers/policy.json: no such file or directory"
The error occurred because the Nix shell's Podman couldn't find the expected policy file. This confused me. Because running Podman outside of this shell was working fine. Why was this file inaccessible in my Nix shell?
This error led me to better understand how nested Nix shells behaved. For context, my work laptop runs NixOS with Podman already configured. By adding Podman to this Nix shell, I was inheriting packages from the “outer” shell while simultaneously overriding or shadowing packages with the same names as those being defined in the “inner” shell. In other words, including pkgs.podman
in my nested Nix shell overrode my already existing, nicely configured Podman.
So…
# runtimeInputs = [ pkgs.podman ];
This does make this particular Nix file dependent on something outside of this file and therefore less portable. But, oh well, for now.
Third attempt: ${} is special
Good to go. Or so I hoped. Ran into syntax errors. The solution (after 1 hour of googling)? Using ''
(double single quotes) for multi-line strings so that the Nix parser does not mistake ${}
in bash as a Nix expression. I wasn't too happy about that, to say the least. If you want me to write bash in Nix, just let me write bash.
The core of the issue stems from the fact that Nix uses ${}
for its own interpolation, which in turn conflicts with bash’s variable interpolation. The fix, allows to write bash scripts without Nix trying to interpret bash variables as Nix expressions.
Works:
IMAGE="''${IMAGES[$i]}"
Doesn’t Work:
IMAGE="${IMAGES[$i]}"
Fourth attempt: GCP Credentials
Lastly, I needed the credentials to our GCP Artifact Registry to make this bash script useful. This was a bit tricky. My work laptop is preconfigured to have a command I can run that would instantiate a Nix shell with all the proper credentials. But I liked the Nix shell I created and didn't want to lose it.
I started experimenting with an approach combining import
and buildInputs ++
to integrate the credential configuration into my custom shell. However, this led to a bunch of errors. I don’t why. My guess is that the credential shell wasn’t designed to be imported this way.
Nothing was working, and I was starting to overcomplicate the solution. Then I remembered what I had learned earlier about Nix shells. They nest! I just ran the preconfigured credential command inside my customer Nix shell. To my relief, it didn't override any of my packages and just layered on top of my custom Nix shell like a container image layer.
custom_nix_shell # enter custom_nix_shell
customer_credentials_shell # run inside custom_nix_shell as nested shell
I'm starting to appreciate Nix's composability, and that's making me a bit uneasy.
But, finally, two hours later, I wrote a bash script the hard way…using Nix.