Freestyle Docs

Freestyle / Guides

How to Run Java in a Sandbox

Bake the JDK into a VM snapshot, boot one VM, and run many Java programs on it with an isolated, reusable runJava() helper — then serve a Java HTTP server from the same snapshot over HTTPS.

This guide builds a reusable snapshot with the JDK preinstalled, boots one VM from it, then wraps that VM in a runJava() helper you can call as many times as you like — each call writes to a uniquely-named file, so runs never clash, sequentially or concurrently. The same snapshot also powers a long-running HTTP server you can reach over HTTPS.

Install the SDK

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Build a Snapshot with Java

Create a VM from the minimal base image and install Amazon Corretto — a free, production-ready OpenJDK build — from its apt repository, then snapshot the machine so the install is baked in. Add Corretto’s signing key and repo, then install the JDK. The minimal image also lacks /usr/share/man/man1, which the JDK post-install configures into — create it first so the install finishes cleanly. Delete the builder VM once the snapshot is captured.

import { freestyle } from "freestyle";

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

// Add the Amazon Corretto apt repository (needs gnupg for the signing key).
await builder.exec(
  "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gnupg ca-certificates",
);
await builder.exec(
  "curl -fsSL https://apt.corretto.aws/corretto.key | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg && " +
    "echo 'deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main' > /etc/apt/sources.list.d/corretto.list",
);

// Install Corretto 21 (the man dir must exist for the JDK post-install step).
await builder.exec("mkdir -p /usr/share/man/man1");
const install = await builder.exec(
  "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq java-21-amazon-corretto-jdk",
);
console.log(install.statusCode); // 0

const version = await builder.exec("java -version 2>&1");
console.log(version.stdout); // OpenJDK Runtime Environment Corretto-21.0.11.10.1 ...

const { snapshotId } = await builder.snapshot();
await builder.delete();

A Reusable runJava() Utility

The VM is the reusable sandbox: create it once, then run as many snippets as you like on it. The helper takes the vm as its first argument, writes your source to a uniquely-named file, and runs it with the JDK 21 single-file launcher (the filename need not match the public class). Each call writes to /tmp/main-<uuid>.java via crypto.randomUUID(), so no two runs — sequential or concurrent — can clobber each other’s source. The helper never creates or deletes a VM; lifecycle stays with the caller.

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

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

const result = await runJava(vm, `public class Main {
  public static void main(String[] args) {
    System.out.println("Hello from Java " + System.getProperty("java.version"));
  }
}
`);

console.log(result.statusCode); // 0
console.log(result.stdout); // Hello from Java 21.0.11

Each exec returns { stdout, stderr, statusCode }, where a statusCode of 0 means the program exited successfully. Reuse the same vm for the next snippet — there is no need to boot a new machine.

Pass Arguments and Standard Input

The base runJava is great for quick snippets, but it hands back a raw result and never checks statusCode, so a failed program looks the same as a successful one. Add a second, stricter helper — runJavaStrict — that also accepts command-line args and stdin, returns a typed result, and throws when the program exits non-zero so failures surface as exceptions instead of silent bad output. It is a distinct function with its own name, so both helpers can live side by side in the same script. Like runJava, it takes the vm first and reuses it — only the per-call source and stdin files are unique.

async function runJavaStrict(
  vm,
  code: string,
  { args = [], stdin = "" }: { args?: (string | number)[]; stdin?: string } = {},
): Promise<{ stdout: string; stderr: string; statusCode: number }> {
  const id = crypto.randomUUID();
  const file = `/tmp/main-${id}.java`;
  const stdinFile = `/tmp/stdin-${id}`;
  await vm.fs.writeTextFile(file, code);
  if (stdin) await vm.fs.writeTextFile(stdinFile, stdin);

  const argv = args
    .map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
    .join(" ");
  const cmd = stdin
    ? `java ${file} ${argv} < ${stdinFile}`
    : `java ${file} ${argv}`;

  const r = await vm.exec(cmd);
  if (r.statusCode !== 0) {
    throw new Error(`runJavaStrict: java exited ${r.statusCode}: ${r.stderr ?? r.stdout}`);
  }
  return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}

