Kubenix
7 min read
Kubernetes management with Nix.

Usage

A minimal flake.nix (build with nix build):

nix
{
inputs.kubenix.url = "github:hall/kubenix";

outputs = { self, kubenix, ... }@inputs: let
system = "x86_64-linux";
in {
packages.${system}.default = (kubenix.evalModules.${system} {
module = { kubenix, ... }: {
imports = [ kubenix.modules.k8s ];
kubernetes.resources.pods.example.spec.containers.nginx.image = "nginx";
};
}).config.kubernetes.result;
};
}

JSON manifests are written to ./result.

Module Structure

Real-world use splits configuration across module files. The typical layout:

modules/namespaced.nix # reusable namespace wrapper (see Submodules)
apps/myapp/myapp.nix # application module

Each application module imports submodules + k8s, declares a submodule instance (which becomes its namespace), and uses lib.mkMerge to compose resources:

nix
{ kubenix, lib, ... }: {
imports = with kubenix.modules; [ submodules k8s ];

submodules.imports = [ ../../modules/namespaced.nix ];

submodules.instances.myapp = {
submodule = "namespaced";

args.kubernetes.resources = lib.mkMerge [
{
statefulSets.myapp.spec = { ... };
services.myapp.spec = { ... };
configMaps.myapp-config.data = { ... };
}
];
};
}

Resource Types

All resources live under kubernetes.resources.<kind>.<name>. Named sub-resources like containers and volumes use attrsets with the name as key (kubenix injects it). Everything else (volumeMounts, ports, env) follows the Kubernetes API and uses lists.

StatefulSet

nix
kubernetes.resources.statefulSets.myapp.spec = {
serviceName = "myapp";
replicas = 1;
selector.matchLabels.app = "myapp";
template = {
metadata.labels.app = "myapp";
spec = {
automountServiceAccountToken = false;
containers.myapp = {
image = "example/myapp:latest";
ports = [{ containerPort = 8080; protocol = "TCP"; }];
envFrom = [{ secretRef.name = "myapp-secret"; }];
env = [
{ name = "BASE_URL"; value = "https://example.com"; }
{
name = "API_KEY";
valueFrom.secretKeyRef = { name = "myapp-secret"; key = "api-key"; };
}
];
volumeMounts = [
{ name = "myapp-data"; mountPath = "/data"; }
{ name = "myapp-config"; mountPath = "/etc/myapp/config.yml"; subPath = "config.yml"; }
{ name = "myapp-secrets"; mountPath = "/secrets"; readOnly = true; }
];
};
volumes = {
myapp-data.persistentVolumeClaim.claimName = "myapp-data";
myapp-config.configMap.name = "myapp-config";
myapp-secrets.secret.secretName = "myapp-secrets";
};
};
};
};

Deployment

nix
kubernetes.resources.deployments.myapp.spec = {
replicas = 3;
selector.matchLabels.app = "myapp";
template = {
metadata.labels.app = "myapp";
spec = {
automountServiceAccountToken = false;
containers.myapp = {
image = "example/myapp:1.0.0";
imagePullPolicy = "IfNotPresent";
ports = [{ containerPort = 8080; }];
env = [{ name = "ENV"; value = "production"; }];
};
};
};
};

DaemonSet

nix
kubernetes.resources.daemonSets.agent.spec = {
selector.matchLabels.app = "agent";
template = {
metadata.labels.app = "agent";
spec = {
serviceAccountName = "agent";
containers.agent = {
image = "example/agent:latest";
env = [{
name = "NODE_NAME";
valueFrom.fieldRef.fieldPath = "spec.nodeName";
}];
volumeMounts = [
{ name = "varlog"; mountPath = "/var/log"; }
{ name = "config"; mountPath = "/etc/agent/config.yml"; subPath = "config.yml"; }
];
};
volumes = {
varlog.hostPath.path = "/var/log";
config.configMap.name = "agent-config";
};
};
};
};

Sidecar Containers

Add extra keys to containers in the pod spec:

nix
kubernetes.resources.statefulSets.myapp.spec.template.spec.containers = {
myapp = {
image = "example/myapp:latest";
volumeMounts = [{ name = "data"; mountPath = "/data"; }];
};
sidecar = {
image = "example/sidecar:latest";
args = [ "--config" "/etc/sidecar/config.yml" ];
volumeMounts = [
{ name = "config"; mountPath = "/etc/sidecar/config.yml"; subPath = "sidecar.yml"; }
];
};
};

Service

nix
# ClusterIP (default, internal)
kubernetes.resources.services.myapp.spec = {
type = "ClusterIP";
selector.app = "myapp";
ports = [{ name = "http"; port = 80; targetPort = 8080; protocol = "TCP"; }];
};

# LoadBalancer (external)
kubernetes.resources.services.myapp.spec = {
type = "LoadBalancer";
loadBalancerIP = "192.168.1.100";
selector.app = "myapp";
ports = [
{ name = "http"; port = 80; targetPort = 8080; protocol = "TCP"; }
{ name = "https"; port = 443; targetPort = 8443; protocol = "TCP"; }
];
};

# Headless (for StatefulSets)
kubernetes.resources.services.myapp.spec = {
clusterIP = "None";
selector.app = "myapp";
ports = [{ port = 5432; targetPort = 5432; }];
};

ConfigMap

nix
# Inline values
kubernetes.resources.configMaps.myapp-config.data = {
"config.json" = builtins.toJSON { key = "value"; };
ENV_VAR = "somevalue";
};

