Serverless Functions

Serverless functions

A serverless function is a handler you write that Ngris wraps in a base runtime image and runs scale-to-zero. It sits idle at zero instances, cold-starts on the first request, then scales back to zero after an idle window — so you pay only when it runs. There is no server to keep alive, no port to bind, and no Dockerfile to write: you ship a function, Ngris runs it.

A function is a fifth application type (function), alongside static, managed, fullstack, and wordpress. Like a managed backend it carries a runtime and is served in-cluster through a managed endpoint — the difference is that it runs at min_replicas=0 and the edge wakes it on demand.

Same isolation as a managed backend. Your function runs in a hardened, gVisor-sandboxed pod: non-root, read-only root filesystem, its own per-app namespace + NetworkPolicy, from a signed, vulnerability-scanned, digest-pinned image. Scale-to-zero doesn't relax any of that — a cold-started instance is exactly as isolated as an always-on one.

Serverless functions require the functions_enabled entitlement — the on/off master gate, seeded paid-only because a function runs a container of your code. Without it, create returns 403 Serverless functions are not available on your plan. Your plan also sets a per-account max_functions quota (how many function apps you can hold at once); reaching it returns 403 Function limit reached.

Function or managed backend?

Both run your code in the same isolated sandbox; the difference is how they run and what you write.

  • Reach for a function for event and HTTP handlers and spiky or low-traffic workloads — a webhook receiver, a small API, a cron-style task hit occasionally — where paying only when it runs matters and a little cold-start latency on the first request after idle is acceptable. You write a handler; Ngris supplies the server.
  • Reach for a managed backend for an always-on service, long-lived connections (WebSockets, SSE, streaming), steady traffic, or anything where you want a full container / Dockerfile and control over the port and process. It stays up and answers every request with no cold start.

You are not locked in: a function and a backend are both just application types, so you can start with a function and move to a managed backend (or a full-stack app) later if the workload grows into an always-on service.

The handler contract

Your handler is an ES module whose default export is a Web Fetch-style handler — it receives a standard Request and returns a Response (or a Promise of one). If you have written a Cloudflare Worker, a Deno handler, or a Next.js route, this is the same shape. The runtime wrapper loads it with a dynamic import(), so your entry file must be a genuine ES module — see the ESM note below.

// index.mjs
export default async function (request) {
  const url = new URL(request.url);
  return new Response(JSON.stringify({ hello: "world", path: url.pathname }), {
    headers: { "content-type": "application/json" },
  });
}

By default Ngris loads the default export of index.js at the root of your source. Point somewhere else with the handler setting (function_handler), written as <file>:<export> — e.g. api/webhook.mjs:handler for the handler export of api/webhook.mjs. The default is index.js:default.

No port, no health path to configure. The Ngris runtime wrapper serves your handler over HTTP on port 8080 and answers a platform health endpoint at /_ngris/health for you. Unlike a managed backend, you never set a listen port or a health-check path — the wrapper owns both. Just write the handler.

ESM is required (name it .mjs, or set type: module)

The wrapper loads your handler with an ESM dynamic import(), so the entry file must be a real ES module — a plain index.js with export default is treated as CommonJS and the build fails to load it. Node decides module vs. script from the file extension and the nearest package.json, so pick one of:

  • Name the file index.mjs (and set the handler to index.mjs:default). A .mjs file is always an ES module regardless of any package.json — the simplest, zero-config choice.
  • Or keep index.js and add "type": "module" to your package.json. That makes every .js in the project an ES module, so index.js:default (the built-in default handler) then loads correctly.
Symptom of getting this wrong: the build’s Log shows the wrapper exit at load with something like SyntaxError: Unexpected token 'export' or failed to load handler index.js:default. Fix it by renaming to index.mjs (and updating function_handler) or adding "type": "module".

npm dependencies

Functions use npm packages the normal Node way. Include a package.json at your source root (a package-lock.json is optional but recommended); the build installs your production dependencies and bundles the resulting node_modules into the function image, so anything you import is present at runtime.

  • With a lockfile the build runs npm ci --omit=dev (reproducible); without one it falls back to npm install --omit=dev. Either way devDependencies are skipped — put anything the handler needs at runtime in dependencies.
  • Ship source, not node_modules — the build installs a clean tree inside the sandbox. Leaving a stale node_modules in your .zip only makes it larger.

For example, importing a small dependency just works — import { nanoid } from "nanoid"; in your handler, with nanoid listed under dependencies, is installed at build time and available on every request.

{
  "name": "my-fn",
  "type": "module",
  "dependencies": { "nanoid": "^5.0.0" }
}

Runtimes

Pick the base runtime your handler wraps into. The supported runtimes today:

Runtime What it is
nodejs20Node.js 20 LTS. The default if you don’t choose one.
nodejs22Node.js 22 LTS.