Run several snippets on the same vm. Pass an argument to compute a factorial:

const fact = await runJavaStrict(vm, `public class Main {
  public static void main(String[] args) {
    long n = Long.parseLong(args[0]), f = 1;
    for (long i = 2; i <= n; i++) f *= i;
    System.out.println(n + "! = " + f);
  }
}
`, { args: [10] });

console.log(fact.stdout); // 10! = 3628800

Feed input on stdin and read it with a Scanner:

const sum = await runJavaStrict(vm, `import java.util.Scanner;
public class Main {
  public static void main(String[] args) {
    Scanner sc = new Scanner(System.in);
    int total = 0;
    while (sc.hasNextInt()) total += sc.nextInt();
    System.out.println("sum=" + total);
  }
}
`, { stdin: "3 4 5 6\n" });

console.log(sum.stdout); // sum=18

Because every run uses a unique filename, you can even fire several at once on the one VM — they stay fully isolated:

const [a, b] = await Promise.all([
  runJavaStrict(vm, `public class Main {
    public static void main(String[] x) { System.out.println("run-a"); }
  }`),
  runJavaStrict(vm, `public class Main {
    public static void main(String[] x) { System.out.println("run-b"); }
  }`),
]);

console.log(a.stdout, b.stdout); // run-a  run-b

A program that throws now rejects the promise, so a try/catch around runJavaStrict() cleanly handles a failed run.

Run a Server

The same snapshot that runs one-off snippets can host a long-running HTTP server. Create a fresh VM from snapshotId — give it a distinct name like server so it lives alongside the run-code vm — and pass idleTimeoutSeconds: null so it never idles out from under the service.

const { vm: server, vmId } = await freestyle.vms.create({
  snapshotId,
  idleTimeoutSeconds: null,
});

Write the server source to /srv. This uses the built-in com.sun.net.httpserver.HttpServer, so there are no dependencies to fetch. It binds to 0.0.0.0 (not 127.0.0.1) so traffic from outside the VM can reach it.

await server.fs.writeTextFile(
  "/srv/Server.java",
  `import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;

public class Server {
  public static void main(String[] args) throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 3000), 0);
    server.createContext("/", exchange -> {
      byte[] body = "Hello from a Java server in a sandbox!\\n".getBytes("UTF-8");
      exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
      exchange.sendResponseHeaders(200, body.length);
      try (OutputStream os = exchange.getResponseBody()) {
        os.write(body);
      }
    });
    server.setExecutor(null);
    server.start();
  }
}
`,
);

Write a systemd unit so the server starts on boot and restarts if it crashes. systemd units do not source a shell profile, so ExecStart uses the absolute java path (/usr/bin/java) and launches the source file directly with the JDK single-file launcher — no separate compile step.

await server.fs.writeTextFile(
  "/etc/systemd/system/java-server.service",
  `[Unit]
Description=Java HTTP server
After=network.target

[Service]
ExecStart=/usr/bin/java /srv/Server.java
WorkingDirectory=/srv
Restart=always

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

Enable and start the service, then poll until it answers 200 on localhost so you know it is live before routing traffic to it.

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

let ready = false;
for (let i = 0; i < 60; i++) {
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
  );
  if (probe.stdout.trim() === "200") {
    ready = true;
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("server never became ready");

Map a domain to the VM’s port with freestyle.domains.mappings.create — pick your own unique style.dev subdomain in place of my-app, since style.dev domains need no verification and get HTTPS automatically.

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 a Java server in a sandbox!

One snapshot now serves two purposes: a sandbox for running arbitrary Java snippets and a base image for a public HTTPS service — boot a VM from it any time and the JDK is ready with no install cost.

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 java-server -f\n");

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