Request hardening
Rate limiting, payload-size caps, and malformed-request handling for a knext app — what the platform gives you, what it does not, and the recipe for the rest.
knext bounds how many requests hit your app, how long each may run, and — since the in-process byte cap shipped — how large a single request body may be. It does not bound request rate per client. This page is precise about which is which, because the difference decides whether you need to do anything.
Request bodies are capped at 8 MiB by default, for every route. The runtime refuses a larger
body with 413 before your handler is entered, and it counts bytes rather than trusting
Content-Length — so a chunked request that sends no length is refused too. Change it with the
KNEXT_MAX_REQUEST_BYTES environment variable; see
The request body cap.
The 8 MiB cap is not the same limit as Next.js's. Next caps Server Action payloads at 1 MB
by default, and that setting does not cover App Router route handlers. The two are deliberately
different numbers so that a 413 tells you which one fired: 1 MB is Next, 8 MiB (or whatever you
set) is knext.
What the platform already bounds
- Request body size — 8 MiB by default, on every route, enforced by the runtime before your code runs. See The request body cap.
- Concurrency per pod —
spec.scaling.containerConcurrency(default 20) is enforced by the Knative queue-proxy inside the pod, so it applies to every request that reaches your app. - Request duration —
spec.timeoutSeconds(default 300s) caps how long one request may occupy a concurrency slot. Lower it per app if you have no long-running streaming responses. - Scale ceiling —
spec.scaling.maxScale(default 10) bounds how far a flood can scale you out, which is what turns an overload into a bounded cost rather than an unbounded one. - Direct-dial isolation — the default
NetworkPolicyadmits only the queue-proxy and metrics ports, so a pod sharing your namespace cannot bypass the queue-proxy by dialling your container port. See Security → Network isolation. This is enforced only if your CNI supports NetworkPolicy (Calico, Cilium); on flannel the policy is inert.kn-next doctorand theNetworkPolicyEnforcedcondition on your app both report which case your cluster is in. - Malformed HTTP — external traffic passes three independent HTTP parsers before your code.
- Metrics-port separation — the app's Prometheus listener defaults to
:9464, deliberately off:9091, which Knative's queue-proxy binds inside every pod on a stock Serving install (defaultconfig-observability). The NetworkPolicy's scrape grants, theprometheus.io/portannotation and the shippedPodMonitorare all fixed to9464. Never setMETRICS_PORT=9091— the app races queue-proxy for the port and crash-loops withEADDRINUSE;kn-next doctordetects this.
What it does not bound
| Gap | What it means in practice |
|---|---|
| Request rate per client | Nothing limits how fast one client can call you. A flood scales you out to maxScale and stops there. |
| Slow clients in aggregate | Bounded per pod by concurrency and by timeoutSeconds, but not globally. |
The recipe
1. Put a proxy in front — for external traffic
Terminate external traffic at a proxy that enforces rate limits and payload caps before they reach the cluster: your cloud load balancer's policies, or an nginx/Envoy ingress layer.
A front proxy binds external traffic only. It does nothing about a pod inside your cluster calling your app — that is what the NetworkPolicy above is for. Three more caveats worth knowing before you rely on one:
- Rate-limit counters are per replica. Three proxy replicas with "100 req/min" allow 300 unless the proxy shares state (Redis-backed limiter or equivalent).
- Chunked bodies have no
Content-Length. Enforcing a size cap on them requires the proxy to buffer — which moves the memory pressure to the proxy rather than removing it. - Cloud LB payload policies vary. Verify your provider actually offers one; several do not.
2. Set the request body cap
knext caps request bodies in-process, so this control applies whether the traffic came through your proxy, through the Knative ingress, or from a pod inside the cluster dialling your app directly.
-
Default:
8388608bytes (8 MiB). Chosen against the memory limit andcontainerConcurrency(20 in-flight × 8 MiB stays far inside a 1Gi pod), and deliberately above Next's 1 MB Server Action limit so the two never answer at the same threshold. -
The runtime counts bytes. A body sent with chunked transfer encoding and no
Content-Lengthis refused at the same size as one that declares its length.Content-Lengthis never trusted on its own. -
The response is a bare
413with no body. It is produced by the runtime before any knext or Next.js code runs, so there is nothing to attach a message to. To confirm which cap is in force, read the line the app prints on start:REQUEST_BYTE_CAP:8388608 METRICS_BYTE_CAP:65536 (default)
Change it per app with an environment variable:
apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
env:
- name: KNEXT_MAX_REQUEST_BYTES
value: "20971520" # 20 MiBKNEXT_MAX_REQUEST_BYTES=0 removes the cap entirely. The app then buffers a body of any size
the runtime accepts, and with containerConcurrency above 1 that is an out-of-memory kill rather
than a 413. It is deliberate, it is logged loudly on every start, and it should be temporary. Any
value that is not a non-negative integer is ignored with a warning and the 8 MiB default stays in
force — a typo cannot silently remove the cap. The metrics port stays capped either way.
If you need a smaller, per-route limit — say 100 KB on one JSON endpoint — the platform cap does not replace it. Check the length as you read, rather than trusting the header:
// app/api/upload/route.ts
const MAX_BYTES = 1_000_000;
export async function POST(req: Request) {
if (!req.body) return new Response('empty body', { status: 400 });
let total = 0;
const chunks: Uint8Array[] = [];
// Count bytes as they arrive and bail early — never buffer first and check after.
for await (const chunk of req.body as unknown as AsyncIterable<Uint8Array>) {
total += chunk.byteLength;
if (total > MAX_BYTES) {
return new Response('payload too large', { status: 413 });
}
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
return Response.json({ ok: true, size: total, keys: Object.keys(body).length });
}3. Set the Server Action limit deliberately
The 1 MB default applies to Server Actions only. Set it explicitly so the value is a decision rather than an inheritance:
// next.config.ts
export default {
experimental: {
serverActions: { bodySizeLimit: '1mb' },
},
};4. Tighten the per-app knobs
apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
timeoutSeconds: 30 # no long-running streams? drop it from 300
scaling:
containerConcurrency: 20 # requests in flight per pod
maxScale: 10 # the ceiling a flood can reachWhy knext does not ship a proxy
Putting a proxy sidecar next to every app would add a container to the cold-start path, which is the one thing a scale-to-zero platform cannot spend freely. The byte cap above is the in-process alternative, and it costs one integer parse at start — nothing per request, and nothing at all on the cold-start path.
Rate limiting is the piece that is still yours. The platform bounds concurrency, duration, scale and bytes; nothing bounds how fast one client may call you, and a proxy is still the right place for that.
Security
knext's security invariants — authed mutating endpoints, network isolation, digest pinning, supply-chain signing, and runtime hardening.
Verified compatibility
knext is validated against the official Next.js deploy-mode e2e suite, per runtime × builder combination, with an honest per-cell credentialing bar.