Nix Flakes: Exposing and using NixOS Modules

A 17 minute read.

Nix flakes allow you to expose NixOS modules. NixOS modules are templates for system configuration and they are the basis of how you configure NixOS. Today we're going to take our Nix flake from the last article and write a NixOS module for it so that we can deploy it to a container running locally. In the next post we will deploy this to a server.

Mara is hacker
<Mara> If you haven't read the other articles in this series, you probably should. This article builds upon the previous ones.

NixOS modules are building blocks that let you configure NixOS servers. Modules expose customizable options that expand out into system configuration. Individually, each module is fairly standalone and self-contained, but they build up together into your server configuration like a bunch of legos build into a house. Each module describes a subset of your desired system configuration and any options relevant to that configuration.

Mara is hacker
<Mara> You can think about them like Ansible playbooks, but NixOS modules describe the desired end state instead of the steps you need to get to that end state. It's the end result of evaluating all of your options against all of the modules that you use in your configuration.

NixOS modules are functions that take in the current state of the system and then return things to add to the state of the system. Here is a basic NixOS module that enables nginx:


{ config, pkgs, lib, ... }:

{
  config = {
    services.nginx.enable = true;
  };
}

This function takes in the state of the world and returns additions to the state of the world. This will use the nginx module that ships with NixOS to give you a basic nginx setup that has the upstream default configuration in it.

NixOS has a way to run other instances of NixOS with NixOS containers. We can use them to test our NixOS module as we write it.

Mara is hacker
<Mara> This probably won't work on a non-NixOS machine. You will need to install NixOS in order to test this. For an easy way to do this, see nixos-infect, a script you can put into a cloudconfig when spinning up a new server. You can also install NixOS manually in a VM, but for now it may be better to use a cloud server as the path of least resistance. Installing NixOS with a flake will be a part of a future article in this series.

In Nix you can merge two attribute sets using the // operator. This allows you to add two attribute sets into one larger one, such as like this:


nix-repl> { foo = 1; } // { bar = 2; }
{ bar = 2; foo = 1; }

Mara is hacker
<Mara> Important pro tip: the merge operator is NOT recursive. If you try to do something like:

nix-repl> foo = { bar = { baz = "foo"; }; }
nix-repl> (foo // { bar = { spam = "eggs"; }; }).bar

You will get:


{ spam = "eggs"; }

And not:


{ baz = "foo"; spam = "eggs"; }

This is because the // operator prefers things in the right hand side over the left hand side if both conflict. To recursively merge two attribute sets (using all elements from both sides), use lib.recursiveUpdate:


nix-repl> (pkgs.lib.recursiveUpdate foo bar).bar
{ baz = "foo"; spam = "eggs"; }

We will use this to add the container configuration to the flake at the end of the flake.nix file. We need to do this because the upper part of the flake with the forAllSystems call will generate a bunch of system-specific attributes for each system we support. NixOS configurations don't support this level of granularity.

At the end of your flake.nix (just before the final closing }), there should be a line that looks like this:


      });

This is what terminates the outputs declaration from all the way at the top. In order to add the container configuration, you should change this to look like this:


      }) // {
      
      };

Then we can add the container configuration to the flake:


}) // {
  nixosConfigurations.container = nixpkgs.lib.nixosSystem {
    system = "x86_64-linux";
    modules = [
      ({pkgs, ...}: {
        # Only allow this to boot as a container
        boot.isContainer = true;
        networking.hostName = "gohello";

        # Allow nginx through the firewall
        networking.firewall.allowedTCPPorts = [ 80 ];

        services.nginx.enable = true;
      })
    ];
  };
};

This will create a container (with the hostname "gohello") that starts nginx and allows traffic to go to nginx on TCP port 80. You can start up the container with the nixos-container command:


$ sudo nixos-container create gohello --flake .#container
host IP is 10.233.1.1, container IP is 10.233.1.2

Then you can start the container with this command:


$ sudo nixos-container start gohello

And then we can try to connect to nginx to see if it's working:


$ curl http://10.233.1.2
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {}
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
                                                                           
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
                                                                           
<p><em>Thank you for using nginx.</em></p>
</body>
</html>

We have nginx!

Now that we have our container to test with, let's write the configuration for the service. At a basic level we need the following things:

Above the container definition, add this basic NixOS module template:


nixosModule = { config, lib, pkgs, ... }:
  with lib;
  let cfg = config.xeserv.services.gohello;
  in {
    options.xeserv.services.gohello = {
      enable = mkEnableOption "Enables the gohello HTTP service";
    };

    config = mkIf cfg.enable {
    };
  };

This will create a NixOS module that will only be enabled when the configuration setting xeserv.services.gohello.enable is set to true. Everything else we do here will build on this.

Mara is happy
<Mara> You can and probably do want to change the namespace xeserv here, it is a placeholder that is not likely to conflict with anything else.

Create a basic systemd service with this template:


config = mkIf cfg.enable {
  systemd.services."xeserv.gohello" = {
    wantedBy = [ "multi-user.target" ];

    serviceConfig = let pkg = self.packages.${system}.default;
    in {
      Restart = "on-failure";
      ExecStart = "${pkg}/bin/web-server";
      DynamicUser = "yes";
      RuntimeDirectory = "xeserv.gohello";
      RuntimeDirectoryMode = "0755";
      StateDirectory = "xeserv.gohello";
      StateDirectoryMode = "0700";
      CacheDirectory = "xeserv.gohello";
      CacheDirectoryMode = "0750";
    };
  };
};

