Entropic Thoughts

Packaging Perl and Shell for NixOS Deployment

Packaging Perl and Shell for NixOS Deployment

As a complete beginner to Nix and NixOS, I recently had some trouble packaging up a Perl script and a shell script with their dependencies for deployment on a NixOS system. Here’s what I learned.

The traditional setup: scripts, system packages, and cronjobs

Let’s imagine we have a Perl script that gets the expiration times of some tls certificates. It does this using the cpan module IPC::Run to call out to openssl. That is probably not the best way to do it, but it happens to be a very illustrative example. A simplified version of the script (that does no formatting or alerting logic, only gets the dates of a single certificate) might look like this.

use v5.16;
use warnings;
use strict;
use IPC::Run qw( run timeout );

sub openssl_get_dates {
    my ($host) = @_;
    my @openssl_client = split " ", "openssl s_client -connect $host:443 -servername $host";
    my @openssl_dates = qw( openssl x509 -noout -dates );
    my $dates;
    my $result = eval {
        run \@openssl_client, '<', \"", '2>', \my $_err1,
          '|', \@openssl_dates, '>', \$dates, '2>', \my $_err2,
          timeout(5);
    };
    return $dates if defined $result;
}

say openssl_get_dates "www.sunet.se";

We will call this Perl script from a shell script, which takes the output and composes it into an email report, and sends it.1 Why not have Perl send the email directly? I’ll wave my hands vaguely and mutter “separation of concerns”. The Perl script is responsible for composing the report, and the shell script that invokes Perl decides what to do with it. In this case, we want to send an email. In another case, we may want to do something else. This is not a completely valid reason, and for the real reason, see the appendix. That shell script might look like the following.

#!/bin/sh

today=''$(date +%Y-%m-%d)
contents=''$(perl main.pl | tr '\n' '%' | sed 's/%/\\n/g')
aws ses send-email \
    --from "<redacted>" \
    --destination "ToAddresses=<redacted>" \
    --message '{
      "Subject": { "Data": "Weekly report '"$today"'" },
      "Body": { "Text": { "Data": "'"$contents"'" }}
    }' \
    >/dev/null

Again, this is crummy code, so don’t focus too much on the details.

We can plop these scripts down anywhere on our file system, and then install enough system packages to satisfy their dependencies before we can run them. On Fedora, this would be something like

$ sudo dnf install openssl perl-IPC-Run awscli2

Then we can create a cron job that runs the shell script weekly, and we’re done!

This is brittle and annoying to reproduce

The drawback of this approach is that it is brittle. The deployment relies on the same system packages being installed. If we upgrade the system, or migrate the script to another machine, we have to remember which the dependencies are and make sure they don’t change too much. If we had used Python instead of Perl, we would have an even worse problem, because then we would probably have to patch up the script itself too, when Python breaks compatibility in a newer version.

With Nix we get out of that issue by specifying declaratively exactly what the scripts need to run, so that once we have the Nix configuration for the scripts, they will run on any system that has Nix installed. Starting them will be one command, which takes care of everything for us.

This speaks to me greatly, because I hate emergency maintenance of side projects. When deploying side projects the traditional way, I try to write down exactly the steps I took, so I can replicate it on another machine later if needed. With Nix, we can write these notes in a format where they are executable and result in a successful deployment.2 How is this different from e.g. containers or configuration management? That’d be a separate article, but the basic idea is that the outcome of the Nix command will always be exactly the same. This is not true of the way most people build containers (because they build them impurely) and use configuration management (because it tends to be additive, rather than a complete description).

Great! So how would we do this in practice?

The incorrect mental model

If we try to draw from some sort of Docker or container intuition, we might imagine we need to get Nix to create a shell where the necessary packages are available. This is a mistake as we’ll see later, but to be clear, Nix can do that too. If we run

$ nix shell nixpkgs#awscli nixpkgs#openssl nixpkgs#perlPackages.IPCRun

we will end up in an environment where everything we need is available. In the shell that is started with that command, we can run our scripts. All they need will be in place. (And this, like most Nix commands, is non-destructive and immutable, meaning when we exit out of that shell, all the changes are reverted, and it’s as if nothing happened.)

