Orva

Documentation

Orva runs JavaScript, TypeScript and Python functions inside nsjail sandboxes on hardware you own, and serves them over HTTP. This page is enough to deploy something and operate it. The exhaustive reference is in the repository.

Getting started

Docker Compose is the shortest path. It publishes the dashboard on host port 3000, which maps to 8443 inside the container.

bash
$ curl -fsSL https://raw.githubusercontent.com/Harsh-2002/Orva/main/docker-compose.yml -o docker-compose.yml
$ docker compose up -d
# http://localhost:3000

nsjail is required on Linux. The server starts without it, but every invocation fails until it is installed at /usr/local/bin/nsjail. That path is hardcoded: PATH is never consulted and there is no environment override. The Docker image ships it.

State is a single directory, /var/lib/orva: one SQLite database in WAL mode and a tree of function versions. There is no external database, no control plane and no telemetry.

Handler contract

There are two runtimes, node and python, both latest-stable only. TypeScript is a first-class path on node: it compiles at build time and runs as JavaScript.

Node.js

handler.js
exports.handler = async (event) => {
  const body = event.body ? JSON.parse(event.body) : {}

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: { hello: body.name || 'world' },
  }
}

Python

handler.py
import json


def handler(event):
    raw = event.get("body") or ""
    body = json.loads(raw) if raw else {}

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": {"hello": body.get("name", "world")},
    }

The body is always a raw string. The platform never parses it, whatever the Content-Type. Call JSON.parse or json.loads yourself, and guard the empty case. Treating it as an already-parsed object is the single most common way a first handler returns a 500.

Event and response

The event carries method, path, headers and body. Node additionally gets query, because its adapter parses it out of the path; Python handlers split event["path"] themselves.

Return { statusCode, headers, body }. A non-string body is JSON-encoded by the adapter. Environment variables and secrets arrive in process.env and os.environ.

Invoking

Every function answers at /fn/<function_id>. Friendlier paths are attached separately as custom routes, from the dashboard, the API, the set_route MCP tool or orva routes set.

bash
$ curl -X POST https://orva.example.com/fn/<function_id> \
    -H 'Content-Type: application/json' \
    -d '{"name": "Orva"}'

These prefixes are reserved and cannot be claimed by a custom route: /api/ /auth/ /fn/ /mcp/ /web/ /webhook/ /_orva/.

Function settings

Each function carries its own runtime, resource budget and network posture. The first five of these are spawn-scoped: a warm worker's environment is fixed when it spawns, so changing them takes effect on the next cold start rather than immediately.

runtime
node or python.
memory_mb / cpus
Enforced by cgroup v2 on the sandbox, not advisory.
timeout_ms
Also surfaced to the handler as getRemainingTimeInMillis() and get_remaining_time_in_millis().
network_mode
none by default: an isolated network namespace with loopback only. egress opts into outbound traffic and adds roughly 5 ms to a cold start. Without it, any outbound call fails ENETUNREACH.
auth_mode
public is the default and matches Cloudflare Workers and Vercel Functions. signed reads its key from the function secret ORVA_SIGNING_SECRET.
max_concurrency
Caps in-flight invocations per function, so a runaway handler cannot exhaust whatever it calls downstream. 0 means unlimited.

The SDK

The orva package is preinstalled in every sandbox. There is nothing to add to a manifest and no network call to fetch it. Note that anything reaching outside the sandbox still needs network_mode: egress.

KV

A per-function JSON namespace on SQLite, with optional TTL. Batches are atomic: a validation or storage failure rolls the whole batch back. The Node SDK is promise-based; the Python SDK is synchronous.

Node.js
const { kv } = require('orva')

exports.handler = async (event) => {
  // Omit ttlSeconds to keep expiry, 0 clears it.
  await kv.put('user:42', { name: 'Ada', tier: 'pro' }, { ttlSeconds: 3600 })

  const user = await kv.get('user:42', null)
  const pages = await kv.list({ prefix: 'page:', limit: 50 })
  await kv.delete('user:42')

  return { statusCode: 200, body: JSON.stringify(user) }
}
Python
from orva import kv


def handler(event):
    kv.put("user:42", {"name": "Ada", "tier": "pro"}, ttl_seconds=3600)

    user = kv.get("user:42", None)
    pages = kv.list(prefix="page:", limit=50)
    kv.delete("user:42")

    return {"statusCode": 200, "body": user}

Function to function

invoke() calls another function on the same instance, with call-depth limits so a cycle cannot spend the host. invoke_stream() streams the response through instead of buffering it.

Background jobs

jobs.enqueue() moves work off the request path. Pass an idempotency key so a retry does not run it twice.

Logs and spans

