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