Twitter   GitGub   Email

Part 2: Quickly packaging services using Nix flakes

In the first part of this series, we learned how to package a small Go application into a Nix flake. In this second part, we will add a service definition and corresponding NixOS module to it, so that we can easily use it on our machines running NixOS!

For reference, the entire Flake is available here.

NixOS modules

To make our service configurable, we will need to add a NixOS module to our flake. These modules allow us to define familiar things such as service.enable. You can read a detailed explanation about them here.

For now we only need to know that it’s just a Nix function returning this set of attributes:

{
  options = {
    # option declarations
  };

  config = {
    # option definitions
  };
}

Adding a module to a flake

To add such a module to our flake, we need to use the nixosModule attribute of our flake output.

{
  outputs =
    { config
    , self
    , nixpkgs
    }:
    let
      #System types to support.
      supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ];

      # Helper function to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;

      # Nixpkgs instantiated for supported system types.
      nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system});

      version = "0.0.3";
      pname = "float";
    in
    {
      nixosModule = forAllSystems (system:
        let
          pkgs = nixpkgsFor.${system};
        in
        { config
        , lib
        , pkgs
        , ...
        }: { 
          # ...
        });
    };
}

Note the use of some helper functions to define the nixosModule for every platform.

Options

Let’s define our first one, a simple option whether this service should be enabled or not:

options.services.float = {
    enable = lib.mkEnableOption "enable the float homepage service";
}

Here we use the helper function mkEnableOption to create the boolean option. You can read more about all the available option functions here.

Likewise, we can define some more simple options that float specifically will need:

package = mkOption {
  type = types.package;
  default = self.packages.${system}.float;
  description = "float package to use";
};

port = mkOption {
  type = types.port;
  default = 8051;
  description = "port to serve float on";
};

title = mkOption {
  type = types.str;
  default = "float";
  description = "title of the homepage";
};

You can find all the types defined here.

Advanced options

Until now, we only used very basic options. However, sometimes we might need to allow users of our module to supply more complex, nested options. A good example of this is the pages we want float to display. It’s a list of links with pretty names for display that we will need to supply as a YAML configuration file. Let’s create a custom “page” option type that represents a single float page.

page = types.submodule {
  options = {
    name = mkOption {
      type = types.str;
      description = "name of the page";
    };
    url = mkOption {
      type = types.str;
      description = "url of the page";
    };
  };
};
pageToYMAL = page: {
  name = page.name;
  url = page.url;
};
configToYAML = input: {
  title = input.title;
  page_data = map pageToYMAL input.pages;
};

Just insert this into a let definition before the body of the module. We can then use the custom type like so:

pages = mkOption {
  type = types.listOf page;
  default = [ ];
  description = "list of sites to be displayed";
};

Generating the NixOS config

Now with our options defined, we can finally define the config part of our module. This will be applied to the NixOS config of the system using this module. Note that we get the state of the NixOS config before our module is applied, passed as the config parameter to our module function. We will use that to access the options we created and the user may have chosen to use!

Our goal here is to create a systemd service that will properly configure and start the float package we created in Part 1!

First, let’s create a small helper variable to point to our service options in the config:

cfg = config.services.float;

Then we can use the mkIf helper function to only generate our config if the enable option is on:

config = lib.mkIf cfg.enable {
  systemd.services.float = {
    # ...
  };
};

Finally, we can populate the body of the systemd service itself!

config = mkIf cfg.enable {
  systemd.services.float = {
    description = "float home page";
    wantedBy = [ "multi-user.target" ];

    serviceConfig = {
      ExecStart = "${cfg.package}/bin/cmd -port ${toString cfg.port} -file ${
      builtins.toFile "config.yml"
      (lib.generators.toYAML {} (configToYAML cfg))
    }";
      ProtectHome = "read-only";
      Restart = "on-failure";
      Type = "exec";
      DynamicUser = true;
    };
  };
};

Note: I’m not that well-versed in systemd services myself, we could probably do more things to harden it.

Pay particular attention to how we referenced the package to locate the float binary and generated a config.yml using our custom functions!

Wrap Up

So now you vaguely know how to create a flake that will build a Go application and use it to actually run it inside of your NixOS system. If I made any mistakes in this series, please let me know on mastodon!