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";
}