Set the runtime as function_runtime at create time. An unsupported value returns 400 unsupported function_runtime (supported: nodejs20, nodejs22).

Config knobs

Every knob is optional — the defaults give sane cold-start behaviour, so a bare create just works. Tune them at create time (or later on the app’s Runtime panel).

Setting Default What it does
function_idle_seconds300Scale to zero after this many idle seconds with no traffic. Set 0 to never scale down (stays warm — no cold starts, but you keep an instance up). Range 03600.
function_timeout_seconds30Per-request wall-clock cap, enforced at the edge — a single invocation that runs longer is cut off. Range 1300.
function_max_concurrency40Concurrent requests one instance handles (enforced per instance) before Ngris scales up another. Range 1200.

How requests work

A function’s whole life cycle is driven by traffic:

  • First request (cold start). With no instance running, the edge cold-starts one (0 → 1) and holds the request until the instance is ready, then proxies it through. That first request pays the cold-start cost — typically ~10–15 seconds — while the client waits for a single slower response; nothing errors.
  • Subsequent requests (warm). While the instance is up, requests reuse it directly — no cold start, so responses are back to sub-second (your handler’s own latency). Beyond function_max_concurrency in flight, Ngris scales up another instance.
  • Idle (scale to zero). After function_idle_seconds with no traffic, Ngris scales the function back to zero. Nothing runs — and nothing is billed — until the next request wakes it again.

This is the trade you are opting into: pay-only-when-it-runs in exchange for a cold start on the first request after idle. If cold starts hurt your workload, set function_idle_seconds=0 to keep an instance warm, or use an always-on managed backend instead.

Environment variables, deploys, logs & previews

Everything you already know from the other app types works the same way for a function — there is nothing function-specific to learn here.

  • Environment variables & secrets are set under the app’s Environment tab (or the env API) and injected into the running instance under their exact names; mark sensitive values as Secret (encrypted at rest, write-only, decrypted only inside the pod). Read them the normal Node way — process.env.MY_TOKEN. See Environment variables & secrets.
  • Deploys upload a .zip or push to a connected git repo, then move through queuedbuildingimage_readyrolling_outready, exactly like a backend deploy.
  • Build logs tail live under the deploy’s Log — watch npm ci and the handler-wrap run, and see exactly why a build broke.
  • Preview URLs per deploy work the same as other apps — enable a preview to test the exact build at <deploy-uuid>.<plan-domain> before you activate it. See Preview URLs.

Deploy a function

Dashboard walkthrough

  1. Create the app. Open Applications → Create and pick type Serverless function.
  2. Pick a runtime & handler. Choose a runtime (nodejs20 or nodejs22) and, if your entry point isn’t index.js:default, set the handler (e.g. index.mjs:default). Tune idle, request timeout, and concurrency if the defaults don’t fit — or leave them.
  3. Set env vars & secrets. Add config and tokens under Environment, marking sensitive values as Secret.
  4. Upload or connect. Drop a .zip with your handler at the root (a genuine ES moduleindex.mjs, or index.js with "type":"module"), plus a package.json if you have dependencies, or connect a Git repo so every push redeploys.
  5. Watch the build. Open the deploy’s Log — it tails live — and wait for the deploy to reach ready.
  6. It’s live. Ngris creates a managed-serving endpoint automatically (same flow as a backend app). Hit its URL — the first request cold-starts your function — and add a custom domain whenever you like; TLS is provisioned for you.

API — create a function

curl -X POST "https://api.ngris.com/v1/applications" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-fn",
    "type": "function",
    "source": "upload",
    "function_runtime": "nodejs20",
    "function_handler": "index.mjs:default",
    "function_idle_seconds": 300,
    "function_timeout_seconds": 30,
    "function_max_concurrency": 40
  }'

Only name and type are required — every function_* field is optional and defaults as above. Use "source":"git" with a repo_url to connect a repo instead of uploading.

API — upload the handler & deploy

curl -X POST "https://api.ngris.com/v1/applications/{uuid}/deploys" \
  -H "X-API-KEY: <your_api_key>" \
  -F "file=@my-fn-src.zip" \
  -F "version=v1.0.0"   # optional label

The build runs asynchronously — it wraps your handler in the base runtime image, installs your dependencies, and produces the signed, digest-pinned image. Poll GET /v1/applications/{uuid}/deploys until the newest deploy reaches ready, or stream the build log with GET /v1/applications/{uuid}/deploys/{deploy_uuid}/logs. Then attach a domain exactly like any other app.

Build failing, or the function not answering? The Log for a failed deploy shows exactly where it stopped — a non-ESM entry file (SyntaxError: Unexpected token 'export' — rename to index.mjs or set "type":"module"), a missing dependency, a handler path that doesn’t resolve (check function_handler points at a real <file>:<export>), or a handler that throws on start. Walk the fixes in Deploy troubleshooting.
Iris