nixidy part 1: Introduction to nixidy
I have managed many GitOps repositories for Kubernetes with ArgoCD and I'm sure I'm not alone in having opened a Helm values override file that was 600 lines of YAML and still not being sure which values actually made it into the rendered manifests. I've run helm template, piped it through grep, given up, committed it anyway and hoped the staging diff would catch anything my eyes missed.
That gap between what you think
you're deploying and what actually lands in the cluster is exactly what nixidy
is meant to close. I wrote it to replace Helm value files, Kustomize overlays, and raw YAML with a single Nix expression per environment. Every field is typed, every build is reproducible, and the output is plain YAML you can git diff
before it ever touches a cluster.
By the end of this part we'll have a working nixidy project that defines an nginx Deployment and Service, generates Argo CD Application
manifests automatically, and deploys to your cluster through GitOps.
What you'll build
We're going to build a nixidy environment called dev
containing one application deployed to your cluster via Argo CD. The project structure will be the skeleton you'd extend to manage an entire production cluster.
Prerequisites
- Nix installed with flakes enabled (download)
- A Kubernetes cluster with Argo CD installed
- A Git repository for your Kubernetes manifests (GitHub, GitLab, etc.)
- Basic familiarity with Kubernetes Deployments, Services, and Namespaces
- Basic familiarity
with Argo CD
Applicationresources
info
nixidy implements the Rendered Manifests Pattern where your CI generates plain YAML, you review it in PRs, and Argo CD deploys it. If you've used Argo CD with raw YAML or Kustomize before, the deployment side is identical. The difference is entirely in how the YAML is produced.
A Nix expression that builds a Kubernetes manifest
The core idea behind nixidy is that every Kubernetes resource is a typed Nix option. A Deployment isn't a blob of YAML, it's a structured attribute set where replicas
is an integer, image
is a string, and a typo in selector
is a build error, not a runtime surprise.
Let's start by creating the project:
mkdir my-cluster && cd my-cluster
git init
Now create flake.nix, this is the entry point that wires nixidy into your Nix flake:
{
description = "My Kubernetes cluster managed with nixidy";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nixidy.url = "github:arnarg/nixidy";
};
outputs = {
nixpkgs,
flake-utils,
nixidy,
...
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
in {
nixidyEnvs = nixidy.lib.mkEnvs {
inherit pkgs;
envs.dev.modules = [ ./env/dev.nix ];
};
});
}
A couple things worth noting:
nixidy.lib.mkEnvstakes a set of named environments and returns Nix derivations that build YAML manifests. The keydevbecomes the attribute you reference with.#dev.- Each environment takes a list of NixOS-style modules which are plain
.nixfiles that set options. This is the same module system that powers NixOS, which means you getimports,lib.mkDefault,lib.mkForce, and all the composition primitives you'd expect.
info
If you've configured a NixOS system before, the shape is identical: a list of modules that set options, merged by the module system. The difference is that the options describe Kubernetes resources instead of system services.
An environment module with one application
Now let's create the environment directory and the dev module:
mkdir -p env
Write env/dev.nix. Make sure to replace the repository URL with your own (this is where nixidy will tell Argo CD to look for rendered manifests):
{
nixidy.target.repository = "https://github.com/YOUR_USERNAME/my-cluster.git";
nixidy.target.branch = "main";
nixidy.target.rootPath = "./manifests/dev";
applications.nginx = {
namespace = "nginx";
createNamespace = true;
resources = {
deployments.nginx.spec = {
replicas = 2;
selector.matchLabels.app = "nginx";
template = {
metadata.labels.app = "nginx";
spec.containers.nginx = {
image = "nginx:1.25.1";
ports.http.containerPort = 80;
};
};
};
services.nginx.spec = {
selector.app = "nginx";
ports.http.port = 80;
};
};
};
}
Let me walk through what this declares:
nixidy.target.*: Where the generated YAML ends up in your Git repo. Argo CDApplicationmanifests will reference this repo, branch, and path.applications.nginx: One logical application. An application gets its own directory in the output and its own Argo CDApplicationmanifest.namespace = "nginx": All resources in this application are deployed to thenginxnamespace.createNamespace = true: Nixidy generates aNamespacemanifest automatically. Without this, you'd need to create the namespace out-of-band.resources.deployments.nginx: A typed Deployment. Thespecattribute follows the Kubernetes Deployment spec, but enforced at Nix evaluation time.resources.services.nginx: A typed Service, same idea.
Why not just write the YAML?
Two reasons.
- Type errors become build errors. Set
replicas = "two"in the module above andnixidy buildfails immediately, not 15 minutes into a deployment rollout. - Composition. When you add a
prod.nixthat imports this same module and setsreplicas = lib.mkForce 10, you're expressing "same app, different scale" in two lines instead of copying an entire YAML file and changing one number. The NixOS module system (imports,lib.mkDefault,lib.mkForce) gives you this for free, and it's the same mechanism that handles multi-environment NixOS configs.
Build the manifests
Run the build:
nix run github:arnarg/nixidy -- build .#dev
info
The first run downloads nixidy and its dependencies into the Nix store. Subsequent runs are instant if nothing changed.
Inspect the output:
tree result
You should see:
result/
├── apps/
│ └── Application-nginx.yaml
└── nginx/
├── Deployment-nginx.yaml
├── Namespace-nginx.yaml
└── Service-nginx.yaml
Look at the generated Deployment:
cat result/nginx/Deployment-nginx.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: nginx
spec:
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx:1.25.1
name: nginx
ports:
- containerPort: 80
name: http
And the Argo CD Application
that nixidy generated for you:
cat result/apps/Application-nginx.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: nginx
namespace: argocd
spec:
destination:
namespace: nginx
server: https://kubernetes.default.svc
project: default
source:
path: ./manifests/dev/nginx
repoURL: https://github.com/YOUR_USERNAME/my-cluster.git
targetRevision: main
Every applications.*
block produces exactly one Argo CD Application
pointing at the directory containing its rendered manifests. This is the rendered manifests pattern: Argo CD syncs plain YAML, not templates, not Helm releases, just static files it can diff against the cluster state.
Commit the rendered manifests
The nixidy switch
command copies the built manifests into your repository at the rootPath
you configured:
nix run github:arnarg/nixidy -- switch .#dev
This creates ./manifests/dev/
with the same directory tree as result/. Commit and push:
git add .
git commit -m "Add nginx application via nixidy"
git push
The rendered YAML is now in your repository. Argo CD can see it.
Deploy to your cluster
Bootstrap with Argo CD
If Argo CD is already running in your cluster, one command creates an "app of apps" (a parent Application
that manages all your nixidy applications):
nix run github:arnarg/nixidy -- bootstrap .#dev | kubectl apply -f -
This outputs an Argo CD Application
manifest that points at manifests/dev/apps/
in your repo. Argo CD reads that directory, discovers Application-nginx.yaml, creates the nginx Application, which then syncs the Deployment, Service, and Namespace into your cluster.
Or: apply directly (for testing)
If you want to skip Argo CD temporarily, a local kind
cluster for instance:
nix run github:arnarg/nixidy -- apply .#dev
This runs kubectl apply --prune
with the correct label selectors, so resources removed from your nixidy config are also removed from the cluster on the next apply (if resources have been removed).
What's next
We now have one application in one environment. Real clusters have a dozen applications across dev, staging, and production and I don't want to copy-paste the same Deployment into three files. In Part 2
we'll refactor the nginx application into a shared module, override replicas
per environment with lib.mkDefault
and lib.mkForce, and integrate a Helm chart without giving up type safety.