Runtime

How to run Lua code in a sandbox

Install Lua 5.4 or LuaJIT in a Linux microVM, write the script there, and run it with lua5.4 under a time limit and with the network off.

On Runtime Lua runs in a microVM of its own, so there is no need to strip the standard library to make it safe. Each sandbox is a Firecracker microVM running Ubuntu 24.04.5, where sudo apt-get install -y lua5.4 gives you the interpreter in one command. A new sandbox took 351 ms from the create request to its first Python result at the median, measured on 24 September 2026 (speed).

In-process sandboxing versus a microVM

Lua is often embedded, and the usual way to run a stranger's Lua is to load it with a restricted environment that leaves out os, io and require. That guards the functions you remembered to remove. It does not bound CPU time or memory by itself, and the code still runs inside your process.

Concern Restricted _ENV in your process A Runtime sandbox
os.execute, io.open Removed by hand, one by one Allowed; they reach only a throwaway machine
Endless loop Needs a debug hook or a watchdog timeoutMs ends the command
Memory Needs a custom allocator The sandbox's memoryMiB, enforced on the host
Network Whatever your process can reach Off with internet: false; private addresses refused
A bug in the interpreter Runs with your process's rights Contained by the microVM's own kernel

If the Lua must call back into your application, keep an embedded interpreter for that part. For scripts that only compute, a sandbox is the simpler boundary (security).

Lua packages on Ubuntu 24.04

Lua is not in the default image. From packages.ubuntu.com, 25 September 2026:

Package Version on noble Command
lua5.4 5.4.6 lua5.4, luac5.4
luajit 2.1.0 (git 20231223) luajit
lua5.1 5.1.5 lua5.1
luarocks 3.8.0 luarocks

Run a script

Check the syntax first with luac5.4 -p, which loads the file without running it (luac manual), then run it with its input passed as arguments.

TypeScriptimport { Sandbox } from "withruntime";const script = `local n = tonumber(arg[1])local squares = {}for i = 1, n do squares[#squares + 1] = i * i endprint(table.concat(squares, ","))`;await using sbx = await Sandbox.create({ timeoutSeconds: 600, onLeaseEnd: "stop" });await sbx.exec("sudo apt-get update -q && sudo apt-get install -y -q lua5.4", {  check: true,  timeoutMs: 300_000,});await sbx.network.set({ internet: false });await sbx.files.write("/workspace/squares.lua", script);const syntax = await sbx.exec(["luac5.4", "-p", "squares.lua"]);if (syntax.exitCode !== 0) throw new Error(syntax.stderr);const run = await sbx.exec(["lua5.4", "squares.lua", "8"], { timeoutMs: 10_000 });console.log(run.exitCode, run.timedOut, run.stdout); // 0 false 1,4,9,16,25,36,49,64
Pythonfrom withruntime import Sandboxscript = """local n = tonumber(arg[1])local squares = {}for i = 1, n do squares[#squares + 1] = i * i endprint(table.concat(squares, ","))"""with Sandbox.create(timeout_seconds=600, on_lease_end="stop") as sbx:    sbx.exec("sudo apt-get update -q && sudo apt-get install -y -q lua5.4", check=True, timeout_ms=300_000)    sbx.network.set(internet=False)    sbx.files.write("/workspace/squares.lua", script)    syntax = sbx.exec(["luac5.4", "-p", "squares.lua"])    if syntax.exit_code != 0:        raise SystemExit(syntax.stderr)    run = sbx.exec(["lua5.4", "squares.lua", "8"], timeout_ms=10_000)    print(run.exit_code, run.timed_out, run.stdout)

Arguments arrive in Lua's arg table. Because the array form runs lua5.4 with no shell in between, an argument such as 8; rm -rf ~ stays one string (run commands).

Many scripts, one sandbox or many?

Scripts from the same user can share a sandbox: write each to its own file and run them in turn. Scripts from different users should not share one, because a script can leave files behind for the next. Start one sandbox per user or per job, stop it after, and let onLeaseEnd: "stop" clean up one you forget.

TypeScriptimport { Sandbox } from "withruntime";const jobs = ["print(1 + 1)", "print(('lua'):upper())", "while true do end"];await using sbx = await Sandbox.create({ image: "lua", network: { internet: false } });for (const [i, code] of jobs.entries()) {  await sbx.files.write(`/workspace/job${i}.lua`, code);  const run = await sbx.exec(["lua", `job${i}.lua`], { timeoutMs: 5_000 });  console.log(i, run.timedOut ? "timed out" : run.stdout.trim());}

This uses lua, the name the image built below links to Lua 5.4; the third job is stopped after five seconds and the loop carries on.

LuaRocks, then no network

luarocks install fetches rocks from the web and may compile C code with the image's gcc. Install with the network on, then cut it:

Pythonfrom withruntime import Sandboxwith Sandbox.create(image="lua", timeout_seconds=900, on_lease_end="stop") as sbx:    sbx.exec("sudo luarocks --lua-version=5.4 install dkjson", check=True, timeout_ms=300_000)    sbx.network.set(internet=False)    run = sbx.exec(["lua", "-e", 'print(require("dkjson").encode({ok = true}))'])    print(run.stdout)

Start every sandbox with Lua

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();await runtime.images.build({  name: "lua",  recipe: {    apt: ["lua5.4", "liblua5.4-dev", "luarocks", "luajit"],    commands: ["ln -sf /usr/bin/lua5.4 /usr/local/bin/lua"],  },});await using sbx = await runtime.sandboxes.create({ image: "lua", network: { internet: false } });console.log((await sbx.exec(["lua", "-v"])).stdout);

Building an image is free; a stored image is charged on its size, and the free trial stores your first three free (custom images). A sandbox waiting for its next script costs $0.03125 an hour at 2 vCPU and 4 GiB, because Runtime bills the CPU in use (pricing). New accounts get 50 free sandbox hours, no card.

See also running untrusted LLM code, turning off a sandbox's internet and how to run Bash scripts in a sandbox.

Sources

Checked 25 September 2026.

Facts on this page were checked on 25 September 2026.