Many recent distributed programs like etcd or Kubernetes require TLS certificates to communicate securely. Creating these by hand is tedious, so let’s see how to automate it with a nix flake.

The code here isn’t meant to be used as a library. It’s essentially a demonstration of how to write scripts with nix flakes. This is particularly useful if your configuration is already stored in nix, because you can then trivially import it in a flake. As an example, we generate the 20 certificates necessary to fully secure a three node Kubernetes cluster backed by a three peer etcd cluster.

The full code for this post is here.

If you don't have the nix flake command, see the wiki on how to install it.

Generating certificates with cfssl

We use Cloudflare’s cfssl to generate the actual certificates. The tool takes JSON configuration files as input, and outputs X.509 certificates and keys in the PEM format. We could have used OpenSSL instead, but I find cfssl to have less arcane options.

A full cfssl invocation looks like this:

$ cfssl gencert \
    -ca=demo-ca.pem \
    -ca-key=demo-ca-key.pem \
    -config=demo-ca-conf.json \
    -profile=auth-only \
    -hostname=example.com,127.0.0.1,localhost \
    csr.json | cfssljson -bare my-certificate

Overall cfssl is good, but it’s annoying to use manually because the full configuration of a certificate is split across three places: the CA configuration (demo-ca-conf.json), the CSR of the certificate (csr.json), and the command-line options (-profile and -hostname).

The CA configuration sets the expiry date of the certificates, and specifies what they are authorized to do. Below, we set the expiry to 10 years after generation, and we define two profiles, one only good for authentication, and a second that is additionally allowed to sign its own certificates.

{
  "signing": {
    "profiles": {
      "auth-only": {
        "expiry": "87600h",
        "usages": [
          "signing", "key encipherment", "server auth", "client auth"
        ]
      },
      "auth-and-cert-sign": {
        "expiry": "87600h",
        "usages": [
          "cert sign",
          "signing", "key encipherment", "server auth", "client auth"
        ]
      }
    }
  }
}
demo-ca-conf.json

The CSR specifies the CN of the certificate, and any additional names it may have. The CN is frequently a domain like example.com, a wildcard like *.example.com, or just an arbitrary string identifying the certificate. In Kubernetes, these fields must have certain values for the cluster to recognize its components correctly.

{
  "CN": "Demo CA",
  "key": {"algo": "rsa", "size": 4096},
  "names": [{"O": "Demo Org"}]
}
CSR for CA root

Finally, the command line options specify which profile from the CA configuration to use, and which hostnames the certificate is valid for:

  -profile=auth-only \
  -hostname=example.com,127.0.0.1,localhost \

The main reason to do this certificate generation with a script is to avoid repeating almost identical commands. For instance, we usually want a separate certificate for each host a service is running on, but these differ only in their CN and -hostname. Another less obvious reason is that a script is very useful during initial setup. Speaking from personal experience, the documentation for what goes in the CN, names, and -hostname options is usually lacking, so I frequently find myself trying different values until I find the combination that works.

The basic nix flake

Our nix flake looks like this:

