Operator & the NextApp CRD
The Go operator is the single source of truth for cluster state.
knext follows one hard rule: nothing mutates the cluster out-of-band. A single Go operator
watches NextApp resources and reconciles each into a running Knative Service. The CLI only ever
applies the CR — it never touches Knative or infra manifests directly.
knext deploy → build · push · emit CR
NextApp CR → applied to the cluster
operator → reconciles desired state
Knative Service → scaled-to-zero, reachableInstalling the operator
The operator ships as one digest-pinned install.yaml bundle (CRDs + RBAC + manager
Deployment + webhook), regenerated for every release. Install the control plane with one
command, after cert-manager:
kubectl apply --server-side -f https://github.com/getknext-dev/knext/releases/download/operator-latest/install.yamloperator-latest always points at the newest stable release — it moves only when a stable
version is cut, never on every commit to main.
Prefer pinning an exact version for anything but a quick trial. Every operator release —
including release-candidate builds — is published under its own immutable tag (operator-vX.Y.Z,
or operator-vX.Y.Z-rc.N for a release candidate), so you can pin the exact version you tested
against instead of tracking whatever is newest:
OPERATOR_VERSION=1.2.3 # any published operator-vX.Y.Z(-rc.N) release
kubectl apply --server-side -f "https://github.com/getknext-dev/knext/releases/download/operator-v${OPERATOR_VERSION}/install-v${OPERATOR_VERSION}.yaml"Each release carries the same digest-pinned bundle under two names: install-vX.Y.Z.yaml
(self-describing — the version is in the file name) and install.yaml. See
Versioning & compatibility to check
which version you run, upgrade to a specific one, and roll back.
operator-latest— the newest stable release; moves only on a stable cut.operator-vX.Y.Z(and pre-release tags likeoperator-vX.Y.Z-rc.1) — cut once and never change: the exact bundle you pin stays the exact bundle you get.operator-edge— tracks the tip ofmainbetween releases; a rolling, try-it-out channel, not for a production cluster.
See Install → 4 · Install the operator for the cert-manager prerequisite, local-bundle fallback, and signature verification.
Upgrade order is load-bearing: upgrade the operator/CRD first, then the CLI. The CLI can emit fields an older operator's CRD does not know about; upgrading the cluster before the CLI keeps every apply compatible. See Upgrading for the full sequence.
The NextApp resource
- apiVersion:
apps.kn-next.dev/v1alpha1 - kind:
NextApp
One resource describes the whole deployment. The image field is required and must be
digest-pinned.
apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
metadata:
name: acme
spec:
image: registry/acme@sha256:9f1c... # required, digest-pinned
scaling:
minScale: 0
maxScale: 20
containerConcurrency: 20
storage:
provider: gcs
bucket: acme-assets
security:
networkPolicy: {} # default-on internal-only isolation
status:
url: https://acme.apps.example.com
conditions: [] # Ready / Degraded / ReconcilingSpec fields (selected)
| Field | Notes |
|---|---|
image | Required. Container image — must be digest-pinned (@sha256:). |
scaling | minScale, maxScale, containerConcurrency, plus poolMax, warmSchedule, targetBurstCapacity, panicWindowPercentage, panicThresholdPercentage, scaleDownDelay, and imagePrewarm. See Scale to zero. |
resources | cpuRequest, memoryRequest, cpuLimit, memoryLimit. Each must be a positive Kubernetes quantity (250m, 512Mi, 1Gi), at most 64 characters, and — if it uses scientific notation — with an exponent no larger than ±9999. A request may not exceed its own limit. |
storage | provider, bucket, region, endpoint. |
cache | provider, url, keyPrefix configure the data cache (ISR/data). There are no bytecode-cache fields: the boot compile cache is baked into your image by default. See bytecode caching. |
revalidation | queue, kafkaBrokerUrl, provisionKafkaSource — async ISR revalidation (see below). |
env | Plain, non-secret env vars (name → value). Reserved names are rejected at admission (see below). |
secrets | envFrom, envMap (Kubernetes Secret refs) — see Binding secrets. |
database | Bind a bring-your-own Postgres by referencing an existing Secret (secretRef / optional roSecretRef). knext does not provision databases. See Databases. |
build | turbopack (the standalone shape — next build, the default the CLI writes and what absence means), webpack (the same standalone shape with a different bundler), or vinext (the compiled single executable, selectable). Drives how the operator starts the container. See Bun & the compiled executable. |
runtime | bun (default) or node. Standalone shape only — for build: "vinext" the runtime ships inside the binary and this field has no effect on startup. |
imagePullSecrets | Names of Secrets the app's ServiceAccount uses to pull the image. See Private registries. |
healthCheckPath | Path Knative's readiness and liveness probes hit (default /api/health). |
observability | enabled, plus rum and tracing sub-blocks. See Observability. |
preview | enabled, prId, branch — marks the resource as a per-PR preview. See Preview environments. |
security | networkPolicy — pod-level L3/L4 isolation, default-on. |
traffic | revisionName, canaryPercent (rollback / canary). |
buildId | Next.js BUILD_ID, stamped as label apps.kn-next.dev/build-id (skew protection). |
Plain env vars (spec.env)
spec.env sets non-secret environment variables — feature flags and runtime tuning. Values are
stored verbatim in the resource, so anything sensitive belongs in spec.secrets instead.
spec:
env:
FEATURE_FLAG_BETA: "on"- Names must be valid env identifiers (
[A-Za-z_][A-Za-z0-9_]*). - The reserved names
HOSTNAME,PORT,K_SERVICE,K_REVISION, andK_CONFIGURATIONare rejected when you apply the resource — they are managed by the platform, and overriding them would break request routing. - If an
envname collides with a platform-managed variable or asecrets.envMapmapping, theenventry is ignored and a Warning event is recorded on the resource (kubectl describe nextapp <name>) explaining which side won.
Binding secrets: envMap + DATABASE_URL
spec.secrets is the CR's mechanism for secret-backed environment variables: envFrom injects
every key of a Secret; envMap maps a single env var to a specific Secret key. The canonical
use is the one-Secret database binding — the app reads DATABASE_URL from the environment,
and the CR binds it from a Kubernetes Secret:
spec:
secrets:
envMap:
DATABASE_URL:
secretName: acme-db-app
secretKey: pooler-urlThis works with any Postgres the Secret points at — a CloudNativePG cluster (point the URL's host at the pooler Service, not the primary, so scale-to-zero fan-out doesn't storm the database), or a managed/serverless provider with a scale-to-zero gateway. Swapping providers is a Secret change, zero app or CR schema change. knext binds the Secret; it does not create it or provision the database.
An envMap name can also collide with a platform-managed system variable — HOSTNAME,
NODE_ENV, or a conditionally-injected one like STORAGE_PROVIDER when spec.storage is set.
Applying the resource rejects this collision (same no-silent-precedence rule as the
DATABASE_URL case below), so a new or updated CR can never carry one — except the connection-
string names below, which are always allowed. For every other reserved name your envMap
override is surfaced on the EnvMapCollision condition (see the table below for the exact reason
and event type per case); for HOSTNAME the platform's own value always wins instead (request
routing depends on it). Remove the envMap entry to fall back to the platform's default.
Connection-string names are exempt from rejection: REDIS_URL, KAFKA_BROKER_URL,
OTEL_EXPORTER_OTLP_ENDPOINT. These are the values behind spec.cache.url,
spec.revalidation.kafkaBrokerUrl, and the tracing endpoint — all plain, non-secret CR
fields today (there is no secretRef equivalent for them yet, unlike spec.database).
Rejecting an envMap override for one of these would force you to choose between admission
failure and writing the credential into the CR in plaintext, which is exactly what this platform's
secret handling forbids. So your envMap entry always wins for these three names, on every CR —
this is the recommended way to keep a Redis/Kafka/OTel credential out of the CR: bind it via
spec.secrets.envMap pointing at a Secret, the same way you would bind DATABASE_URL, rather
than putting the URL in spec.cache.url / spec.revalidation.kafkaBrokerUrl directly. Using this
pattern still sets the EnvMapCollision condition (it is a real collision at the env-render
level), but with the informational reason/event below — not the same alarm treatment as an
actual conflict.
EnvMapCollision reasons
kubectl describe nextapp <name> and knext status both surface this condition. The reason
tells you which case you are in; the event type tells you whether it needs attention:
| Reason | When | Event type | Meaning |
|---|---|---|---|
EnvVarIgnored | envMap names a platform-managed variable that always wins on the platform side (currently only HOSTNAME) | Warning | Your envMap entry is ignored outright; the platform's own value is what the app actually gets. Remove the entry — it has no effect. |
EnvMapUserOverride | envMap names any other reserved system variable, and this CR predates admission rejection for that collision (or reconciled while the validating webhook was briefly unavailable) | Warning | Your envMap value is used instead of the platform's own default for that name. A new or updated CR can never reach this state — admission rejects the collision going forward — so seeing it means an existing CR is grandfathered. Remove the envMap entry to fall back to the platform's default. |
EnvMapExpectedOverride | envMap names only connection-string exempt name(s) (REDIS_URL / KAFKA_BROKER_URL / OTEL_EXPORTER_OTLP_ENDPOINT) — the documented, sanctioned pattern above | Normal | Informational only — no action needed. This is what binding a Redis/Kafka/OTel credential via envMap is supposed to look like. |
A report can mix reasons — e.g. an EnvVarIgnored-class collision on HOSTNAME alongside an
exempt REDIS_URL entry. Whenever any real collision (EnvVarIgnored or
EnvMapUserOverride) is present, the condition reports that reason and a Warning event, even if
an exempt name is also present in the same report — the informational downgrade only applies when
the entire report is exempt names.
Databases: spec.database
knext is engine-agnostic — it does not provision or manage a database. spec.database is
a bring-your-own binding: it wires an existing Postgres Secret into the app as DATABASE_URL.
secretRef binds a Secret in the app's own namespace as DATABASE_URL; roSecretRef optionally
binds a read-only DSN as DATABASE_URL_RO (its key defaults to DATABASE_URL_RO, so one Secret
carrying both keys binds with the same name). It is typed sugar over the envMap path above —
same injection machinery, same semantics. knext db bind writes exactly this patch for you (see
the CLI reference).
spec:
database:
secretRef: { name: acme-db } # key defaults to DATABASE_URL
roSecretRef: { name: acme-db } # key defaults to DATABASE_URL_ROA NextApp only binds a Secret in its own namespace — it can never name or reach into another
app's database. spec.database owns DATABASE_URL/DATABASE_URL_RO: a conflicting
secrets.envMap entry for those names is rejected rather than silently overridden. Point the DSN
at a connection pooler for scale-to-zero apps, and consume the injected DSNs with a typed client —
see the Databases guide and the Data SDK.
Secrets live in Kubernetes Secrets / env only — never in spec.env, config files, images, or
URLs. spec.env values are stored verbatim in the CR and visible to anyone who can read it.
Status fields
| Field | Notes |
|---|---|
url | The resolved Knative Service URL. |
conditions | Ready, Degraded, Reconciling, DatabaseReady, EnvMapCollision, PodCreationBlocked — see below. |
currentTraffic | Mirror of the ksvc traffic split. |
The operator reports honest readiness: Ready mirrors the underlying Knative Service's own
Ready condition rather than reporting success as soon as the resource is applied — a CR whose
service never comes up shows Ready: False with the real reason (e.g. the image-pull or crash
detail). knext status renders these conditions verbatim and exits non-zero when Ready is
False — see the CLI reference.
DatabaseReady reports binding health for a bring-your-own database. When spec.database
binds a Secret, a successfully bound app shows DatabaseReady: True with reason Bound (message:
"Bound existing Secret … as DATABASE_URL"). It is a binding-health signal, not a provisioning one —
knext binds an existing Secret, it does not provision or manage the database. When spec.database
is empty the condition is not set.
Three otherwise-silent failure modes get first-class diagnoses:
- Ingress never programs. If the Knative Service reports its ingress as not-yet-configured for
more than two minutes, the operator flips
ReadytoFalsewith reasonIngressNotProgrammedand emits a Warning event naming the usual fix — theconfig-networkConfigMap'singress-classmust be the full controller-qualified value (kourier.ingress.networking.knative.dev; see Installing knext). Without this, a misconfigured ingress class looks like an app that is "still starting" forever. - A pinned revision no longer exists. If
spec.trafficpins a revision that has been deleted (e.g. garbage-collected), Knative keeps serving the last-good route with only an opaque internal condition. The operator instead performs its own existence check and — after a short stall window that rules out an in-progress deploy — reportsReady: False/Degradedwith reasonPinnedRevisionNotFound, plus a Warning event telling you to list the surviving revisions and re-pin (or clearspec.traffic). See Rollback & traffic split. - The namespace rejects the app's pods. A
LimitRange(for example amax.cpuceiling or amaxLimitRequestRatio) or aResourceQuotacan refuse to create the pod for a new revision. The Knative Service then never becomes Ready, and the reason used to be visible only inkubectl get events. The operator now setsPodCreationBlocked: Trueon the NextApp, with reasonLimitRangeRejectedorQuotaExceeded, naming the revision and carrying the admission message, plus a Warning event. Read it withkubectl get nextapp <name> -o jsonpath='{.status.conditions[?(@.type=="PodCreationBlocked")].message}'. The fix is on your side: lowerspec.resources.cpuLimit/cpuRequest/ memory to fit theLimitRange, or raise the namespace limits (kubectl describe limitrange,resourcequota -n <ns>). The condition clears by itself once pods can be created. Only these two kinds of rejection are reported; other pod failures keep surfacing throughReady.
ISR revalidation & the Kafka queue
knext's ISR / data cache is Redis (cache.provider: redis). revalidateTag() and
revalidatePath() write through the Redis-backed cache handler, and because the Redis keys are
shared across every pod, an invalidation is fleet-wide — one pod's revalidateTag is seen by
all of them. This is the default path and requires no queue.
The spec.revalidation block opts into running revalidation asynchronously over a queue, so
regeneration does not block the user's request:
spec:
revalidation:
queue: kafka
kafkaBrokerUrl: kafka-cluster-kafka-bootstrap.kafka.svc:9092Honest scope — provisionKafkaSource no longer does anything. This field used to opt into
provisioning a KafkaSource, on the understanding that you had deployed your own
<app>-revalidator consumer. That bring-your-own path is withdrawn: the sink contract — what
the consumer must be called, which events it receives, how it authenticates — was never specified
or tested, so the option could only aim events at a Service that might never exist.
No KafkaSource is provisioned for any value of the field. Your manifest still applies and your
app still deploys normally; setting the field reports a RevalidationDeferred condition with
reason ProvisionKafkaSourceInert plus a warning event, so an ignored setting is visible rather
than silent. queue: kafka on its own behaves the same way, with reason ConsumerNotProvisioned.
Cache invalidation is unaffected — revalidateTag() is already fleet-wide through the shared
Redis-backed cache. Kafka-driven revalidation returns when knext ships the consumer itself.
Security defaults
The operator is also the policy boundary:
- Digest pinning is enforced. A CR whose
imageis:latest, tag-only, or untagged is rejected (Degraded/ reasonInvalidImage) and never deployed. The validator accepts a ref only if it contains@sha256:. - Mutating endpoints require auth. App-level cache-invalidation routes require a signed bearer token; nothing mutating is open by default.
- No direct access to your Secrets. The operator's ClusterRole grants no permission on
Kubernetes Secrets at all.
envFrom/envMap/spec.databaseonly write references (Secret name and key) into your app's Pod spec; the kubelet resolves them when the pod starts. This is not a complete barrier: because the operator can create workloads cluster-wide, a compromised operator could still reach a Secret indirectly through a pod it creates. Install the operator per namespace or add an admission policy to close that path. See Security. - Default-on NetworkPolicy.
spec.security.networkPolicyreconciles an internal-only network policy for the app. - Cluster-local backends (design-stage). The planned gRPC backend layer is
cluster-localwith no public ingress — designed, not yet shipped.
Because only the operator mutates cluster state, your Git history of NextApp resources is the
deployment history — auditable, reproducible, portable across clouds.