Skip to content
Install the CLI

Explanation

Dependency contracts

When you deploy an application in Kubernetes, connecting it to backing services like PostgreSQL, Redis, SMTP mail, or OpenID Connect (OIDC) usually requires configuring each application’s specific environment variables, Secret keys, and ConfigMap files by hand. If you run an operator in local development and a cloud-managed service in production, you end up maintaining two different sets of application configuration. Kix avoids this by defining shared dependency contracts (kix.contracts.*) between service providers and consuming applications.

Decoupling what an application needs from who provides it

Section titled “Decoupling what an application needs from who provides it”

In Kix, an application package declares what backing service it needs by naming a dependency argument on its build function (database, valkey, smtp, oidc, gateway, or otlp). The package never inspects which operator or cloud service satisfies that argument. Instead, both sides agree on a contract in kix.contracts.*:

  • Providers (cnpg-database, stackgres-database, valkey, oidc-client, envoy-gateway, traefik, tempo, otel-collector) validate their root.out attribute set with kix.contracts.<name>.check before exposing it to the cluster.
  • Consumers (grafana, harbor, immich, keycloak, metabase, n8n, plausible, vaultwarden) validate the bound dependency with kix.contracts.<name>.assertions (or a contract helper such as kix.contracts.smtp.settings or kix.contracts.oidc.client) and read its fields to render their own environment variables, ConfigMaps, and network policies.

Because the contract sits between the two packages, changing how a backing service is provisioned requires zero changes to the application’s configuration.

For example, consider a metabase instance that requires a PostgreSQL database dependency:

clusters/test-postgres-swap/consumer.nix
{
instances.app = {
metabase.package = packages.metabase;
metabase-encryption-key = {
package = packages.secret;
aliases = [ "encryptionKey" ];
# Test values only.
config.stringData.key = "0123456789abcdef0123456789abcdef";
};
};
}

View source on GitHub ↗

On a cluster running the StackGres operator, a stackgres-database instance in the app namespace satisfies database:

clusters/test-postgres-swap/stackgres.nix
{
instances.stackgres-system.stackgres-operator.package = packages.stackgres-operator;
instances.db.pg.package = packages.stackgres-cluster;
instances.app.metabase-db = {
package = packages.stackgres-database;
deps.postgres = ref.db.pg;
# Test values only.
config.password = "swap-fixture-password";
};
}

View source on GitHub ↗

On another cluster running CloudNativePG, a cnpg-database instance satisfies the exact same database dependency:

clusters/test-postgres-swap/cnpg.nix
{
instances.cnpg-system.cnpg-operator.package = packages.cnpg-operator;
instances.db.pg.package = packages.cnpg-cluster;
instances.app.metabase-db = {
package = packages.cnpg-database;
deps.postgres = ref.db.pg;
# Test values only.
config.password = "swap-fixture-password";
};
}

View source on GitHub ↗

And on a cloud cluster where PostgreSQL runs on Amazon RDS or Google Cloud SQL, a kix.mkImport instance in the same namespace satisfies database by publishing the required contract fields (host, port, database, and credentials):

clusters/test-doc-how-tos.nix
instances.contracts-rds = {
rds-login = {
package = packages.secret-ref;
config.keys = [
"username"
"password"
];
};
database.package = kix.mkImport {
scope = "namespace";
out = {
host = "app.abc123.eu-west-1.rds.amazonaws.com";
port = "5432";
database = "metabase";
tls = true;
credentials = ref.contracts-rds.rds-login;
};
};
encryption-key = {
package = packages.secret-ref;
aliases = [ "encryptionKey" ];
config.keys = [ "key" ];
};
metabase.package = packages.metabase;
};

View source on GitHub ↗

In all three environments, the metabase instance definition is identical. Furthermore, when metabase requests the citext PostgreSQL extension through deps.database.config.extensions = [ "citext" ], a managed database package provisions the extension automatically, while an imported cluster can declare out.extensions so Kix verifies that the external database supports it.

Why credentials travel on the contract (out.credentials)

Section titled “Why credentials travel on the contract (out.credentials)”

In raw Kubernetes manifests or Helm values, connecting an application to an authenticated database, cache, mail server, or OIDC provider usually means wiring two separate things: the connection address (in values or a ConfigMap) and the login Secret (in secretKeyRef or volume mounts). Those two settings can easily drift out of sync, or point an application at one server with another server’s credentials.

