Back to Docs
Serverless Functions

Serverless Functions

Inline source code in Node 18 or Python 3.9. A thin HTTP shim on a hardened container, with an SSRF guard on every outbound call.

Overview

Grid's "serverless" surface is a thin serverless-style runtime on top of the standard service pipeline. Every function is a real Docker container built from a hand-rolled HTTP wrapper, hardened for untrusted code, and constrained to the public network by an SSRF guard. Functions flow through the same build / deploy / health-check pipeline as any other service — there is no separate FaaS substrate.

Reality Check: Not a True FaaS

Grid functions are not AWS Lambda. They are full Docker containers wrapped in a small HTTP shim. Concretely:

  • A function is a Service row with deploy_type='FUNCTION', function_code, function_runtime, and function_handler set.
  • The build phase emits a Dockerfile from a static template (Node 18 or Python 3.9), drops the user code into /app/, and produces a smsly/function-<id> image.
  • The container listens on port 8000. The HTTP shim parses the incoming request, runs the user code, captures the response, and returns it. There is no V8 isolate, no micro-VM, no warm pool.
  • Cold start = container startup time. The first request to a brand-new function pays a 200–2000 ms container boot cost; subsequent requests on the same container are microseconds.
  • Functions are bounded by the same Service resource fields (memory_mb, cpu_shares, min_replicas, max_replicas).
  • Triggers are HTTP only. There is no cron, no queue, no event source.

Treat the feature as "inline code with a streamlined UI", not as a competitor to dedicated FaaS runtimes. If you need bursty scale-to-zero and sub-100 ms cold starts, run a regular service with min_replicas=0 and let the autoscaler handle it.

Runtimes

RuntimeImageWrapper
node18node:18-alpine/app/smsly-function-runner.cjs invokes handler(event, context)
python3.9python:3.9-slim/app/smsly_function_runner.py invokes handler(event, context)

The wrappers are intentionally minimal: no built-in HTTP client, no third-party packages, no env var templating. Anything the function needs must be present in the code itself (or installed at build time). The platform's outbound fetch / urllib / requests / http.client calls are monkey-patched at startup to enforce the SSRF guard.

Hardening

Non-Root User

The function Dockerfile emits:

RUN addgroup -S function_user && adduser -S function_user -G function_user
USER function_user

This means the user code runs as UID 1000-ish, not as root. A code-execution vulnerability inside the function cannot mount, iptables, or write to /proc/sys. It also cannot bind to port 80 (the wrapper is hard-coded to 8000). The node18 image additionally uses node (UID 1000) as the runtime user.

SSRF Guard (Outbound Network Policy)

The function runner is sandboxed against outbound network calls to internal infrastructure. The guard runs at runtime, in the same process, by monkey-patching the standard library HTTP client. It applies to:

  • Node.jsfetch, http.request, https.request, and the global http / https modules.
  • Pythonurllib.request, http.client, and the requests library (when imported).

The blocked ranges are:

RangeReason
127.0.0.0/8Loopback (Docker socket, metadata)
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16RFC 1918 private
169.254.0.0/16Link-local / cloud metadata
100.64.0.0/10Carrier-grade NAT (RFC 6598)
0.0.0.0/8, 255.255.255.255/32Unspecified / broadcast
224.0.0.0/4Multicast
fc00::/7IPv6 unique-local (ULA)
fe80::/10IPv6 link-local
::1/128IPv6 loopback

The check runs against the resolved IP, not the hostname. A function that calls http://10-0-0-1.xip.io/ is still blocked (the resolver maps that hostname to 10.0.0.1). The check is DNS-rebinding-aware: the resolver result is captured at request time and compared against the guard, not deferred to a separate background lookup.

The guard returns HTTP 452 (a non-standard "blocked by policy" code) for any outbound request that fails the check. The platform's AuditLog is not written for these — the function simply sees a thrown error.

DNS Rebinding Mitigation

A naive SSRF guard that only checks the URL is bypassable via DNS rebinding: the attacker controls a DNS record that initially resolves to a public IP, then flips to 10.0.0.1 after the guard's check. Grid's guard is resolve-then-check (not check-then-resolve) and re-checks at connect time. The patched urllib.request and http.client call socket.getaddrinfo() first, validate every IP in the result against the blocklist, and refuse the request if any IP matches.

Container Security Directives

The generated Dockerfile includes:

  • USER function_user / node — non-root execution.
  • EXPOSE 8000 — single-port surface; no host networking.
  • HEALTHCHECK curl -fsS http://127.0.0.1:8000/health every 30s — detects wedged wrappers.
  • WORKDIR /app — read-only by default.

Limits

LimitDefaultSource
Code size256 KBMAX_FUNCTION_CODE_BYTES
Request body size1 MBMAX_FUNCTION_BODY_BYTES
Execution time30 sFUNCTION_TIMEOUT_SECONDS
Memoryinherits Service.memory_mbsame as a regular service
Concurrencyinherits min_replicas / max_replicasone request per container

