---
title: "How to Run Ruby in a Sandbox"
description: "Build a reusable VM snapshot with the Ruby runtime, then run as many Ruby scripts as you like on one long-lived sandbox VM."
url: "/docs/guides/run-ruby-in-a-sandbox"
---

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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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.

```ts
import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

// 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.

```ts
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:

```ts
const { vm } = await freestyle.vms.create({ 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:

```ts
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.

```ts
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`.

```ts
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:

```ts
const { vm: server, vmId } = await freestyle.vms.create({
  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:

```ts
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:

```ts
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:

```ts
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";
}
```

Map a public domain to the VM's port. `style.dev` subdomains need no verification; each run gets a unique one:

```ts
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({
  domain,
  vmId,
  vmPort: 3000,
});
```

Fetch it from outside the VM:

```ts
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](https://www.freestyle.sh/docs/vms/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:

```ts
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.
```
