Self-host Immich.
The compiler handles the rest.

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:

The whole thing

This is everything you write

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";
    }];
  };
};
Where's the cache? The storage class? The service account? The immich package declares what it needs. The compiler finds or auto-instantiates the rest. You only write what's specific to your deployment.
Under the hood

What the compiler does with those lines

The immich package has a signature — a list of things it needs. The compiler reads it and resolves every dependency.

The immich package declares: { scope, lib, kix, postgres, valkey, storage, machineLearning ? null, ... }: The compiler resolves each dependency: postgresimmich-db found via alias "postgres" in same namespace storageimmich-library found via alias "storage" in same namespace valkey(auto-instantiated) no valkey in namespace → created from availablePackages

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:

Namespace: immich immich Deployment + Service + ConfigMap ├── depends on immich-db StackGres PostgreSQL cluster (with pgvecto.rs) ├── depends on immich-library 200Gi PersistentVolumeClaim │ └── depends on storage-class (auto-resolved from cluster infrastructure) └── depends on valkey (auto-created) Redis-compatible cache
The compiler catches mistakes

Try to cut corners. The compiler stops you.

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.

Forget the storage
# 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.
Storage is data. It can't be optional. Unlike valkey (which has a sensible default config), a photo library PVC needs you to decide the size and storage class. The package author made this a required dependency on purpose. You can't accidentally deploy immich without somewhere to put the photos.
Give it too little storage
# 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.
Forget the postgres vector extension
# 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 package is the expert. The immich package knows it needs pgvecto.rs. The immich package knows photos need space. These aren't cluster-level policies — they're domain knowledge encoded by the package author. Every deployment of immich gets the same guardrails automatically.
Composition

From a single resource to a full cluster

The cluster definition above didn't appear from nowhere. Here's the composition at every level — from one Kubernetes resource all the way up.

Level 1: A single resource

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;
    }];
  };
};
Every green reference is a tracked dependency. 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.

Level 2: A package (multiple resources that belong together)

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 */ };
}
The package doesn't know where postgres lives. It doesn't hardcode 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.

Level 3: A namespace (instances wired together)

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 */]; };
};

Level 4: A cluster (namespaces + infrastructure)

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   = { ... };
      };
    }
  ];
}
Each level composes into the next. Resources compose into packages. Packages compose into namespaces. Namespaces compose into clusters. At every level, the compiler tracks dependencies and validates contracts. The person writing the cluster definition doesn't need to know what's inside the immich package — just that it needs postgres, storage, and valkey.
The invisible work

Everything you didn't write

Those 12 lines produced a working deployment. Here's everything the compiler figured out on its own:

You wrote: The compiler produced: 3 instance declarations Namespace/immich Deployment/immich-server Service/immich-server ConfigMap/immich ServiceAccount/immich PersistentVolumeClaim/immich-library StatefulSet/immich-db (StackGres cluster) StatefulSet/valkey (auto-instantiated) Service/valkey + RBAC, secrets, operator configs... You configured: The compiler wired: config.size = "200Gi" PVC spec.resources.requests.storage aliases = ["postgres"] Immich's DB_HOSTNAME env var aliases = ["storage"] Immich's volume mount + claim name config.postgres.extensions StackGres extension config + immich validation You skipped: The compiler auto-resolved: valkey Auto-instantiated from availablePackages storage class Resolved from cluster infrastructure via DI service selectors Derived from deployment labels (guaranteed match) connection strings Generated from .out.fqdn (tracked dependency)

Trivial to deploy. Impossible to misconfigure.

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:

Forget storage? Compile error. Storage is a required dependency. Storage too small? Warning. The immich package checks and tells you. No vector extension? Compile error. Immich validates postgres capabilities. Selector mismatch? Impossible. Selectors are derived, not copy-pasted. Wrong database hostname? Impossible. Connection strings come from .out.fqdn. Forgot the cache? Auto-instantiated. Valkey is in the available packages.

You declare what matters. The compiler handles what's mechanical. And when you get something wrong, it tells you before anything touches your cluster.