log writes structured lines that land in the invocation log, and trace opens custom spans that appear in the trace view. The import is log, not logger.

Python
from orva import trace, log


def handler(event):
    with trace.span("fetch-user"):
        log.info("looking up", user_id=42)

    return {"statusCode": 200, "body": "ok"}

Webhooks

Deliveries are signed, and each runtime ships a verification helper. The x-orva-* header namespace is server-set and stripped from inbound requests, so a caller cannot forge one.

CLI

A single static binary with no CGO, so it runs on a bare Alpine image. It reads ~/.orva/config.yaml for its endpoint and API key.

bash
# Key on stdin: the command line is visible via ps.
$ printf %s "$ORVA_KEY" | orva login --endpoint https://orva.example.com --api-key -

$ orva system health
$ orva deploy ./my-fn --name my-fn --runtime node
$ orva invoke my-fn --body '{"hello":"world"}'
$ orva logs my-fn --follow

Beyond those, the surface covers functions, deployments, rollback, diff, kv, secrets, cron, jobs, keys, traces, executions, activity, pool, routes, channels, firewall, dns, backup, chat and upgrade. Run orva docs for the full reference, which is embedded in the binary.

Server config

The server is configured by environment variables only. There is no server config file.

ORVA_PORT
Default 8443. Plain HTTP, no TLS. Put a reverse proxy in front of it for TLS.
ORVA_HOST
Default 0.0.0.0. Set 127.0.0.1 to listen on loopback only, which is the recommendation behind a proxy.
ORVA_DATA_DIR
/var/lib/orva under Docker, ~/.orva in development.
ORVA_TRUSTED_PROXY
Default false. Turn it on only when a proxy in front of Orva actually sets X-Forwarded-For, because it makes Orva trust that header.
ORVA_LOG_LEVEL
debug, info (default), warn or error.

The full table, including body limits, retention, session lifetime and the seccomp policy switch, is in CONFIG.md.

Security model

Between a handler and the host kernel there are five layers:

what is between user code and the host
host kernel
  └─ docker container "orva"
      └─ orvad (Go server)
          └─ nsjail process         unshare(CLONE_NEWUSER) drops effective caps
              └─ user namespace
                  ├─ chroot to runtime rootfs      read-only
                  ├─ /code bind-mount              read-only, function-private
                  ├─ tmpfs /tmp                    private, wiped on worker exit
                  ├─ cgroup v2                     memory + CPU + pids
                  ├─ seccomp filter                ~150 syscalls blocked
                  └─ user code (node / python)

The gaps between those layers are enforced by the Linux kernel, not by Orva code. Orva configures the boundaries and trusts the kernel to hold them.

What is explicitly out of scope

Kernel-level zero-days and side-channel attacks. No seccomp filter is bulletproof, and the threat model says so rather than implying otherwise. Orva is also single-tenant: every function on an instance shares one host and one trust boundary, so it is not built to run code from people you do not trust.

Egress

Egress policy is per sandbox and fail-closed. The blocklist compiles into that sandbox's own network configuration, which the spawn refuses to start without. No host firewall table is ever created, so nothing Orva does can outlive the process that needed it.

Credentials

The SDK credential handed to a sandbox is bound to that worker and to a single spawn. It is released when the sandbox is reaped, so a redeploy genuinely invalidates a leaked copy rather than merely appearing to. SECURITY.md sets out each credential class and exactly what revoking it reaches.

MCP

Orva exposes 73 tools over Model Context Protocol, so an agent can operate the instance the same way you do. A read-only key reaches 28 of them. Authentication is OAuth 2.1 or a static bearer token.

Claude Code
$ claude mcp add --transport http orva https://orva.example.com/mcp/ \
    --header "Authorization: Bearer $ORVA_KEY"

Config snippets for Cursor, VS Code, Codex CLI, OpenCode, Zed, Windsurf, Claude Desktop and ChatGPT are in the reference. Agent channels let you bundle a set of functions and expose them as tools in their own right.

Troubleshooting

Every invocation fails, the server is up
nsjail is missing. It must exist at /usr/local/bin/nsjail; that path is hardcoded and PATH is not consulted.
An outbound call fails with ENETUNREACH
The function is on network_mode: none, which is the default. Turn on "Allow outbound network" in its configuration.
The handler returns 500 on a valid JSON POST
event.body is a raw string and has to be parsed. It is never an object, whatever the Content-Type says.
A config change did not take effect
Runtime, memory, CPU, timeout and network mode are baked in when a worker spawns. A warm worker keeps the old environment until it is replaced.
Sandboxes will not start under Docker
On the default runc runtime the container needs --pid host, --cgroupns host and --cap-add SYS_ADMIN, plus a writable cgroup mount, to construct sandboxes at all.