NixOS modules, but for Kubernetes.
Declare your app and describe its needs. Let podule manage the rest.
One instance, five needs, five providers chosen by the cluster file. Your app has no dependency on a specific implementation. A missing provider is a build time error.
The same Metabase at data.test.podule.dev, backed by its own Postgres. On the left, Helm charts and YAML by hand. On the right, one podule cluster file.
# postgres-values.yaml (bitnami/postgresql)
auth:
username: metabase
database: metabase
existingSecret: metabase-db # key: password
# metabase-db.yaml (by hand, in both namespaces)
apiVersion: v1
kind: Secret
metadata: { name: metabase-db }
stringData:
password: "s3cr3t" # in Git, or add sealed-secrets
DB_URL: "postgres://metabase:[email protected]:5432/metabase"
# metabase-values.yaml (pmint93/metabase)
database:
type: postgres
existingSecret: metabase-db
existingSecretConnectionURIKey: DB_URL
ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt
external-dns.alpha.kubernetes.io/hostname: data.test.podule.dev
hosts: [data.test.podule.dev]
tls:
- secretName: metabase-tls
hosts: [data.test.podule.dev]
# … 40 more values to tune by hand
$ kubectl apply -n db -f metabase-db.yaml
$ kubectl apply -n metabase -f metabase-db.yaml
$ helm install postgresql bitnami/postgresql -n db -f postgres-values.yaml
$ helm install metabase pmint93/metabase -n metabase -f metabase-values.yaml
The hostname 3 times, the password 2 times, the Secret in 2 namespaces. Move Postgres and nothing tells you Metabase broke.
# cluster.nix
providers = {
dns = apps.external-dns.main;
http = apps.traefik.main;
database = apps.db-operator.main;
volume = apps.local-path.main;
secret = impl.secret.secretspec;
};
# Domain: Traefik reads the zone from the dns provider.
# Every http need gets .test.podule.dev unless it asks otherwise.
apps.external-dns.main.domain = "test.podule.dev";
apps.traefik.main = { };
# Database: db-operator runs one DbInstance per Postgres it is given.
# Each database need becomes a Database CR pointing at that instance.
apps.postgresql.main = { };
apps.db-operator.main.database = apps.postgresql.main;
apps.metabase.main.needs.http.host = "data.test.podule.dev";
The hostname once, the password nowhere: the Database CR mints it in-cluster and Metabase receives a secretRef. Point db-operator at another Postgres and every dependent follows.
Five stages, all at evaluation time except the last. Secret values never enter Nix.
A module says what it implements and what it needs: http, database, volume, secret, runtime.
apps.metabase.main = { } is an instance. Its DNS label and namespace follow from the name.
Every need meets providers.<contract> or a per-need override. Wrong type, missing instance: eval error.
Providers write facts (host, secretRef, class) and enqueue objects: Ingress, PVC, Database CR.
One manifest.yaml. Generated secrets are minted by an in-cluster Job; you are prompted only for external ones.
Proof of concept, 0.1. Metabase, Postgres, Traefik, cert-manager and ExternalDNS run on k3s today. 13 contracts are defined; a contract is a named resource kind an app can ask for. Each card lists the modules that implement it and the options on the roadmap.