Getting started
Docker Compose is the shortest path. It publishes the dashboard on host port 3000, which maps to 8443 inside the container.
$ 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
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
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.
$ 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.
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) }
} 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.
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.
# 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:
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 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.