{
  inputs = {
    flake-utils.url = "github:numtide/flake-utils";
    nixpkgs.url = github:NixOS/nixpkgs;
  };
  outputs = { self, flake-utils, nixpkgs }:
    flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = nixpkgs.legacyPackages.${system};
          machines = import ./machines.nix pkgs;
        in
        {
          apps.genCerts = {
            type = "app";
            program = import ./gen-certs.nix pkgs machines;
          };
          apps.showCert = {
            type = "app";
            program = toString (pkgs.writers.writeBash "show-cert" ''
              if [[ $# != 1 ]]; then
                 echo "ERROR: Specify certificate argument"
                 exit 1
              fi
              CERT="$1"
              ${pkgs.openssl}/bin/openssl x509 -text -noout -in "$CERT"
            '');
          };
        });
}
flake.nix

Our two inputs are nixpkgs and flake-utils. The former contains the derivations for cfssl and useful library functions, while the latter contains utilities specifically for writing flakes.

The outputs are wrapped by a call to flake-utils.lib.eachDefaultSystem. This is a bit of boilerplate present in many flakes, and extends our code to work on multiple architectures like x86_64-linux and aarch64-linux. Our outputs are two “apps”. We’ll look at genCerts in the next section. Before that, let’s talk about what nix can and can’t do. Nix is a mostly pure language, so it doesn’t have any facility for running external commands. In other words, we can’t just call system(...). What nix does have, however, is lots of support for generating text like, for example, the contents of a script. When we write a script in a nix flake, we actually generate a bash (or whatever) script, then put it into a set like the following:

apps.my-cmd = {
  type = "app";
  program = path/to/executable;
};
...

Then, we can check the generated script with nix eval, and run the script with nix run.

$ nix eval ".#apps.x86_64-linux.my-cmd"
{ program = "/nix/store/..."; type = "app"; }
$ nix run ".#my-cmd" arg1 arg2 ...
...

For nix eval, we need to specify the full path to the attribute in the flake. The x86_64-linux comes from flake-utils. For nix run, we can just use the app name because the higher level command knows the structure of flakes.

Looking at apps.showCert above, we see that it’s a regular bash script that takes an argument from $1, does some error checking, and then calls openssl. The interesting bits are:

  • We call ${pkgs.openssl}/bin/openssl, so this is the openssl from nixpkgs. OpenSSL does not need to be installed on the host system.

  • The script is wrapped in a call to pkgs.writers.writeBash. This is a utility function that puts the given script into the nix store. The result of this is a set, so we turn it into a path with toString.

Generating certificates with nix

Onto actually generating certificates. First, we put the list of machines and their properties into machines.nix. Separating the machine definitions like this lets us reuse this list in other places like a NixOps or Colmena deployment.

pkgs:
with builtins; with pkgs.lib;
{
  fsn-qws-kube1 = {
    privateIpAddress = "10.10.0.10";
    kubernetes.enable = true;
  };

  fsn-qws-kube2 = {
    privateIpAddress = "10.10.0.11";
    kubernetes.enable = true;
  };

  fsn-qws-kube3 = {
    privateIpAddress = "10.10.0.12";
    etcd.enable = true;
    kubernetes.enable = true;
  };

  fsn-qws-etcd1 = {
    privateIpAddress = "10.10.0.13";
    etcd.enable = true;
  };

  fsn-qws-etcd2 = {
    privateIpAddress = "10.10.0.14";
    etcd.enable = true;
  };
}
machines.nix

The file defines five machines, two with Kubernetes, two with etcd, and one with both. The only property we need for each machine is its IP address on the internal network. Although we don’t use them here, we take pkgs as an argument, and import all the functions from builtins, and pkgs.lib since they’ll almost certainly be needed in the future.

With the machine list in hand, we pass it to gen-certs.nix which is responsible for creating the big certificate generation script. This file looks like this (full file here):

pkgs: machines:
with builtins; with pkgs.lib;
let
  cfssl = "${pkgs.cfssl}/bin/cfssl";
  cfssljson = "${pkgs.cfssl}/bin/cfssljson";
gen-certs.nix (begin)

We start with the usual imports, then alias cfssl and cfssljson since we’ll be using them later.

  profiles = [
    { name = "auth-only"; }
    { name = "auth-and-cert-sign"; extra = [ "cert sign" ]; }
  ];
  caName = "demo-ca";
  caConf = pkgs.writeText "${caName}-conf.json" (toJSON
    {
      signing = {
        profiles = listToAttrs (map
          ({ name, extra ? [ ] }:
            nameValuePair
              name
              {
                usages = extra ++ [ "signing" "key encipherment" "server auth" "client auth" ];
                expiry = "87600h";
              }
          )
          profiles);
      };
    }
  );
gen-certs.nix (continued)

Next, we generate the CA configuration. We create a nix set that looks like the demo-ca-conf.json example from above, convert it to JSON with toJSON, and commit it to the nix store with pkgs.writeText. At the end, caConf is a path to the configuration which we’ll use in our script later.

  mkCSR = { CN, names }: pkgs.writeText "csr.json" (toJSON {
    inherit CN;
    key = { algo = "rsa"; size = 4096; };
    names = [ names ];
  });
gen-certs.nix (continued)

Next, we write a function to generate CSRs. Again, it’s a nix set that gets converted to a JSON, and is then committed to the nix store. As a reminder, inherit CN is a just shorthand for writing CN = CN. One nice thing about generating certificates with a script is that it’s clear what parts of the certificates vary, and which are constant. Here, it’s clear that algo and size are the same for all our certificates, and that only CN and names differ.

  caCSR = mkCSR {CN = "Demo CA"; names = { O = "Demo Org"; };};
gen-certs.nix (continued)

Next, we use mkCSR to generate the root CA certificate. This is the special self-signed certificate that will sign all the others.

  certificates = [
    {
      name = "kubernetes-ca";
      profile = "auth-and-cert-sign";
      CN = "Kubernetes CA Signing Key";
      names = { };
    }
    {
      name = "kubernetes";
      profile = "auth-only";
      CN = "kubernetes";
      names = { O = "Kubernetes"; };
      hostnames = [
        "kube.eu1"
        "10.33.0.1" # first IP in `--service-cluster-ip-range`
        "127.0.0.1"
        "kubernetes"
        "kubernetes.default"
        "kubernetes.default.svc"
        "kubernetes.default.svc.cluster"
        "kubernetes.svc.cluster.local"
      ] ++ concatLists
        (mapAttrsToList
          (hostname: machine: [ hostname machine.privateIpAddress ])
          (filterAttrs
            (_: machine: machine ? kubernetes)
            machines));
    }
  ]
  # Kubelet certificates, one per host
  ++ mapAttrsToList
    (hostname: machine: {
      name = "kubelet-${hostname}";
      profile = "auth-only";
      CN = "system:node:${hostname}";
      names = { O = "system:nodes"; };
      hostnames = [ "kube.eu1" hostname machine.privateIpAddress ];
    })
    (filterAttrs (_: machine: machine ? kubernetes) machines)
  ... many more ...
  ];
