knext

Databases (bring your own Postgres)

knext is engine-agnostic — you bring your own Postgres and bind its DSN from a Kubernetes Secret. knext does not provision or manage databases.

knext is engine-agnostic about databases. It does not provision, host, or manage a database for you — you bring your own Postgres. knext's only database surface is a thin, secure binding: point the app at an existing Postgres by supplying a DATABASE_URL from a Kubernetes Secret, and the operator injects it into your pods.

knext does not run your database. There is no managed mode, no per-app provisioning, and no "branch-per-app" database. Any Postgres your app can reach over the network works — a CloudNativePG cluster you run in the same cluster, or a managed/serverless provider.

Bind an existing Postgres

The whole contract is one Kubernetes Secret carrying a DSN, referenced from the NextApp:

nextapp.yaml (database binding)
spec:
  database:
    secretRef: { name: acme-db }          # key defaults to DATABASE_URL
    roSecretRef: { name: acme-db }         # optional; key defaults to DATABASE_URL_RO
  • secretRef binds a Secret in the app's own namespace as DATABASE_URL (the writer).
  • roSecretRef optionally binds a read-only DSN as DATABASE_URL_RO. Its key defaults to DATABASE_URL_RO, so a single Secret that carries both keys can be referenced by the same name for both fields.
  • spec.database owns DATABASE_URL / DATABASE_URL_RO: a conflicting spec.secrets.envMap entry for those names is rejected rather than silently overridden.

The NextApp never names or reaches into another app's database — it only binds a Secret that lives in its own namespace. Swapping providers is a Secret change: zero app or CR schema change.

You can also declare the binding in your kn-next.config.ts — knext emits the same spec.database into the app's manifest, so the database and the app are configured from one file:

kn-next.config.ts
import type { KnativeNextConfig } from '@getknext/core';

const config: KnativeNextConfig = {
  name: 'acme',
  // …storage, registry, scaling, etc.
  database: {
    secretRef: { name: 'acme-db' },     // key defaults to DATABASE_URL
    roSecretRef: { name: 'acme-db' },    // optional; key defaults to DATABASE_URL_RO
  },
};

export default config;

The config still references a Secret — never an inline DSN. Declaring roSecretRef without secretRef is rejected. The kn-next db bind command writes the equivalent patch directly — see the CLI reference.

Secrets live in Kubernetes Secrets / env only — never in spec.env, config files, images, or URLs. Put the DSN in a Secret and bind it; never inline a connection string.

The equivalent raw binding

spec.database is typed sugar over spec.secrets.envMap — the same injection machinery. If you prefer, bind the DSN by hand:

nextapp.yaml (raw envMap binding)
spec:
  secrets:
    envMap:
      DATABASE_URL:
        secretName: acme-db
        secretKey: pooler-url

Use a connection pooler

Because knext scales your app to zero and back up, replicas come and go and each cold-started pod opens its own database connections. Without a pooler, a scale-up fan-out can open far more connections than Postgres allows and storm the database. Put a connection pooler in front of Postgres and point DATABASE_URL at the pooler, not at the database directly.

Any Postgres-aware pooler works — for example PgBouncer or pgcat, run either as a per-pod sidecar or as a shared in-cluster Service. If you run Postgres with CloudNativePG, point the DSN's host at its pooler Service (not the primary).

db-secret.yaml (DSN points at the pooler)
apiVersion: v1
kind: Secret
metadata:
  name: acme-db
  namespace: my-apps
type: Opaque
stringData:
  # transaction-mode pooler in front of Postgres, NOT the primary directly
  DATABASE_URL: "postgresql://app:PASSWORD@acme-pooler.my-apps.svc:6432/acme"
  DATABASE_URL_RO: "postgresql://app:PASSWORD@acme-reader-pooler.my-apps.svc:6432/acme"

Two settings keep the pooler friendly to a scale-to-zero app that reconnects on every cold start:

  • Pool idle timeout below your pooler's / provider's idle window — so a parked app never holds an idle socket open across scale-to-zero.
  • Connect timeout comfortably above your database's cold-wake time — so the first request after scale-up waits for the connection instead of failing.

The @getknext/db SDK already ships pools tuned to these bounds and drains them cleanly on SIGTERM, so a terminating replica releases its connections instead of severing them mid-request.

Concurrent cold starts share one database wake. When a burst of requests hits a pod whose database is asleep, the pool single-flights the 0→1 wake: the first request triggers the wake and every concurrent first-request awaits that same wake rather than each racing its own. So a 20-request burst pays roughly one cold-wake, not twenty stacked wakes. The single-flight is fail-open — if the first wake attempt times out it does not latch, so the next request re-triggers a fresh wake — and adds nothing to the warm path once the database is up.

A request arriving mid-wake gets bounded latency, not a 5xx. During the ~2.5s cold wake the gateway's socket-accept briefly races the database's readiness, so a connect can see a transient error (ECONNREFUSED/ECONNRESET/"Connection terminated") a moment before the database is truly up. The pool absorbs that: a transient acquire failure during the wake window is retried with capped exponential backoff within a bounded total budget, so the request resolves as slightly higher latency — a 200, not a 5xx. This is the client-side complement to the scale-zero-pg gateway's own bounded wake-retry.

The retry is deterministic and bounded: a permanent error (bad password / SQLSTATE 28xxx, missing role, missing database) fails fast without burning the budget, and if the wake never completes within the budget the last error surfaces — no infinite retry, no unhandled rejection. It composes with the single-flight above: only the wake leader retries; concurrent first-requests await that one shared wake instead of each retry-storming their own.

Tunables (env, sane defaults — no config needed for the common case):

VariableDefaultMeaning
DB_WAKE_RETRY_BUDGET_MS8000Total budget across all retries of one cold acquire (comfortably above the ~2.5s wake).
DB_WAKE_RETRY_BASE_MS100First backoff; doubles each attempt.
DB_WAKE_RETRY_MAX_MS1000Cap on any single backoff sleep.

Warm-tier fallback for latency-sensitive apps. The retry keeps a mid-wake request correct, but it still pays the cold-wake latency. If an app cannot tolerate that occasional first-request delay, keep a warm replica so the database never fully sleeps — bind a warm read endpoint via DATABASE_URL_RO (see Read your data with a typed client) or raise the app/database idle window so a low-traffic app stays warm between requests. That trades some scale-to-zero savings for a consistently warm path.

Read your data with a typed client

For a typed, drizzle-orm-based data layer over the DSNs you just bound — schema, migrations, and explicit getDb() (writer) / getDbRO() (reader) clients — use the @getknext/db Data SDK. It provisions nothing and mutates no cluster resource; it is a plain client over whatever DATABASE_URL you supply.

On this page