K8s on NixOS - Chapter 0: Preface

Posted on Fri 14 March 2025 in software

Intro

IPv6 only

The whole setup will be IPv6 only. I will not configure any IPv4 because I don’t need legacy IP support. If you have legacy workloads that require IPv4 connectivity, you will have to think about adding a SIIT + NAT64 setup with something like JOOL or building everything dual stack. Both ways complicate the setup in different ways. You decide how you want to solve that issue. Maybe I’ll add a SIIT-DC setup later just for fun. It’s probably the easiest to get rid of your IPv4-only software anyway. If you want to create an IPv4 only setup you should be able to follow this guide and simply replace every mentioned IPv6 thing with your IPv4 equivalent. But honestly, why would you do that? It’s 2025, IPv6 is now over 30 years old. There should be no reason to not support it as first class citizen. Do this first and think about legacy support later when you actually need it.

There will be an exception for the Kubernetes Ingress Controller. It needs to be reachable by public IPv4 addresses so that legacy users can still reach my public services. They are punished enough by their ISPs, let’s not add more to that.

Public addresses

I will use public IPv6 addresses for many components. That means services are available to the public internet. Firewall rules will need to make sure that only our components talk to non-public endpoints. This will be done with nftables firewall rules that block outside traffic to our Pod and Service networks.

I’m still undecided if I should use ULA addresses for the Pod and Service networks or Global Unicast. There are many ways to set this up and none of them are wrong per se. Let’s see how thing evolve when we get to the CNI plugin setup.

Combined control plane and worker nodes

In a production setup you would usually separate kubernetes worker nodes and control plane nodes. This is better for security and it also gives you the ability to easier scale them independent from each other. Because I only have 3 dedicated nodes at the moment and I want to simplify the setup I will combine these roles in this guide. This doesn’t stop you from adding more nodes with dedicated roles in the future. In fact once this setup works I will migrate it from 3 VMs to 4 hardware nodes with one being only a worker node.

A control plane node would only host etcd, apiserver, scheduler, controller manager, addon manager and proxy services. A worker node would only host a container runtime, kubelet and proxy services. Depending on your network plugin it would probably also be installed on both node roles. In NixOS this is handled by the services.kubernetes.roles option. It overwrites the enabled option of kubernetes components. For that reason we will not use that and take fine grained control over our services.

Basic setup

This guide assumes you’re running NixOS or you have Nix installed on your machine. If you don’t want to do that, the Makefile will not be able to deploy the nodes for you. You will have to do that by connecting to the node and run the nixos-rebuild commands locally.

Git

We will put everything we do in a single git repository. So let’s set this up now.

Let’s start with a standard .gitignore file. The most important thing is the direnv part that keeps us from accidentally including some nix outputs. I will use PyCharm and Vim. Both of them will generate some files that you don’t want, so I’ll exclude these too. And finally we will exclude all OS level stuff. Adapt the file to your own needs. The gitignore.io API has templates for a lot of tools.

curl -sL "gitignore.io/api/linux,windows,macos,direnv,pycharm+all,vim" > ./.gitignore

You can put a .envrc file with the content use flake in the Git repos root dir, to load the flakes devshell automatically. You need to install direnv to make that work. On NixOS, you can use a faster implementation of direnv like nix-direnv.

If you don’t want to do that you can activate the devshell of the flake by running nix develop manually.

We will have a pki directory which consists of our certificate autorities and certificates. In there we need another gitignore file, so add a ./pki/.gitignore like this:

# don't include unencrypted certificates
*/*.pem

This will prevent you from accidentally making your precious certificates public.

We will also have a services directory which holds our Nix configuration of the services we will setup. This makes it easy to include them only on the nodes where we need them.

The next direcotry is secrets. It will hold our age encrypted secrets. So no worries this should be plenty secure to put them into git. At least for now. &xF609;

Lastly we will have a deployments directory with our Kubernetes manifests. I will probably come up with a better way of handling those at some point but for now they live there.

Nix flake

Let’s put a simple flake in there too.

{
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-24.11;
    agenix = {
      url = github:ryantm/agenix;
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs = { self, nixpkgs, agenix }:
  let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
  in
  {
    formatter.${system} = pkgs.nixpkgs-fmt;
    devShells.${system}.default = pkgs.mkShell {
      packages = with pkgs; [
        agenix.packages.${system}.default
        age # secrets debugging
        gnumake # automation
      ];
    };
  }
}

Different architectures

If the architectures of your dev machine and the architecture of your nodes don’t match, you can utilize flake-utils like this:

{
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-24.11;
    agenix = {
      url = github:ryantm/agenix;
      inputs.nixpkgs.follows = "nixpkgs";
    };
    flake-utils.url = github:numtide/flake-utils;
  };
  outputs = { self, nixpkgs, agenix, flake-utils }: flake-utils.lib.eachDefaultSystem (system:
  let
    pkgs = import nixpkgs { inherit system; };
  in
  {
    formatter = pkgs.nixpkgs-fmt;
    devShells.default = pkgs.mkShell {
      packages = with pkgs; [
        agenix.packages.default
        age # secrets debugging
        gnumake # automation
      ];
    };
  })
}

This will generate the flakes dev shell in your local architecture and you can use the nodes system further down for the architecture of your nodes like this:

nixosConfigurations = let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
in {
    "node-01".nixpkgs.lib.nixosSystem = { inherit system; };
    "node-02".nixpkgs.lib.nixosSystem = { inherit system; };
    "node-03".nixpkgs.lib.nixosSystem = { inherit system; };
};

You also need to redefine pkgs to use the correct architecture in the modules we will include. Keep this in mind for the next chapter when we setup the nodes.

Makefile

Let’s also add a helping Makefile that doesn’t do much for now:

.DEFAULT_GOAL := help

.PHONY: all
all: update-flake ## Update flake inputs

.PHONY: update-flake
update-flake: ## Update nix flake
    nix flake update

.PHONY: help
help: ## Display this help
    @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

By default, it prints a nice help message.

Outro

This is the final layout so far:

.
├── Makefile
├── README.md
├── deployments/
├── flake.lock
├── flake.nix
├── pki
│   └── Makefile
├── secrets/
│   └── secrets.nix
└── services/

We will add more stuff later on.

Good bye for now!