Immich is a self-hosted Google Photos alternative. It needs a server, a database with vector search, a cache, and persistent storage for your photos. That's a lot of moving parts. Here's all of them:
This is a real cluster definition. It builds. It produces valid Kubernetes manifests for every resource immich needs. Three explicit instances. Everything else is automatic.
instances.immich = {
immich = {
package = packages.immich;
};
immich-library = {
package = packages.persistent-volume-claim;
aliases = [ "storage" ];
config.size = "200Gi";
};
immich-db = {
package = packages.stackgres-cluster;
aliases = [ "postgres" ];
config.postgres.extensions = [{
name = "vectors";
publisher = "tensorchord";
version = "0.4.0";
}];
};
};
The immich package has a signature — a list of things it needs. The compiler reads it and resolves every dependency.
You declared three instances. The compiler saw that immich also needs valkey, found no match in the namespace, and auto-instantiated one from the available packages. The result is a complete, working deployment:
Every one of these mistakes would silently deploy with Helm or plain YAML. Immich would start, fail to connect, crash-loop, and you'd debug it at midnight. With kix, you never get that far.
# You deploy immich without any PVC instances.immich = { immich = { package = packages.immich; }; immich-db = { package = packages.stackgres-cluster; aliases = ["postgres"]; }; }; error: no 'storage' found in cluster for namespace 'immich' The package 'immich' requires a dependency named 'storage' but no instance with that name or alias exists in namespace 'immich' and no matching package was found in availablePackages for auto-instantiation. # The immich package declares storage as a required argument. # No default. No fallback. You must provide it.
# You provide a PVC, but only 10Gi instances.immich = { immich = { package = packages.immich; }; immich-library = { package = packages.persistent-volume-claim; aliases = ["storage"]; config.size = "10Gi"; }; immich-db = { ... }; }; warning: immich 'immich' library storage is 10Gi — recommended minimum is 50Gi for photo libraries # The build succeeds — 10Gi is technically valid. # But the immich package reads storage.out.size and warns you. # A photo library fills up fast. You'll thank the warning later.
# You provide postgres, but without the vectors extension instances.immich = { immich = { package = packages.immich; }; immich-db = { package = packages.stackgres-cluster; aliases = ["postgres"]; }; # ← no extensions configured immich-library = { ... }; }; error: immich requires the 'vectors' PostgreSQL extension (pgvecto.rs) but the postgres instance 'immich-db' does not have it configured. Add to your postgres config: config.postgres.extensions = [{ name = "vectors"; publisher = "tensorchord"; version = "0.4.0"; }]; # The immich package checks postgres.out.extensionNames at build time. # No vectors extension → immich can't do similarity search → hard error. # With Helm, you'd discover this when immich tries to CREATE INDEX and crashes.
The cluster definition above didn't appear from nowhere. Here's the composition at every level — from one Kubernetes resource all the way up.
At the bottom, everything is a Kubernetes resource. Standard fields.
mkResource registers it in the dependency graph.
# Inside the immich package: one Deployment server = scope.mkResource { kind = "Deployment"; name = "immich-server"; spec.template.spec = { containers = [{ image = "ghcr.io/immich-app/immich-server:v1.131.1"; env = [ { name = "DB_HOSTNAME"; value = postgres.out.fqdn; } { name = "REDIS_HOSTNAME"; value = valkey.out.fqdn; } ]; volumeMounts = [{ name = "library"; mountPath = "/usr/src/app/upload"; }]; }]; volumes = [{ name = "library"; persistentVolumeClaim.claimName = storage.out.name; }]; }; };
postgres.out.fqdn, valkey.out.fqdn, storage.out.name
— the compiler knows this Deployment depends on all three. Change the
database config, the Deployment's hash changes too.
A package bundles related resources: the Deployment, its Service, ConfigMap, ServiceAccount. It declares what it needs as function arguments.
# packages/immich — the package signature IS the dependency contract { scope, lib, kix, postgres, valkey, storage, machineLearning ? null, ... }: # Validation: check that postgres has the vectors extension assert (builtins.elem "vectors" postgres.out.extensionNames) || throw "immich requires the 'vectors' PostgreSQL extension"; # Build resources using the injected dependencies { server = scope.mkResource { /* Deployment using postgres, valkey, storage */ }; service = scope.mkResource { /* Service selecting server.out.selector */ }; configmap = scope.mkResource { /* ConfigMap with connection strings */ }; }
postgres.immich.svc.cluster.local.
It uses postgres.out.fqdn — whatever the injected instance resolves to.
Same package works whether postgres is in the same namespace, another namespace,
or an external managed database.
The cluster definition places package instances in namespaces. The compiler resolves dependencies within each namespace.
# Cluster definition — this is what you actually write instances.immich = { immich = { package = packages.immich; }; immich-library = { package = packages.persistent-volume-claim; aliases = ["storage"]; config.size = "200Gi"; }; immich-db = { package = packages.stackgres-cluster; aliases = ["postgres"]; config.postgres.extensions = [/* vectors */]; }; };
The full cluster. Multiple namespaces. Shared infrastructure. Independent modules merged by the compiler.
kix.buildCluster {
name = "my-homelab";
modules = [
./infrastructure.nix # storage classes, cert-manager, ingress
{
instances.immich = {
immich = { package = packages.immich; };
immich-library = { ... };
immich-db = { ... };
};
# Same cluster, different namespace, different app
instances.paperless = {
paperless = { package = packages.paperless; };
paperless-data = { ... };
paperless-db = { ... };
};
}
];
}
Those 12 lines produced a working deployment. Here's everything the compiler figured out on its own:
Self-hosting immich with Helm means reading a values.yaml with 200+ keys, hoping you set the right postgres host, and discovering at runtime that you forgot the vector extension. With kix:
You declare what matters. The compiler handles what's mechanical. And when you get something wrong, it tells you before anything touches your cluster.