Freestyle Docs

Freestyle / Guides

How to Run Ruby in a Sandbox

Bake Ruby into a VM snapshot, then run Ruby scripts on one sandbox VM.

Build a snapshot with the Ruby runtime baked in, create one VM from it, then wrap it in a small runRuby() helper that runs as many scripts as you like on that single sandbox.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Build a Snapshot with the Ruby Runtime

The base image is minimal, so install Ruby explicitly. Create a VM, install the ruby package with apt-get, then snapshot the machine so the install is baked in. The exec shell is a non-login sh with no HOME, so set DEBIAN_FRONTEND=noninteractive to keep apt-get from prompting. Once the runtime is in place, snapshot the VM and delete the builder.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

const { vm: builder } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "ruby-builder" });

// Install Ruby from apt.
const install = await builder.exec(
  "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ruby",
);
console.log(install.statusCode); // 0

// Confirm the runtime is present.
const version = await builder.exec("/usr/bin/ruby --version");
console.log(version.stdout?.trim()); // ruby 3.3.8 (2025-04-09 revision b200bad6cd) [x86_64-linux-gnu]

// Bake it into a reusable snapshot, then drop the builder.
const { snapshotId } = await builder.snapshot();
await builder.delete();

Every VM you create from snapshotId boots with Ruby ready, so you only pay the apt-get cost once.

A Reusable runRuby() Utility

The VM is your reusable sandbox: create it once, then run as many snippets as you like on it. Wrap that in a helper that takes the vm as its first argument, writes the script with vm.fs.writeTextFile, and runs it synchronously with vm.exec. The exec shell is non-login with no HOME and a bare PATH, so call the interpreter by absolute path (/usr/bin/ruby) and export HOME=/root so gems that expect it behave. vm.exec blocks until the program exits and hands you { stdout, stderr, statusCode }, so a single call captures the whole run.

const ruby = "export HOME=/root && /usr/bin/ruby";

async function runRuby(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.rb`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(`${ruby} ${file}`);
}

The helper never creates or deletes a VM — it just writes and runs. Each call writes to a unique /tmp/main-<uuid>.rb path, so running many snippets on the same VM, sequentially or concurrently, can never overwrite each other’s script.

Create one VM and reuse it across every call:

const { vm } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "ruby-sandbox", snapshotId });

const sumResult = await runRuby(
  vm,
  `nums = [1, 2, 3, 4, 5]
puts "ruby #{RUBY_VERSION}"
puts "sum=#{nums.sum}"`,
);

console.log(sumResult.statusCode); // 0
console.log(sumResult.stdout); // ruby 3.3.8\nsum=15

// Same VM, another snippet — no new machine needed.
const upperResult = await runRuby(vm, `puts "hello".upcase`);

console.log(upperResult.stdout.trim()); // HELLO

Because the helper writes a unique file per call, you can also fan out concurrently on the one VM and each run stays isolated:

const [pow, rev, fact] = await Promise.all([
  runRuby(vm, `puts (2 ** 10)`),
  runRuby(vm, `puts "abc".reverse`),
  runRuby(vm, `puts((1..5).reduce(:*))`),
]);

console.log(pow.stdout.trim(), rev.stdout.trim(), fact.stdout.trim()); // 1024 cba 120

Pass Arguments and Throw on Failure

A raw { stdout, stderr, statusCode } is easy to misuse — it is simple to forget to check statusCode. Make the helper safe by default: take the same reused vm, accept an args array forwarded to Ruby’s ARGV, throw when the script exits non-zero, and return just the captured streams. Like runRuby, it never touches the VM lifecycle.

async function runRubyStrict(vm, code: string, args: string[] = []) {
  const file = `/tmp/main-${crypto.randomUUID()}.rb`;
  await vm.fs.writeTextFile(file, code);
  const argv = args
    .map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
    .join(" ");
  const result = await vm.exec(`${ruby} ${file} ${argv}`);
  if (result.statusCode !== 0) {
    throw new Error(
      `ruby exited with status ${result.statusCode}: ${result.stderr ?? ""}`,
    );
  }
  return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}

Pass the same vm you already created. The args are single-quoted before they reach the shell, so they arrive intact in ARGV.

const { stdout } = await runRubyStrict(
  vm,
  `a, b = ARGV.map(&:to_i)
puts "product=#{a * b}"`,
  ["6", "7"],
);
console.log(stdout); // product=42

// A failing script rejects instead of returning a bad result.
await runRubyStrict(vm, `exit 3`); // throws: ruby exited with status 3

Run a Server

The same snapshot also runs a long-lived HTTP server. Ruby ships WEBrick, so there is nothing extra to install — create a fresh VM from snapshotId, write the server plus a systemd unit, and route a public domain to it. Use a distinct variable (server) so it never clashes with the run-code vm, and pass idleTimeoutSeconds: null so the VM stays up to serve traffic:

const { vm: server, vmId } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: "ruby-server",
  snapshotId,
  idleTimeoutSeconds: null,
});

Write the server. WEBrick must bind 0.0.0.0 (not 127.0.0.1) so the VM accepts connections routed from outside:

await server.fs.writeTextFile(
  "/srv/server.rb",
  `require "webrick"

server = WEBrick::HTTPServer.new(BindAddress: "0.0.0.0", Port: 3000)
server.mount_proc "/" do |_req, res|
  res.content_type = "text/plain"
  res.body = "Hello from Ruby WEBrick on Freestyle!\\n"
end
trap("INT") { server.shutdown }
trap("TERM") { server.shutdown }
server.start
`,
);

Write a systemd unit. systemd is PID 1 on the VM, and units do not source a shell profile, so use the absolute interpreter path /usr/bin/ruby and set HOME with Environment=. Restart=always keeps it up, and WantedBy=multi-user.target makes enable persist the service:

await server.fs.writeTextFile(
  "/etc/systemd/system/ruby-server.service",
  `[Unit]
Description=Ruby WEBrick server
After=network.target

[Service]
ExecStart=/usr/bin/ruby /srv/server.rb
WorkingDirectory=/srv
Environment=HOME=/root
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

Reload systemd, enable and start the unit, then wait until it actually serves HTTP 200 on localhost before routing traffic to it:

await server.exec(
  "systemctl daemon-reload && systemctl enable --now ruby-server",
);

let ready = false;
while (!ready) {
  await new Promise((r) => setTimeout(r, 1000));
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
  );
  ready = probe.stdout?.trim() === "200";
}

Route a public domain to the VM’s port. Any unused subdomain of style.dev is yours to take, with no verification and no DNS records; to use a name you own, verify it and point its DNS at Freestyle first:

const domain = "my-app.style.dev"; // any unused style.dev subdomain
await freestyle.tls.rules.create({
  action: "allow",
  domain,
  source: { public: true },
  destination: { vmId, port: 3000 },
});

Fetch it from outside the VM:

const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from Ruby WEBrick on Freestyle!

You can snapshot server at any point to bake the running server into a reusable image — every VM created from that snapshot boots with the server already listening on port 3000.

Stream the Server’s Logs

vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:

const session = await server.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u ruby-server -f\n");

// session.detach() drops your handle — the service keeps running in the VM.
esc