Mara is hacker
<Mara> NOTE: If you have been following along since before this article was published, you will want to be sure to do the following things to your copy of gohello:
  • Move the definition of defaultPackage into the packages attribute set with the name default
  • Update defaultApp and the other entries to point to self.packages.${system}.default instead of self.defaultPackage.${system}

We have updated previous articles and the template accordingly. Annoyingly it seems that this change is new enough that it isn't totally documented on the NixOS wiki. We are working on fixing this.

This will do the following things:

Then you need to add the nginx configuration. We want this application to have its own virtual host, so we will need to add that as a configuration option under the enable option:


domain = mkOption rec {
  type = types.str;
  default = "gohello.local.cetacean.club";
  example = default;
  description = "The domain name for gohello";
};

Mara is happy
<Mara> Pro tip: anything.local.cetacean.club points to 127.0.0.1. You can use this when testing things.

And then we can add the nginx configuration under the systemd service definition:


services.nginx.virtualHosts.${cfg.domain} = {
  locations."/" = { proxyPass = "http://127.0.0.1:3031"; };
};

Your module should look like this:


nixosModule = { config, lib, pkgs, ... }:
  with lib;
  let cfg = config.xeserv.services.gohello;
  in {
    options.xeserv.services.gohello = {
      enable = mkEnableOption "Enables the gohello HTTP service";

      domain = mkOption rec {
        type = types.str;
        default = "gohello.local.cetacean.club";
        example = default;
        description = "The domain name for gohello";
      };
    };

    config = mkIf cfg.enable {
      systemd.services."xeserv.gohello" = {
        wantedBy = [ "multi-user.target" ];

        serviceConfig = let pkg = self.packages.${pkgs.system}.default;
        in {
          Restart = "on-failure";
          ExecStart = "${pkg}/bin/web-server";
          DynamicUser = "yes";
          RuntimeDirectory = "xeserv.gohello";
          RuntimeDirectoryMode = "0755";
          StateDirectory = "xeserv.gohello";
          StateDirectoryMode = "0700";
          CacheDirectory = "xeserv.gohello";
          CacheDirectoryMode = "0750";
        };
      };

      services.nginx.virtualHosts.${cfg.domain} = {
        locations."/" = { proxyPass = "http://127.0.0.1:3031"; };
      };
    };
  };

Mara is hacker
<Mara> The service name is overly defensive. It's intended to avoid conflicting with any other unit on the system named gohello.service. Feel free to remove this part, it is really just defensive devops by design to avoid name conflicts.

Then you can add it to the container by importing our new module in its configuration and activating the gohello service:


nixosConfigurations.container = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [
    self.nixosModule
    ({ pkgs, ... }: {
      # Only allow this to boot as a container
      boot.isContainer = true;

      # Allow nginx through the firewall
      networking.firewall.allowedTCPPorts = [ 80 ];

      services.nginx.enable = true;

      xeserv.services.gohello.enable = true;
    })
  ];
};

Then you can update the container's configuration with this command:


$ sudo nixos-container update gohello --flake .#container
reloading container...

And finally make a request to the gohello service running in that container:


$ curl http://10.233.1.2 -H "Host: gohello.local.cetacean.club"
hello world :)

Mara is hacker
<Mara> Exercises for the reader:

Try adding a nixos option that correlates to the --bind flag that gohello uses as the TCP address to serve HTTP from. You will want to have the type be types.port. If you are stuck, see here for inspiration.

Also try adding AmbientCapabilities = "CAP_NET_BIND_SERVICE" and CapabilityBoundingSet = "CAP_NET_BIND_SERVICE" to your serviceConfig and bind gohello to port 80 without nginx involved at all.

You can delete this container with sudo nixos-container destroy gohello when you are done with it.

These are the basics on how to use NixOS modules. Everything else you can do with them builds off of these fundamental ideas. Modules are templates that coordinate packages and configuration into your desired system state. Containers can let you test out modules without having to add them to your currently running system. Modules declare options and emit configuration based on those options.

You can also consume NixOS modules from flakes using the input system, however I will go into more details about this at a later date. If you want more examples of NixOS modules, I would suggest checking out my nixos-configs repository. I have nearly everything neatly modularized and configurable. If you see anything in there that is confusing to you, please reach out and ask. I am happy to answer your questions and your feedback will help me write future posts in this series.

I also have my "next generation" flakes-based configuration experiments here if you want to read through those. I have still been porting over things piecemeal, so it is not a complete replica of my existing configuration.

Next time I will cover how to install NixOS to a server and deploy system configurations using deploy-rs. This will allow you to have your workstation build configuration for your servers and push out all the changes from there.


Many thanks to Open Skies for being my fearless editor that helps make these things shine.

In part of this post I use my new Xeact-powered HTML component for some of the conversation fragments, but the sizing was off on my iPhone when I tested it. If you know what I am doing wrong, please get in touch.


This post was written live on Twitch. You can check out the stream recording on Twitch here and on YouTube here.

This article was posted on M04 07 2022. Facts and circumstances may have changed since publication. Please contact me before jumping to conclusions if something seems wrong or unclear.

Series: nix-flakes

Tags: nixos

This post was not WebMentioned yet. You could be the first!

The art for Mara was drawn by Selicre.

The art for Cadey was drawn by ArtZora Studios.