# Files from disk
kubernetes.resources.configMaps.myapp-config.data = {
"nginx.conf" = builtins.readFile ./nginx.conf;
"config.yaml" = builtins.readFile ./config.yaml;
};

PersistentVolumeClaim

nix
# Dynamic (storage class allocates the volume)
kubernetes.resources.persistentVolumeClaims.myapp-data.spec = {
accessModes = [ "ReadWriteOnce" ];
storageClassName = "standard";
resources.requests.storage = "10Gi";
};

PersistentVolume + PersistentVolumeClaim

For a static, pre-provisioned volume, create a PV and PVC together and bind them by name:

nix
kubernetes.resources.persistentVolumes.myapp-data.spec = {
capacity.storage = "10Gi";
volumeMode = "Filesystem";
accessModes = [ "ReadWriteOnce" ];
persistentVolumeReclaimPolicy = "Retain";
storageClassName = "";
claimRef.name = "myapp-data";
csi = {
driver = "example.csi.com";
volumeHandle = "myapp-data";
};
};

kubernetes.resources.persistentVolumeClaims.myapp-data.spec = {
accessModes = [ "ReadWriteOnce" ];
storageClassName = "";
volumeName = "myapp-data";
resources.requests.storage = "10Gi";
};

Volume Types (reference)

nix
volumes = {
config.configMap.name = "my-config"; # ConfigMap
secret.secret.secretName = "my-secret"; # Secret
data.persistentVolumeClaim.claimName = "my-pvc"; # PVC
cache.emptyDir.medium = "Memory"; # RAM-backed tmpfs
logs.hostPath = { path = "/var/log"; type = "Directory"; }; # host path
};

RBAC

nix
kubernetes.resources.serviceAccounts.myapp = {};

kubernetes.resources.clusterRoles.myapp-role.rules = [{
apiGroups = [ "" ];
resources = [ "pods" "nodes" "namespaces" ];
verbs = [ "get" "watch" "list" ];
}];

kubernetes.resources.clusterRoleBindings.myapp-binding = {
roleRef = {
apiGroup = "rbac.authorization.k8s.io";
kind = "ClusterRole";
name = "myapp-role";
};
subjects = [{
kind = "ServiceAccount";
name = "myapp";
namespace = "myapp";
}];
};

Helm

Import kubenix.modules.helm and use kubernetes.helm.releases:

nix
{ kubenix, ... }: {
imports = [ kubenix.modules.helm ];

kubernetes.helm.releases.mychart = {
namespace = "mychart"; # optional

chart = kubenix.lib.helm.fetch {
repo = "https://charts.example.com";
chart = "mychart";
version = "1.2.3";
sha256 = "sha256-...";
};

values = {
replicaCount = 2;
image.tag = "latest";
};
};
}

Patching Helm Resources

Define a resource with the same name as one inside a release to merge extra fields at evaluation time — without touching values.yaml:

nix
kubernetes.resources.deployments.nginx = {
metadata.namespace = "default";
spec.template.spec.containers.nginx.env = [{
name = "MY_VAR";
value = "hello";
}];
};

Custom Resource Types

Register CRDs so kubenix knows how to handle them:

nix
kubernetes.customTypes = [
{ attrName = "cephClusters"; group = "ceph.rook.io"; version = "v1"; kind = "CephCluster"; }
{ attrName = "myCustomThings"; group = "example.io"; version = "v1alpha1"; kind = "MyCustomThing"; }
];

After registration, use kubernetes.resources.cephClusters.name = { ... } like any built-in resource.

CLI

The built-in CLI shows a diff, prompts for confirmation, applies, and prunes removed resources. It can also pipe manifests through vals to inject secrets without writing them to the Nix store.

Configure by overriding the default package:

nix
{
kubenix = inputs.kubenix.packages.${pkgs.system}.default.override {
module = import ./cluster;
specialArgs = { flake = self; };
};
}

Apply (diff + confirm):

sh
nix run '.#kubenix'

Build manifests to ./result/manifests.json:

sh
nix build '.#kubenix'

Submodules

Some options like kubernetes.namespace apply globally. Submodules let you scope resources to different namespaces and generate resources from arguments.

Submodule Definition

nix
# modules/namespaced.nix
{ config, kubenix, lib, name, args, ... }: {
imports = with kubenix.modules; [ submodule k8s ];

options.submodule.args = {
kubernetes = lib.mkOption {
description = "Kubernetes config to be applied to a specific namespace.";
type = lib.types.attrs;
default = {};
};
};

config = {
submodule = {
name = "namespaced";
passthru.kubernetes.objects = config.kubernetes.objects;
};

kubernetes = lib.mkMerge [
{ namespace = name; } # instance name becomes the namespace
{ resources.namespaces.${name} = {}; } # create the namespace object
args.kubernetes # caller-supplied resources
];
};
}

Using the Submodule

nix
{ kubenix, lib, ... }: {
imports = with kubenix.modules; [ submodules k8s ];

submodules.imports = [ ./modules/namespaced.nix ];

submodules.instances.app-http = {
submodule = "namespaced";
args.kubernetes.resources.services.nginx.spec = {
ports = [{ name = "http"; port = 80; }];
selector.app = "nginx";
};
};

submodules.instances.app-https = {
submodule = "namespaced";
args.kubernetes.resources.services.nginx.spec = {
ports = [{ name = "https"; port = 443; }];
selector.app = "nginx";
};
};

# Resources here are in the top-level namespace, unaffected by submodules.
kubernetes.namespace = "default";
}