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 freestylebun add freestylenpm install freestyleyarn add freestyle 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 { 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.
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({ 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({
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";
}
Map a public domain to the VM’s port. style.dev subdomains need no verification; each run gets a unique one:
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:
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.