We can run something non-interactively by appending a command to run in that shell:

$ nix shell nixpkgs#awscli nixpkgs#openssl nixpkgs#perlPackages.IPCRun \
     --command perl -E 'use IPC::Run; say $IPC::Run::VERSION;'
20231003.0

Here we run Perl to prove that it has access to IPC::Run, but instead of doing that, we could kick off the shell script.

But this is not how we’re supposed to use Nix.

Nix is a build system, producing derivations in the store

We should instead look at Nix as a build system. To package our scripts, we will build them into the Nix store. The things in the Nix store are called derivations, because like with any other build system, they are outputs derived from inputs. In difference to other build systems, the derivations in the Nix store come with all the dependencies they need, and are completely independent from system packages.3 This sounds expensive but since store derivations are content-addressed we achieve useful levels of sharing anyway.

Unfortunately, Nix uses the word derivation to mean three different things:

  • The outputs in the Nix store are called store derivations, or just “derivations” for short.
  • The low-level recipes and inputs used to build the outputs are called derivation files, or just “derivations” for short.4 In Nix speak, building outputs is called realising derivations.
  • The high-level recipes we write in the Nix language are called derivation expressions, or just “derivations” for short.5 In Nix speak, these high-level descriptions are instantiated into derivation files.

I will try to be clear whether I refer to a store derivation, a derivation file, or a derivation expression in this article, but I might slip up.

How a script can be built into a derivation with Nix

When Nix builds a derivation expression, it gets translated into a lower-level derivation file, which is then used to realise (build) its inputs into outputs, which are put into the Nix store. For a better background to this process, I recommend reading Eelco Dolstra’s PhD thesis. It’s very readable and presents the problems and solutions clearly. It also gives a brief example of how Nix would build something like a traditional C project: it would

  1. Copy the sources to a build directory;
  2. Ensure all build dependencies (e.g. system C libraries) are present, by building them into the Nix store if they are not there already, and then setting up environment variables to point to them;
  3. Run the commands that compile the sources (usually some variation of ./configure && make);
  4. Scan through the build outputs to figure out which libraries need to be present also during the run-time of the program, marking these as run-time dependencies; and then
  5. Copy over the resulting binaries to the Nix store.

This6 With some additional details. gives us a program that is built only from known inputs, and will reference only those dependencies. It cannot break when a system library is updated, for example. It is not built differently on a different system.

It is not obvious how to apply this to e.g. a Perl script. There’s no build step, and our dependencies need to be available when the script runs, not when it is built. We can squeeze this problem into the Nix framework, though. During the build, Nix needs to do three things:

  1. Copy the sources to a build directory;
  2. Set up the scripts such that they depend on derivations in the Nix store, rather than system libraries, so those dependencies are available when the script executes; and then
  3. Copy over the resulting scripts to the Nix store.

Nix is flexible enough to do this. We can, for example, use the mkDerivation function, which lets us write the code for step 2 above, and this would be used to produce a derivation expression. Doing that is tedious, so Nix comes with convenience functions for common types of scripts. Among others, Perl!

Making a derivation of a Perl script

The writePerlBin convenience function takes a Perl script and creates a derivation expression that performs step 2 above, resulting in a sensibly-shaped store derivation for the script. To see how it works, we’ll first create a file perl-script.nix with an expression that evaluates to such a derivation.

I will try to explain the main parts of any Nix code in this article, but this article is not a tutorial on the Nix language. It assumes a passing familiarity with the Nix language. Don’t be intimidated: as strange as it looks, the Nix language is small and easy to learn.7 The difficult thing about reading Nix code is figuring out how various parameters are used in functions in nixpkgs. The only way to do that is often to read the source code of the function. I refer again to Eelco Dolstra’s PhD thesis for a brief introduction.

{ pkgs ? import <nixpkgs> {} }:

pkgs.writers.writePerlBin "openssl-dates" {
  libraries = [ pkgs.perlPackages.IPCRun ];
} ''
  use v5.16;
  use warnings;
  # -------------->8----
