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.
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 /
Dockerfileand 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.
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 toindex.mjs:default). A.mjsfile is always an ES module regardless of anypackage.json— the simplest, zero-config choice. - Or keep
index.jsand add"type": "module"to yourpackage.json. That makes every.jsin the project an ES module, soindex.js:default(the built-in default handler) then loads correctly.
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 tonpm install --omit=dev. Either way devDependencies are skipped — put anything the handler needs at runtime independencies. - Ship source, not
node_modules— the build installs a clean tree inside the sandbox. Leaving a stalenode_modulesin your.ziponly 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 |
|---|---|
| nodejs20 | Node.js 20 LTS. The default if you don’t choose one. |
| nodejs22 | Node.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_seconds | 300 | Scale 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 0–3600. |
| function_timeout_seconds | 30 | Per-request wall-clock cap, enforced at the edge — a single invocation that runs longer is cut off. Range 1–300. |
| function_max_concurrency | 40 | Concurrent requests one instance handles (enforced per instance) before Ngris scales up another. Range 1–200. |
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_concurrencyin flight, Ngris scales up another instance. - Idle (scale to zero). After
function_idle_secondswith 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
.zipor push to a connected git repo, then move throughqueued→building→image_ready→rolling_out→ready, exactly like a backend deploy. - Build logs tail live under the deploy’s Log — watch
npm ciand 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
- Create the app. Open Applications → Create and pick type Serverless function.
- Pick a runtime & handler. Choose a runtime (
nodejs20ornodejs22) and, if your entry point isn’tindex.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. - Set env vars & secrets. Add config and tokens under Environment, marking sensitive values as Secret.
- Upload or connect. Drop a
.zipwith your handler at the root (a genuine ES module —index.mjs, orindex.jswith"type":"module"), plus apackage.jsonif you have dependencies, or connect a Git repo so every push redeploys. - Watch the build. Open the deploy’s Log — it tails live — and wait for the deploy to reach
ready. - 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.
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.