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:
spec:
database:
secretRef: { name: acme-db } # key defaults to DATABASE_URL
roSecretRef: { name: acme-db } # optional; key defaults to DATABASE_URL_ROsecretRefbinds a Secret in the app's own namespace asDATABASE_URL(the writer).roSecretRefoptionally binds a read-only DSN asDATABASE_URL_RO. Its key defaults toDATABASE_URL_RO, so a single Secret that carries both keys can be referenced by the same name for both fields.spec.databaseownsDATABASE_URL/DATABASE_URL_RO: a conflictingspec.secrets.envMapentry 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:
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:
spec:
secrets:
envMap:
DATABASE_URL:
secretName: acme-db
secretKey: pooler-urlUse 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).
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):
| Variable | Default | Meaning |
|---|---|---|
DB_WAKE_RETRY_BUDGET_MS | 8000 | Total budget across all retries of one cold acquire (comfortably above the ~2.5s wake). |
DB_WAKE_RETRY_BASE_MS | 100 | First backoff; doubles each attempt. |
DB_WAKE_RETRY_MAX_MS | 1000 | Cap 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.
Related
- Data SDK (
@getknext/db) — typed schema, migrations, and read/write clients. - CLI reference →
db bind/db migrate— the commands behind this page. - Operator & the NextApp CRD → Databases — how the DSN gets into your pods.