''

This is a Nix function, which has a pkgs parameter (that defaults to <nixpkgs>), and when evaluated it results in the derivation returned by pkgs.writers.writePerlBin. This function takes three arguments:

  1. The name of the derivation (in this case "openssl-dates").
  2. Some named parameters (in this case we pass only libraries).
  3. A string containing the Perl script to be placed in the Nix store (in this case using Nix’s multi-line string syntax.)

The script we give to this function is exactly the same as before. We could also have kept it in a separate file if we wanted.

When we build this derivation with nix-build perl-script.nix, Nix will report it as successfully built and print out the path of the store derivation. That path contains an executable script. The first line of that script is

$ head -n1 /nix/store/g68las6kxjbp2vmb0nmqqw75psxhgia8-openssl-dates/bin/openssl-dates
#! /nix/store/bhj3j29k1gvwammkdg3sx29m0alzwqpq-perl-5.38.2-env/bin/perl

What in the shebang?! This is the work of writePerlBin. It has ensured this script will run using a specific Perl interpreter, namely one that has access to the IPC::Run dependency we specified.

This script is now likely to work if we run it on our regular systems, but it will work for the wrong reason. The very attentive reader may already realise why. Here’s a hint for the rest of us.

$ /nix/store/bhj3j29k1gvwammkdg3sx29m0alzwqpq-perl-5.38.2-env/bin/perl -E 'say qx( which openssl )'
/bin/openssl

Oops. We forgot to add openssl as a dependency in the derivation so it’s accidentally using the system openssl. A bare NixOS system might not be configured with a global openssl, so this script would crash if deployed to such a system.

The writePerlBin function does not handle system program dependencies, but it does accept a makeWrapperArgs parameter which it passes down to lower-level methods. We can use this to create a wrapper script that sets up the path for any system programs we depend on.

{ pkgs ? import <nixpkgs> {} }:

pkgs.writers.writePerlBin "openssl-dates" {
  libraries = [ pkgs.perlPackages.IPCRun ];
  makeWrapperArgs = [
    "--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.openssl ]}"
  ];
} ''
  use v5.16;
  use warnings;
  # -------------->8----
  say openssl_get_dates "www.sunet.se";
''

When we change the derivation thusly, it will create two files. Note, by the way, that the hash in the store path has changed now that the inputs changed.

$ ls -a /nix/store/kph0wlls4w4g00mkp1g3g67afcvxkhdk-openssl-dates/bin/
openssl-dates  .openssl-dates-wrapped

The binary openssl-dates is a tiny program that sets up the PATH environment variable and then calls .openssl-dates-wrapped which is the same script as before. When this runs, it is now fully isolated: it calls a specific Perl interpreter, which includes a specific version of IPC::Run, and shells out to a specific openssl, all built by Nix. It will run the same on my system as on any other system where it’s built.

Adding the complication of the shell script

We’ll use a similar procedure to create the shell script that goes along with the Perl script, except we’ll first declare a variable and set it to the result of running the function in perl-script.nix. This means the mainScript variable will contain the derivation of the Perl script.

{ pkgs ? import <nixpkgs> {} }:

let
  mainScript = import ./perl-script.nix { pkgs = pkgs; };
in
  pkgs.writeShellApplication {
    name = "report-dates";
    runtimeInputs = [ pkgs.awscli mainScript ];
    text = ''
      today=''$(date +%Y-%m-%d)
      contents=''$(${mainScript}/bin/openssl-dates | tr '\n' '%' | sed 's/%/\\n/g')
      # -------------->8----
    '';
  }

We have also made one change to the script text here: instead of calling perl (which would correspond to the system Perl, unless otherwise specified), we are splicing in the mainScript variable, which evaluates to the Nix store path of that derivation. Thus, this shell script calls the Perl script in the Nix store, which in turn depended on a specific version of Perl, etc.

As opposed to writePerlBin, the writeShellApplication function does have support for adding system programs as run-time dependencies. We use this when adding awscli to runtimeInputs.

