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@latestbun add freestyle@latestnpm install freestyle@latestyarn 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.