In Kix, every contract that carries a login bundles the login directly on the dependency at out.credentials:

  • For kix.contracts.postgresDatabase and kix.contracts.smtp, out.credentials is a secret with keys username and password.
  • For kix.contracts.redis, out.credentials is a secret with key password.
  • For kix.contracts.oidc, out.credentials is a secret with keys clientId and clientSecret.

When Kix runs the provider in the cluster (such as cnpg-database, stackgres-database, or valkey), the provider creates the Kubernetes Secret and publishes it at out.credentials. When the service lives outside Kix, a kix.mkImport sets credentials = ref.<namespace>.<secret>, pointing at a secret or secret-ref instance.

The consumer package always reads dep.out.credentials.out.keyRef "<key>". Kix verifies at build time that the secret declares every key required by the contract, and orders the deployment so the Secret exists before the import marker and the consuming workload roll out.

Why authenticated imports are namespace-scoped

Section titled “Why authenticated imports are namespace-scoped”

Kubernetes pods can only mount or read Secret objects that live in their own namespace, and Kix’s workload validators reject cross-namespace Secret references at evaluation time. That Kubernetes boundary shapes how contracts handle scope:

  • Unauthenticated imports can be cluster-wide (scope = "cluster"): An internal SMTP relay that requires no login has no out.credentials Secret. A single kix.mkImport in a mail namespace can keep the default scope = "cluster" and satisfy smtp for applications across every namespace.
  • Authenticated imports live in the consumer’s namespace (scope = "namespace"): Because an authenticated database, Redis server, SMTP server, or OIDC client carries out.credentials, the Secret must live in the consuming application’s namespace. Placing the kix.mkImport beside that Secret with scope = "namespace" ensures that another namespace cannot accidentally bind to it and try to read a Secret across namespace boundaries.
  • Multi-realm OIDC providers split the provider from the per-namespace client: A single keycloak instance in an auth namespace can serve the whole cluster (kix.contracts.oidcProvider), while each consuming application needs its own realm and its own clientId/clientSecret Secret in its own namespace. The oidc-client package bridges that boundary: it sits in the application’s namespace with meta.scope = "namespace", pairs deps.oidcProvider = ref.auth.keycloak with config.realm and the local oidcClient secret, and publishes kix.contracts.oidc to the application:
clusters/test-doc-how-tos.nix
grafana-oidc = {
package = packages.secret-ref;
aliases = [ "oidcClient" ];
config.keys = [
"clientId"
"clientSecret"
];
};
oidc = {
package = packages.oidc-client;
deps.oidcProvider = ref.auth.keycloak;
config.realm = "main";
};
grafana = {
package = packages.grafana;
config = {
ingress.host = "grafana.example.org";
oidc.autoLogin = true;
};
};

View source on GitHub ↗

Guarding against secret leaks, mailer pitfalls, and duplicate configuration

Section titled “Guarding against secret leaks, mailer pitfalls, and duplicate configuration”

Contracts also let packages enforce security and operational invariants that would otherwise be left to manual review:

  1. Preventing duplicate or conflicting configuration: Once a contract dependency such as smtp or oidc is bound, the consuming package owns the corresponding application settings. Packages such as grafana, immich, plausible, and vaultwarden refuse manual SMTP or OIDC environment variables and config keys when the dependency is wired, so settings cannot be defined in two places at once.
  2. Keeping passwords out of ConfigMaps: Some applications (such as Immich) accept an SMTP password inside their JSON configuration file. If a cluster author sets notifications.smtp.transport.password in config.settings, that password would be written into an unencrypted Kubernetes ConfigMap. The immich package refuses that setting and requires the smtp dependency instead, injecting the password from out.credentials at container startup so no ConfigMap ever holds the secret.
  3. Enforcing TLS safety across mailers: Different mail clients behave differently when negotiating TLS:
    • Any external SMTP server (external = true, the default) with tls = "none" is rejected by kix.contracts.smtp.settings and kix.contracts.smtp.server so credentials and mail are never sent across the internet in clear text.
    • The mailers in Plausible and Immich will send the login password over a plain connection if a server on a starttls port does not advertise STARTTLS. When out.credentials is set, those packages require tls = "implicit".
    • Grafana’s mailer uses implicit TLS only on port 465, so the grafana package rejects tls = "implicit" on any other port and rejects tls = "starttls" on port 465.
  4. Opening network policy egress automatically: When a cluster enables generated network policies (networkPolicy.enable = true), in-cluster dependencies derive pod-to-pod firewall rules from .out string context. For an external SMTP server (external = true), kix.contracts.smtp.egressPolicy automatically generates a network policy rule allowing the application’s pods to reach "world" on the server’s TCP port.