The effects of this derivation can be observed by running nix-build shell-script.nix and then looking at the build output created in the Nix store. The first few lines are

#!/nix/store/izpf49b74i15pcr9708s3xdwyqs4jxwl-bash-5.2p32/bin/bash
set -o errexit
set -o nounset
set -o pipefail

export PATH="/nix/store/61s2xncjcds05d5rxc0z9mrz2x2kn8y9-awscli-1.33.13/bin:$PATH"

today=$(date +%Y-%m-%d)
contents=$(/nix/store/kph0wlls4w4g00mkp1g3g67afcvxkhdk-openssl-dates/bin/openssl-dates | tr '\n' '%' | sed 's/%/\\n/g')
# -------------->8----

The writeShellApplication helper has been busy here:

  • It makes the script run with the specific version of bash requested when Nix built the derivation;
  • It adds a few sanity options to the script;
  • It sets the path to include the aws cli; and
  • The variable containing our other derivation (perl-script.nix) gets expanded into the path in the Nix store where that derivation is built.8 Nix understands that if perl-script.nix is not built into the Nix store, or if it has changed since it was last built into the Nix store, it should be built before this derivation, and Nix will take care of that.

As an example of other helpful things Nix derivations can do when they build stuff, the writeShellApplication runs the build output through ShellCheck, meaning we get some shell script linting for free when we use that function instead of e.g. creating the derivation build step manually.

Packaging it all up in a flake

Assuming we don’t want to use the Perl script outside the email reporting script, we can combine the two derivations above into one file. Since Nix is a plain programming language, we copy the Perl script derivation expression and assign it to the variable in the shell script derivation.

But if we do this, we can do one better: package it into a flake. A flake has explicit inputs and outputs. In the inputs, we get the opportunity to choose a specific version of nixpkgs, rather than get whatever happened to be the default as was the case previously. This improves the reliability of the build.

{
  description = "An example of packaging two scripts with system dependencies.";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";

  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;

      mainScript = pkgs.writers.writePerlBin "openssl-dates" {
        # -------------->8----
      } ''
        use v5.16;
        use warnings;
        # -------------->8----
      '';

      shellScript = pkgs.writeShellApplication {
        # -------------->8----
        text = ''
          today=''$(date +%Y-%m-%d)
          contents=''$(${mainScript}/bin/openssl-dates | tr '\n' '%' | sed 's/%/\\n/g')
          # -------------->8----
        '';
      };

    in
    {
      packages.x86_64-linux.default = shellScript;
    };
}

The mainScript and shellScript variables contain the derivations from before, and then we assign the default package output of this flake to be the shell script. If we name this flake.nix we can execute nix run to build and run it. (It is unlikely to be re-built since the derivations that make it up were already built earlier.)

What we have learned

We have now done the main bit we wanted to. When we run nix build with the flake9 Note the difference between nix-build and nix build! The former builds a Nix derivation expression, the latter builds a flake. we will get an executable in the Nix store that is packaged with its own dependencies and runs the same regardless of how the system is changed.

We accomplished this by using convenience functions in Nix that package up scripts in a way that they retain references to their dependencies, so those dependencies are used when the script runs. This happens slightly differently for different kinds of scripts10 For cpan modules it runs the script with a specific Perl interpreter that knows about those modules. For system programs it appends to the PATH environment variable to ensure the right versions are used., but is part of what is considered “building” a script using Nix.

We could have used Nix to set up a shell in which to run our scripts, but Nix is meant to be used as a build system, so the more natural approach is to build the scripts into the Nix store where they work as regular Unix programs that technically don’t require Nix to run anymore, but still retain their connections to the specific dependencies built by Nix.

It was very difficult for me – a beginner to Nix and NixOS – to wrap my head around what it means to “build” a script with Nix. But now once I see it, I think it’s a useful mental model to carry forward that will work for many different kinds of scripts.

Appendix A: Scheduling with a systemd timer in NixOS

The one thing we didn’t get around to in this article was to actually deploy this to a NixOS system to send weekly emails. It’s not particularly interesting, but we’ll go ahead and do that too for completeness.

