julian@hub:~/blog$ cat secureboot.md

Declarative UEFI Secure Boot with Lanzaboote.

On distributions like Fedora or Ubuntu, Secure Boot works out of the box because you boot a Microsoft-signed shimx64.efi that verifies the distro's pre-compiled kernel.

On NixOS, this falls apart on your first rebuild. Every custom kernel parameter, out-of-tree driver, or initrd change creates a unique derivation in /nix/store. Your motherboard's factory key database has no idea what that binary is, so POST immediately halts with an invalid signature error.

Manually running sbsign on /boot after every rebuild gets tedious quickly, and it falls apart the moment you roll back to an older generation. Lanzaboote solves this by replacing standard systemd-boot with a signing toolchain that signs every generation during nixos-rebuild switch using your own keys enrolled in UEFI NVRAM.

How Lanzaboote Works

Lanzaboote consists of two main pieces:

During nixos-rebuild switch, Lanzaboote runs a command like this:

lzbt-systemd install \
  --system x86_64-linux \
  --systemd /nix/store/...-systemd-261.2 \
  --allow-unsigned false \
  --public-key=/var/lib/sbctl/keys/db/db.pem \
  --private-key=/var/lib/sbctl/keys/db/db.key \
  /boot /nix/var/nix/profiles/system-*-link

Notice the last argument: /nix/var/nix/profiles/system-*-link. Instead of only signing the current generation, lzbt loops over all profile links on disk. When you reboot into an older generation, its EFI binary is already signed and waiting in /boot/EFI/Linux/.

Because private keys cannot live in the world-readable /nix/store, Lanzaboote reads them from /var/lib/sbctl, which stays locked down to root (0700).

Make Sure Your ESP Is at Least 1GB

Many default Linux installers allocate a 100MB or 256MB EFI partition. Because Lanzaboote keeps signed stubs for multiple generations under /boot/EFI/Linux/, you will quickly hit No space left on device during a rebuild.

In my Disko partition layout (hosts/junixos/disk-config.nix), I give the ESP 1GB:

# hosts/junixos/disk-config.nix
ESP = {
  priority = 1;
  name = "ESP";
  start = "1M";
  end = "1G";
  type = "EF00";
  content = {
    type = "filesystem";
    format = "vfat";
    mountpoint = "/boot";
    mountOptions = [ "umask=0077" ];
  };
};

1GB leaves plenty of breathing room for a dozen kernel generations.

NixOS Configuration

Here is the module I use in modules/nixos/secureboot.nix:

{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.custom.system.boot.secureboot;
in
{
  options.custom.system.boot.secureboot = {
    enable = lib.mkEnableOption "UEFI Secure Boot using Lanzaboote";

    pkiBundle = lib.mkOption {
      type = lib.types.path;
      default = "/var/lib/sbctl";
      description = "Directory where Secure Boot keys and database certificates are stored.";
    };
  };

  config = lib.mkIf cfg.enable {
    environment.systemPackages = [ pkgs.sbctl ];

    # Lanzaboote provides its own systemd-boot binary
    boot.loader.systemd-boot.enable = lib.mkForce false;

    boot.lanzaboote = {
      enable = true;
      pkiBundle = cfg.pkiBundle;
    };
  };
}

Lanzaboote uses Nixpkgs's boot.loader.external under the hood, which sets boot.loader.systemd-boot.enable = lib.mkDefault false;. However, stock configuration.nix files (and base host profiles) almost always have boot.loader.systemd-boot.enable = true; explicitly set. Because an explicit true beats mkDefault false, both bootloaders attempt to define system.build.installBootLoader, causing an evaluation collision. Setting lib.mkForce false; ensures Lanzaboote takes precedence without having to rip systemd-boot out of shared base modules.

Add the flake input to flake.nix:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    lanzaboote = {
      url = "github:nix-community/lanzaboote";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, lanzaboote, ... }@inputs: {
    nixosConfigurations."junixos" = nixpkgs.lib.nixosSystem {
      specialArgs = { inherit inputs; };
      modules = [
        inputs.lanzaboote.nixosModules.lanzaboote
        ./hosts/junixos/configuration.nix
      ];
    };
  };
}

And enable it in your host config (hosts/junixos/configuration.nix):

custom.system.boot.secureboot.enable = true;

Key Enrollment and the Discrete GPU Trap

Do not flip Secure Boot to "Enabled" in your BIOS yet. If you turn on enforcement before keys are enrolled and binaries are signed, the machine will refuse to boot.

First, reboot into your UEFI firmware setup, find the Secure Boot section, and clear the existing keys. This puts the board into Setup Mode. Save and boot back into NixOS.

Verify Setup Mode with sbctl:

$ sbctl status
Installed:   ✗ Not installed
Setup Mode:  ✓ Enabled
Secure Boot: ✗ Disabled

Create your keys:

$ sudo sbctl create-keys

Now enroll them. If you have a dedicated graphics card (AMD Radeon or NVIDIA), you must pass -m:

$ sudo sbctl enroll-keys -m

This is the easiest way to soft-brick a desktop setup. Discrete GPUs use an Option ROM signed by Microsoft's 3rd-party UEFI CA so the card can display video during POST. If you enroll keys without -m, your motherboard drops Microsoft keys from db. On the next boot, the firmware refuses to execute the GPU's Option ROM, leaving you with spinning fans, a black screen, a VGA error LED, and no video output at all. Recovering means opening your case and clearing the CMOS jumper.

Passing -m keeps the Microsoft 3rd-party certificate in db while keeping you as the sole owner of the Platform Key (PK).

Switching and Enforcing

Rebuild your system so Lanzaboote can sign everything:

$ sudo nixos-rebuild switch --flake .#junixos

Check that all EFI files are signed before rebooting:

$ sudo sbctl verify
Verifying file database and EFI images in /boot...
✓ /boot/EFI/BOOT/BOOTX64.EFI is signed
✓ /boot/EFI/systemd/systemd-bootx64.efi is signed
✓ /boot/EFI/Linux/nixos-generation--.efi is signed

Once everything shows green, reboot into UEFI setup, enable Secure Boot, and boot back up. You can confirm it is running with:

$ sbctl status
Installed:	✓ sbctl is installed
Owner GUID:	
Setup Mode:	✓ Disabled
Secure Boot:	✓ Enabled
Vendor Keys:	microsoft

Silent Boot and Generation Rollbacks

I pair Lanzaboote with Plymouth silent boot in modules/nixos/plymouth.nix:

# modules/nixos/plymouth.nix
boot = {
  plymouth = {
    enable = true;
    theme = "rings_2";
    themePackages = [ (pkgs.adi1090x-plymouth-themes.override { selected_themes = [ "rings_2" ]; }) ];
  };

  consoleLogLevel = 0;
  initrd.verbose = false;
  kernelParams = [
    "quiet"
    "splash"
    "loglevel=3"
    "rd.systemd.show_status=false"
    "rd.udev.log_level=3"
    "udev.log_priority=3"
  ];

  loader.timeout = 0;
};

With loader.timeout = 0, the signed stub boots straight into the Plymouth splash without any menu flicker.

If a new kernel breaks display output or panics, hold Space or Esc during POST to bring up the systemd-boot menu. Because lzbt signs previous generation links during activation, generation N-1 boots without any signature complaints.

TPM2 PCR 7 Note

If you use a TPM2 chip for automatic disk unlocking, keep your recovery passphrase nearby. PCR 7 measures your Secure Boot state and certificate databases. When you enroll custom keys, PCR 7 changes, and your TPM will refuse to release the key on the first reboot. Once booted with your custom keys, re-bind your TPM to the new PCR state.