podule podule.dev
apps.metabase.main
needs.http.host = "data.test.podule.dev";
# everything else: defaults
  • http
  • database
  • volume
  • secret
  • runtime

NixOS modules, but for Kubernetes.

Composition
over templating.

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.

Less boilerplate, more power

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.

Helm charts and YAML 4 files

# 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.

podule 1 file

# 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.

How it works

Five stages, all at evaluation time except the last. Secret values never enter Nix.

Declare

A module says what it implements and what it needs: http, database, volume, secret, runtime.

Instantiate

apps.metabase.main = { } is an instance. Its DNS label and namespace follow from the name.

Bind

Every need meets providers.<contract> or a per-need override. Wrong type, missing instance: eval error.

Fulfill

Providers write facts (host, secretRef, class) and enqueue objects: Ingress, PVC, Database CR.

Render, apply

One manifest.yaml. Generated secrets are minted by an in-cluster Job; you are prompted only for external ones.

Current status

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.