To deploy this to a NixOS system, we would add the flake as an input to the system configuration of that host:

inputs.dateExample.url = "path:/home/kqr/tmp/date-example";

This makes it a flake input, but we also want to pass this as an argument to the NixOS module that configures the system. To do this, we use the specialArgs named parameter.

specialArgs = { dateExample = dateExample; };

Then, in that system configuration module, we can create the systemd units necessary to run this weekly.11 There’s nothing about Nix or NixOS that prevents using cron like we were in the traditional setup, but the NixOS community leans heavily toward systemd timers, and having tried them, they are convenient to work with. This will refer to the dateExample flake passed using specialArgs. Nix will understand that this flake needs to be built and installed on the NixOS system which is being configured.

systemd.services.date-example-weekly = {
  description = "Run the weekly date example report.";
  serviceConfig = {
    Type = "oneshot";
    User = "kqr";
    ExecStart = "${dateExample.packages.${config.nixpkgs.system}.default}/bin/shell-script";
  };
};

systemd.timers.date-example-weekly = {
  wantedBy = [ "timers.target" ];
  timerConfig = {
    OnCalendar = "Sat *-*-* 08:12:00";
    Persistent = true;
  };
};

and that’s it. Now we get an email every Saturday with the dates from a tls certificate. If we remove the systemd units from the NixOS configuration and rebuild the system, they will be removed from the system. (Useful, since this was just a silly test!)

Appendix B: The story behind the story

In case you are very curious and/or have time to kill, here’s the why behind all of this.

Why a Perl script for certificate expiration

Some time ago, Let’s Encrypt stopped sending expiration reminder emails. We can still pay someone else to send those, but I thought it could be a fun exercise to write a Perl script that acts as a custom, configurable alerting system. This would let me configure tls certificate expiration alerts, as well as other useful types of alerts.12 I imagined a hierarchical architecture similar to Prometheus, where most machines would periodically run a script that dumps a report on the file system somewhere. This script could be configured to fetch that report file from other machines. Finally, one machine selected to be the reporter would, in addition to the above, also email the report to me weekly or if a problem is detected.

I didn’t get that far, but I did write a script that prints out a neat report over the tls certificates I care about, and emails it to me once a week. Most of the time, it’s just a long list of OK but every now and then one of the lines says FAIL and then I need to make sure that certificate is renewed. It works great, actually.

Why Amazon for sending emails

I didn’t want to send emails myself because of deliverability problems, but I also didn’t expect it to be difficult to find a transactional email provider for this, given that my expected volume is 4.35 emails per month. I picked a well-known provider with a free plan whose api seemed easy to use.

Somehow I had missed that this free plan would expire after 60 days! I realised this only after I noticed there had been no email reports for a couple of weeks. After those 60 days, they want to charge you $20 per month. For sending 4.35 emails! Not worth it. Unfortunately, this seems to be the case for most transactional email providers. They expect volumes in the thousands and charge you for it.

Brief web searches led me to aws ses, which charges per-email, at a price point of a few cents per thousand emails. That sounds perfectly cheap for my purposes.13 Although I have heard aws users mistakenly say that before …

Why run this on NixOS

At that point, these scripts still used the traditional deployment method. But when I updated the script to use the aws cli for emails instead of curl, it meant adding another system dependency to keep track of. At the same time, this script had been running on one of my web servers for lack of a better place to put it.

By coincidence, I have recently installed a general shell server that would be a much better fit for this script. The catch? It’s a NixOS machine. But maybe that’s actually a good thing in disguise. I have to add another system dependency, but if I also migrate the script to NixOS, that’s not such a big burden.

So there you have it:

  • why there’s a Perl script in the first place,
  • why it uses aws for sending emails, and
  • why I migrated it to NixOS.

(Aside from the shell server, I have something like three web servers, one file server, and a couple of servers for communication. They’re all virtual machines but I’ve found it easier to manage them when they are separated by purpose; less emergency maintenance when things go bad. Most of them run a traditional Linux distribution, but the two most recently created ones run NixOS because I want to see if it is as good as it sounds.)