gen-certs.nix (continued)

Next, we list all the certificates we want to generate. We don’t call mkCSR yet, so these are just sets of all the parameters needed for each certificate. Doing it like keeps all the information for each certificate in one place. Some certificates are more complicated that others:

  • kubernetes-ca is a certificate with static fields,

  • kubernetes is a certificate with a list of hostnames that depend on the machines list,

  • kubelet-* are one certificate for each machine with a Kubernetes node,

  • more examples in the full file.

in
toString (pkgs.writers.writeBash "gen-certs" ''
  if [[ $# != 1 ]]; then
     echo "ERROR: Specify directory argument"
     exit 1
  fi
  DIR="$1"
  mkdir -p "$DIR/ssl"
  cd "$DIR/ssl"
  if [[ ! -f ${caName}.pem ]]; then
     echo "### Generating CA self-signed certificate"
     ${cfssl} gencert -initca ${caCSR} | ${cfssljson} -bare ${caName}
  fi
  ${concatStringsSep "\n" (map ({profile, name, CN, names ? {}, hostnames ? []}:
    let csr = mkCSR { inherit CN names; }; in
    ''
      if [[ ! -f ${name}.pem ]]; then
          echo "### Generating certificate ${name}"
          ${cfssl} gencert \
            -ca=${caName}.pem \
            -ca-key=${caName}-key.pem \
            -config=${caConf} \
            -profile=${profile} \
            -hostname=${concatStringsSep "," hostnames} \
            ${csr} | ${cfssljson} -bare ${name}
      fi
    '') certificates)}
  echo "### Done"
'')
gen-certs.nix (end)

Finally, we get to actually creating the certificate generation script. Like with the showCert example above, it’s a bash script committed to the nix store. The script takes one argument, ensures the target directory exists, initializes the CA with the special root certificate, then creates a long list of cfssl invocations, one for each element of the certificates list. Each invocation is wrapped in an if that tests that the certificate doesn’t already exist. This lets us run the script multiple times (perhaps adding CSRs incrementally) without replacing existing certificates.

We run it and see that it works:

$ nix run .#genCerts secrets/
### Generating CA self-signed certificate
2022/02/05 12:34:19 [INFO] generating a new CA key and certificate from CSR
2022/02/05 12:34:19 [INFO] generate received request
2022/02/05 12:34:19 [INFO] received CSR
2022/02/05 12:34:19 [INFO] generating key: rsa-4096
2022/02/05 12:34:22 [INFO] encoded CSR
2022/02/05 12:34:22 [INFO] signed certificate with serial number 255216261573763656155674331232636980454956464680
### Generating certificate kubernetes-ca
2022/02/05 12:34:22 [INFO] generate received request
2022/02/05 12:34:22 [INFO] received CSR
2022/02/05 12:34:22 [INFO] generating key: rsa-4096
2022/02/05 12:34:25 [INFO] encoded CSR
2022/02/05 12:34:25 [INFO] signed certificate with serial number 362355941867092112443675438359297285001383949982
2022/02/05 12:34:25 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kubernetes
2022/02/05 12:34:25 [INFO] generate received request
2022/02/05 12:34:25 [INFO] received CSR
2022/02/05 12:34:25 [INFO] generating key: rsa-4096
2022/02/05 12:34:26 [INFO] encoded CSR
2022/02/05 12:34:26 [INFO] signed certificate with serial number 599230592728265123345854521433985825609759901806
### Generating certificate kube-proxy-client
2022/02/05 12:34:26 [INFO] generate received request
2022/02/05 12:34:26 [INFO] received CSR
2022/02/05 12:34:26 [INFO] generating key: rsa-4096
2022/02/05 12:34:30 [INFO] encoded CSR
2022/02/05 12:34:30 [INFO] signed certificate with serial number 662877141352135744358780028198429966846572386004
2022/02/05 12:34:30 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kubelet-client
2022/02/05 12:34:30 [INFO] generate received request
2022/02/05 12:34:30 [INFO] received CSR
2022/02/05 12:34:30 [INFO] generating key: rsa-4096
2022/02/05 12:34:33 [INFO] encoded CSR
2022/02/05 12:34:33 [INFO] signed certificate with serial number 543994420377806563073732385780495657832913425378
2022/02/05 12:34:33 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate flannel
2022/02/05 12:34:33 [INFO] generate received request
2022/02/05 12:34:33 [INFO] received CSR
2022/02/05 12:34:33 [INFO] generating key: rsa-4096
2022/02/05 12:34:36 [INFO] encoded CSR
2022/02/05 12:34:36 [INFO] signed certificate with serial number 591949624719950483238567817650079972958996279455
2022/02/05 12:34:36 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kubernetes-service-account
2022/02/05 12:34:36 [INFO] generate received request
2022/02/05 12:34:36 [INFO] received CSR
2022/02/05 12:34:36 [INFO] generating key: rsa-4096
2022/02/05 12:34:39 [INFO] encoded CSR
2022/02/05 12:34:39 [INFO] signed certificate with serial number 222318375133334448800473743194963609483540879528
2022/02/05 12:34:39 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kube-scheduler
2022/02/05 12:34:39 [INFO] generate received request
2022/02/05 12:34:39 [INFO] received CSR
2022/02/05 12:34:39 [INFO] generating key: rsa-4096
2022/02/05 12:34:42 [INFO] encoded CSR
2022/02/05 12:34:42 [INFO] signed certificate with serial number 454703761339516727874348407545699299157613526908
2022/02/05 12:34:42 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kube-proxy
2022/02/05 12:34:42 [INFO] generate received request
2022/02/05 12:34:42 [INFO] received CSR
2022/02/05 12:34:42 [INFO] generating key: rsa-4096
2022/02/05 12:34:44 [INFO] encoded CSR
2022/02/05 12:34:44 [INFO] signed certificate with serial number 525975915717291197168318170227799275956559344275
2022/02/05 12:34:44 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kubernetes-admin
2022/02/05 12:34:44 [INFO] generate received request
2022/02/05 12:34:44 [INFO] received CSR
2022/02/05 12:34:44 [INFO] generating key: rsa-4096
2022/02/05 12:34:47 [INFO] encoded CSR
2022/02/05 12:34:47 [INFO] signed certificate with serial number 671001094570723203993386888906694004326192330404
2022/02/05 12:34:47 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kube-controller-manager
2022/02/05 12:34:47 [INFO] generate received request
2022/02/05 12:34:47 [INFO] received CSR
2022/02/05 12:34:47 [INFO] generating key: rsa-4096
2022/02/05 12:34:49 [INFO] encoded CSR
2022/02/05 12:34:49 [INFO] signed certificate with serial number 33985344004714776433420033052933434229695645858
2022/02/05 12:34:49 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate kubelet-fsn-qws-kube1
2022/02/05 12:34:49 [INFO] generate received request
2022/02/05 12:34:49 [INFO] received CSR
2022/02/05 12:34:49 [INFO] generating key: rsa-4096
2022/02/05 12:34:53 [INFO] encoded CSR
2022/02/05 12:34:53 [INFO] signed certificate with serial number 66019326403708897913039633955585402235676302626
### Generating certificate kubelet-fsn-qws-kube2
2022/02/05 12:34:53 [INFO] generate received request
2022/02/05 12:34:53 [INFO] received CSR
2022/02/05 12:34:53 [INFO] generating key: rsa-4096
2022/02/05 12:34:53 [INFO] encoded CSR
2022/02/05 12:34:53 [INFO] signed certificate with serial number 730589996255290701936511573808754940964377990185
### Generating certificate kubelet-fsn-qws-kube3
2022/02/05 12:34:53 [INFO] generate received request
2022/02/05 12:34:53 [INFO] received CSR
2022/02/05 12:34:53 [INFO] generating key: rsa-4096
2022/02/05 12:34:54 [INFO] encoded CSR
2022/02/05 12:34:54 [INFO] signed certificate with serial number 183031766535612297633905704133568409536308643654
### Generating certificate etcd-fsn-qws-etcd1
2022/02/05 12:34:54 [INFO] generate received request
2022/02/05 12:34:54 [INFO] received CSR
2022/02/05 12:34:54 [INFO] generating key: rsa-4096
2022/02/05 12:34:55 [INFO] encoded CSR
2022/02/05 12:34:55 [INFO] signed certificate with serial number 137432362200738010184937598850932966654582428076
### Generating certificate etcd-fsn-qws-etcd2
2022/02/05 12:34:55 [INFO] generate received request
2022/02/05 12:34:55 [INFO] received CSR
2022/02/05 12:34:55 [INFO] generating key: rsa-4096
2022/02/05 12:34:56 [INFO] encoded CSR
2022/02/05 12:34:56 [INFO] signed certificate with serial number 259153816427411027659880709910647596132126504357
### Generating certificate etcd-fsn-qws-kube3
2022/02/05 12:34:57 [INFO] generate received request
2022/02/05 12:34:57 [INFO] received CSR
2022/02/05 12:34:57 [INFO] generating key: rsa-4096
2022/02/05 12:34:58 [INFO] encoded CSR
2022/02/05 12:34:58 [INFO] signed certificate with serial number 231598153329229557585338548289864401247280503990
### Generating certificate etcd-client-fsn-qws-kube1
2022/02/05 12:34:58 [INFO] generate received request
2022/02/05 12:34:58 [INFO] received CSR
2022/02/05 12:34:58 [INFO] generating key: rsa-4096
2022/02/05 12:35:02 [INFO] encoded CSR
2022/02/05 12:35:02 [INFO] signed certificate with serial number 634272850751969049116139743899829631749058129626
2022/02/05 12:35:02 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate etcd-client-fsn-qws-kube2
2022/02/05 12:35:02 [INFO] generate received request
2022/02/05 12:35:02 [INFO] received CSR
2022/02/05 12:35:02 [INFO] generating key: rsa-4096
2022/02/05 12:35:03 [INFO] encoded CSR
2022/02/05 12:35:03 [INFO] signed certificate with serial number 600539793806686385928484212358084072319111286832
2022/02/05 12:35:03 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Generating certificate etcd-client-fsn-qws-kube3
2022/02/05 12:35:03 [INFO] generate received request
2022/02/05 12:35:03 [INFO] received CSR
2022/02/05 12:35:03 [INFO] generating key: rsa-4096
2022/02/05 12:35:05 [INFO] encoded CSR
2022/02/05 12:35:05 [INFO] signed certificate with serial number 407256696456261161227454478628793567276785015870
2022/02/05 12:35:05 [WARNING] This certificate lacks a "hosts" field. This makes it unsuitable for
websites. For more information see the Baseline Requirements for the Issuance and Management
of Publicly-Trusted Certificates, v.1.1.6, from the CA/Browser Forum (https://cabforum.org);
specifically, section 10.2.3 ("Information Requirements").
### Done
nix run .#genCerts secrets/

We can also inspect the generated script, and see all the intermediate CSRs and configs:

$ nix eval .#apps.x86_64-linux.genCerts
{ program = "/nix/store/lz03j9dxy2b2jy3917840hwsnw556bz6-gen-certs"; type = "app"; }

$ head -n 12 /nix/store/lz03j9dxy2b2jy3917840hwsnw556bz6-gen-certs
#! /nix/store/07j81a5xy8j1srvf57dl7lxsaihnmfac-bash-5.1-p12/bin/bash
if [[ $# != 1 ]]; then
   echo "ERROR: Specify directory argument"
   exit 1
fi
DIR="$1"
mkdir -p "$DIR/ssl"
cd "$DIR/ssl"
if [[ ! -f demo-ca.pem ]]; then
   echo "### Generating CA self-signed certificate"
   /nix/store/0d5ii4z5ln81dfghf9ijka0yil7pbg6k-cfssl-1.6.1/bin/cfssl gencert \
     -initca /nix/store/2ln029j7hqm0la53w2ck7sf01dninhsi-csr.json \
     | /nix/store/0d5ii4z5ln81dfghf9ijka0yil7pbg6k-cfssl-1.6.1/bin/cfssljson \
         -bare demo-ca
fi

$ cat /nix/store/2ln029j7hqm0la53w2ck7sf01dninhsi-csr.json
{"CN":"Demo CA","key":{"algo":"rsa","size":4096},"names":[{"O":"Demo Org"}]}
Finding the intermediate work

Conclusion

That’s what it takes to generate a bunch of certificates with a nix flake. At first glance, it might seem like we could do this more concisely with a shell script, but that would get unwieldy fast as we add more kinds of secrets to generate like Wireguard keys or Ceph keyrings. The flake lockfile also ensures that anybody running the script is using the same versions of all the software, so no surprises there.

Like most nix things, there’s a steep learning curve, and some setup work to do, but it pays off in the long run.

Box: Using a local nixpkgs directory

In the code above, we imported nixpkgs by adding it to the flake’s input:

inputs = {
  nixpkgs.url = github:NixOS/nixpkgs;
};

The issue with this setup is that it’s much harder to hack on nixpkgs itself. If we want to test a change, we have to commit nixpkgs, push it, then run nix flake update. Ideally, we’d just make a change locally, and have that automatically take effect on the next command. We can actually do this by just importing nixpkgs directly from a local path. It has a default.nix after all, so there’s almost nothing stopping us from doing it.

outputs = { self, flake-utils }:
  flake-utils.lib.eachDefaultSystem
    (system:
      let
        nixpkgs = import "${builtins.getEnv ''PWD''}/nix-channel/nixpkgs" { };
        pkgs = nixpkgs.pkgs;
      in
      {
        apps.genCerts = {

If we try to run it as before, we get an error. Since we’re referencing code outside of flake’s version tracking, we have to pass --impure.

$ nix run ".#genCerts" secrets/
error: access to absolute path '/nix-channel/nixpkgs' is forbidden in pure eval mode (use '--impure' to override)

$ nix run --impure ".#genCerts" secrets/
...
Done

Empirically, this seems to work fine. My suspicion is that this setup is actually correct, provided we have nixpkgs as a git module of the repo with the flake in it.