A function that exceeds the execution timeout returns HTTP 504 (Gateway Timeout) and the container is recycled. A function that exceeds the body limit returns HTTP 413 (Payload Too Large) without invoking the user code.

Triggers

The only trigger is HTTP. There is no built-in cron, no queue subscriber, no event source. The endpoint URL is:

https://<service.public_domain>/fn/<function_name>

For example, a service with function_name='hello' exposes:

POST https://hello.example.com/fn/hello

The HTTP method on the request becomes the HTTP method on the wrapper. The body is JSON-decoded and passed as event.body; query string is event.queryStringParameters; headers are event.headers (with Host and Content-Length removed for size). The function's return value is JSON-serialized with status code 200 by default; the user can override by returning {statusCode, headers, body}.

If you need scheduled invocation, point an external cron (GitHub Actions, system cron, Cloudflare Workers cron) at the function URL with an empty POST body. The platform does not provide a built-in scheduler for functions.

API Reference

Function endpoints live under /api/v1/services/. Functions are created, updated, and deployed via the same endpoints as a regular service, with deploy_type='FUNCTION'.

Create a function

curl -sS -X POST http://localhost:8000/api/v1/services/ \
  -H "Authorization: Token $SMSLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "hello",
    "deploy_type": "FUNCTION",
    "function_runtime": "node18",
    "function_code": "module.exports.handler = async (event) => ({ statusCode: 200, body: "hello, " + (event.queryStringParameters?.name || "world") });",
    "public_domain": "hello.example.com"
  }'

Returns HTTP 201 with the new service record and a triggered deployment.

Update a function

curl -sS -X PATCH http://localhost:8000/api/v1/services/9c8b4b1a-7d1c-4a2b-9a55-2e8c3d4f9b21/ \
  -H "Authorization: Token $SMSLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"function_code": "module.exports.handler = async () => ({ statusCode: 200, body: "v2" });"}'

The patch auto-triggers a fresh deployment.

Synchronous invocation (test)

curl -sS -X POST http://localhost:8000/api/v1/services/9c8b4b1a-7d1c-4a2b-9a55-2e8c3d4f9b21/invoke/ \
  -H "Authorization: Token $SMSLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event": {"queryStringParameters": {"name": "alice"}}}'

Runs the function on the controller, not on the deployed container. Useful for testing, cron-style invocation, or admin operations. Throttled to 60 calls/minute per user (FunctionInvokeRateThrottle).

Full API reference

See docs/functions.md in the repository for the complete request body, response field schema, error codes, and the custom-dependency vendoring pattern.

Security

Outbound Network Policy

The SSRF guard described above is the only egress control. There is no separate firewall. Operators who want a stronger guarantee should add a network policy at the cluster level (Calico, Cilium) and refuse to run Grid on a network where the SSRF guard's blocklist is insufficient.

The blocklist is not configurable. If you need to allow 10.0.0.0/8 (e.g. internal services), the function surface is not the right tool — deploy a regular service and use a private add-on instead.

Encrypted Code at Rest

Service.function_code is stored as a regular TextField (not encrypted). The reasoning: the code is runnable, so the operator can read it. There is no secret material in the function code by policy. If the code includes secrets (which it should not), use a regular service with EnvironmentVariable and is_secret=True instead.

API Key Management

Functions inherit Service.env_vars (with the same is_secret masking and Fernet encryption). The standard precedence rules apply — see Deployments.

No Cross-Tenant Data

The function runs in its own container with no shared filesystem. It cannot read other services' volumes, addons, or backups. The platform's database connection is also inaccessible.

Troubleshooting

"Function code exceeds 256 KB"

The function_code field is capped at 256 KB. Move large assets out of the function (use a static service, or fetch them at runtime from a CDN).

"SSRF guard blocked outbound request to 10.x.x.x"

The function tried to call an internal IP. The guard is intentional and not configurable. If you need to call internal services, deploy a regular service and put it on the same Docker network as the target.

"Execution timed out after 30s"

Raise FUNCTION_TIMEOUT_SECONDS in the platform .env (max 300), or refactor the function to return early. Long-running tasks belong in a worker service, not a function.

"Function runs in invoke/ but returns 504 in production"

The deployed container is OOM-killed or CPU-throttled. Check Service.memory_mb and cpu_shares. The function's HEALTHCHECK will also have flipped to unhealthy — check the deployment's health-check phase.

"Health check returns 200 but the function returns 502"

The wrapper's /health endpoint does not invoke the user code. A 200 on /health only means the wrapper is alive. Test the function with POST /api/v1/services/{id}/invoke/ to see the actual error.

"Function works on one replica but not another"

Service.min_replicas > 1 and a stale container is serving the request. Roll the deployment, or set min_replicas=1 and let the autoscaler scale up.