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 theirroot.outattribute set withkix.contracts.<name>.checkbefore exposing it to the cluster. - Consumers (
grafana,harbor,immich,keycloak,metabase,n8n,plausible,vaultwarden) validate the bound dependency withkix.contracts.<name>.assertions(or a contract helper such askix.contracts.smtp.settingsorkix.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:
8{9 instances.app = {10 metabase.package = packages.metabase;11 metabase-encryption-key = {12 package = packages.secret;13 aliases = [ "encryptionKey" ];14 # Test values only.15 config.stringData.key = "0123456789abcdef0123456789abcdef";16 };17 };18}On a cluster running the StackGres operator, a stackgres-database instance in the app namespace satisfies database:
13 {14 instances.stackgres-system.stackgres-operator.package = packages.stackgres-operator;15 instances.db.pg.package = packages.stackgres-cluster;16 instances.app.metabase-db = {17 package = packages.stackgres-database;18 deps.postgres = ref.db.pg;19 # Test values only.20 config.password = "swap-fixture-password";21 };22 }On another cluster running CloudNativePG, a cnpg-database instance satisfies the exact same database dependency:
13 {14 instances.cnpg-system.cnpg-operator.package = packages.cnpg-operator;15 instances.db.pg.package = packages.cnpg-cluster;16 instances.app.metabase-db = {17 package = packages.cnpg-database;18 deps.postgres = ref.db.pg;19 # Test values only.20 config.password = "swap-fixture-password";21 };22 }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):
217 instances.contracts-rds = {218 rds-login = {219 package = packages.secret-ref;220 config.keys = [221 "username"222 "password"223 ];224 };225
226 database.package = kix.mkImport {227 scope = "namespace";228 out = {229 host = "app.abc123.eu-west-1.rds.amazonaws.com";230 port = "5432";231 database = "metabase";232 tls = true;233 credentials = ref.contracts-rds.rds-login;234 };235 };236
237 encryption-key = {238 package = packages.secret-ref;239 aliases = [ "encryptionKey" ];240 config.keys = [ "key" ];241 };242
243 metabase.package = packages.metabase;244 };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.postgresDatabaseandkix.contracts.smtp,out.credentialsis a secret with keysusernameandpassword. - For
kix.contracts.redis,out.credentialsis a secret with keypassword. - For
kix.contracts.oidc,out.credentialsis a secret with keysclientIdandclientSecret.
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 noout.credentialsSecret. A singlekix.mkImportin amailnamespace can keep the defaultscope = "cluster"and satisfysmtpfor 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 carriesout.credentials, the Secret must live in the consuming application’s namespace. Placing thekix.mkImportbeside that Secret withscope = "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
keycloakinstance in anauthnamespace can serve the whole cluster (kix.contracts.oidcProvider), while each consuming application needs its own realm and its ownclientId/clientSecretSecret in its own namespace. Theoidc-clientpackage bridges that boundary: it sits in the application’s namespace withmeta.scope = "namespace", pairsdeps.oidcProvider = ref.auth.keycloakwithconfig.realmand the localoidcClientsecret, and publisheskix.contracts.oidcto the application:
389 grafana-oidc = {390 package = packages.secret-ref;391 aliases = [ "oidcClient" ];392 config.keys = [393 "clientId"394 "clientSecret"395 ];396 };397
398 oidc = {399 package = packages.oidc-client;400 deps.oidcProvider = ref.auth.keycloak;401 config.realm = "main";402 };403
404 grafana = {405 package = packages.grafana;406 config = {407 ingress.host = "grafana.example.org";408 oidc.autoLogin = true;409 };410 };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:
- Preventing duplicate or conflicting configuration: Once a contract dependency such as
smtporoidcis bound, the consuming package owns the corresponding application settings. Packages such asgrafana,immich,plausible, andvaultwardenrefuse manual SMTP or OIDC environment variables and config keys when the dependency is wired, so settings cannot be defined in two places at once. - 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.passwordinconfig.settings, that password would be written into an unencrypted KubernetesConfigMap. Theimmichpackage refuses that setting and requires thesmtpdependency instead, injecting the password fromout.credentialsat container startup so noConfigMapever holds the secret. - Enforcing TLS safety across mailers: Different mail clients behave differently when negotiating TLS:
- Any external SMTP server (
external = true, the default) withtls = "none"is rejected bykix.contracts.smtp.settingsandkix.contracts.smtp.serverso 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
starttlsport does not advertiseSTARTTLS. Whenout.credentialsis set, those packages requiretls = "implicit". - Grafana’s mailer uses implicit TLS only on port 465, so the
grafanapackage rejectstls = "implicit"on any other port and rejectstls = "starttls"on port 465.
- Any external SMTP server (
- 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.outstring context. For an external SMTP server (external = true),kix.contracts.smtp.egressPolicyautomatically generates a network policy rule allowing the application’s pods to reach"world"on the server’s TCP port.