knext

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, reachable

Installing 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:

shell
kubectl apply --server-side -f https://github.com/getknext-dev/knext/releases/download/operator-latest/install.yaml

operator-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:

shell
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 like operator-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 of main between 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.

nextapp.yaml
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 / Reconciling

Spec fields (selected)

FieldNotes
imageRequired. Container image — must be digest-pinned (@sha256:).
scalingminScale, maxScale, containerConcurrency, plus poolMax, warmSchedule, targetBurstCapacity, panicWindowPercentage, panicThresholdPercentage, scaleDownDelay, and imagePrewarm. See Scale to zero.
resourcescpuRequest, 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.
storageprovider, bucket, region, endpoint.
cacheprovider, 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.
revalidationqueue, kafkaBrokerUrl, provisionKafkaSource — async ISR revalidation (see below).
envPlain, non-secret env vars (name → value). Reserved names are rejected at admission (see below).
secretsenvFrom, envMap (Kubernetes Secret refs) — see Binding secrets.
databaseBind a bring-your-own Postgres by referencing an existing Secret (secretRef / optional roSecretRef). knext does not provision databases. See Databases.
buildturbopack (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.
runtimebun (default) or node. Standalone shape only — for build: "vinext" the runtime ships inside the binary and this field has no effect on startup.
imagePullSecretsNames of Secrets the app's ServiceAccount uses to pull the image. See Private registries.
healthCheckPathPath Knative's readiness and liveness probes hit (default /api/health).
observabilityenabled, plus rum and tracing sub-blocks. See Observability.
previewenabled, prId, branch — marks the resource as a per-PR preview. See Preview environments.
securitynetworkPolicy — pod-level L3/L4 isolation, default-on.
trafficrevisionName, canaryPercent (rollback / canary).
buildIdNext.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, and K_CONFIGURATION are rejected when you apply the resource — they are managed by the platform, and overriding them would break request routing.
  • If an env name collides with a platform-managed variable or a secrets.envMap mapping, the env entry 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:

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

This 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:

ReasonWhenEvent typeMeaning
EnvVarIgnoredenvMap names a platform-managed variable that always wins on the platform side (currently only HOSTNAME)WarningYour envMap entry is ignored outright; the platform's own value is what the app actually gets. Remove the entry — it has no effect.
EnvMapUserOverrideenvMap names any other reserved system variable, and this CR predates admission rejection for that collision (or reconciled while the validating webhook was briefly unavailable)WarningYour 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.
EnvMapExpectedOverrideenvMap names only connection-string exempt name(s) (REDIS_URL / KAFKA_BROKER_URL / OTEL_EXPORTER_OTLP_ENDPOINT) — the documented, sanctioned pattern aboveNormalInformational 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).

nextapp.yaml (BYO database)
spec:
  database:
    secretRef: { name: acme-db }          # key defaults to DATABASE_URL
    roSecretRef: { name: acme-db }        # key defaults to DATABASE_URL_RO

A 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

FieldNotes
urlThe resolved Knative Service URL.
conditionsReady, Degraded, Reconciling, DatabaseReady, EnvMapCollision, PodCreationBlocked — see below.
currentTrafficMirror 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 Ready to False with reason IngressNotProgrammed and emits a Warning event naming the usual fix — the config-network ConfigMap's ingress-class must 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.traffic pins 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 — reports Ready: False / Degraded with reason PinnedRevisionNotFound, plus a Warning event telling you to list the surviving revisions and re-pin (or clear spec.traffic). See Rollback & traffic split.
  • The namespace rejects the app's pods. A LimitRange (for example a max.cpu ceiling or a maxLimitRequestRatio) or a ResourceQuota can refuse to create the pod for a new revision. The Knative Service then never becomes Ready, and the reason used to be visible only in kubectl get events. The operator now sets PodCreationBlocked: True on the NextApp, with reason LimitRangeRejected or QuotaExceeded, naming the revision and carrying the admission message, plus a Warning event. Read it with kubectl get nextapp <name> -o jsonpath='{.status.conditions[?(@.type=="PodCreationBlocked")].message}'. The fix is on your side: lower spec.resources.cpuLimit / cpuRequest / memory to fit the LimitRange, 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 through Ready.

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:

nextapp.yaml (revalidation)
spec:
  revalidation:
    queue: kafka
    kafkaBrokerUrl: kafka-cluster-kafka-bootstrap.kafka.svc:9092

Honest 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 image is :latest, tag-only, or untagged is rejected (Degraded / reason InvalidImage) 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.database only 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.networkPolicy reconciles an internal-only network policy for the app.
  • Cluster-local backends (design-stage). The planned gRPC backend layer is cluster-local with 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.

On this page