This guide builds a reusable snapshot with the PHP CLI preinstalled, boots one VM from it, then wraps that VM in a runPhp() 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 then powers a long-running PHP web server routed to a public domain.
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 PHP
Create a VM from the minimal base image, install the PHP command-line interpreter with apt-get, then snapshot the machine so the install is baked in. Nothing is preinstalled, so install PHP explicitly — php-cli pulls in the php binary without a web server. Delete the builder VM once the snapshot is captured.
import { freestyle } from "freestyle";
const { vm: builder } = await freestyle.vms.create();
await builder.exec("apt-get update -qq");
const install = await builder.exec(
"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq php-cli",
);
console.log(install.statusCode); // 0
const version = await builder.exec("php -v");
console.log(version.stdout); // PHP 8.4.21 (cli) ...
const { snapshotId } = await builder.snapshot();
await builder.delete();
apt-installed packages persist into the snapshot, so every VM you boot from snapshotId has the php binary ready immediately — you only pay the apt-get cost once.
A Reusable runPhp() 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 php. Each call writes to /tmp/main-<uuid>.php 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. vm.exec is synchronous: it runs the script to completion and returns { stdout, stderr, statusCode }.
async function runPhp(vm, code: string) {
const file = `/tmp/main-${crypto.randomUUID()}.php`;
await vm.fs.writeTextFile(file, code);
return await vm.exec(`php ${file}`);
}
const { vm } = await freestyle.vms.create({ snapshotId });
const out = await runPhp(
vm,
`<?php
echo "php " . PHP_VERSION . "\\n";
$nums = [1, 2, 3, 4, 5];
echo "sum=" . array_sum($nums) . "\\n";
`,
);
console.log(out.statusCode); // 0
console.log(out.stdout);
// php 8.4.21
// sum=15
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:
const greet = await runPhp(vm, `<?php echo strtoupper("hello"), "\\n";`);
console.log(greet.stdout.trim()); // HELLO
Pass Arguments and Standard Input
The base runPhp 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 — runPhpStrict — 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 runPhp, it takes the vm first and reuses it — only the per-call source and stdin files are unique.
async function runPhpStrict(
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}.php`;
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
? `php ${file} ${argv} < ${stdinFile}`
: `php ${file} ${argv}`;
const r = await vm.exec(cmd);
if (r.statusCode !== 0) {
throw new Error(`runPhpStrict: php 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 arguments and read them back from $argv:
const sum = await runPhpStrict(vm, `<?php
$total = 0;
foreach (array_slice($argv, 1) as $n) { $total += (int)$n; }
echo "total: $total\\n";
`, { args: [3, 4, 5] });
console.log(sum.stdout.trim()); // total: 12
Feed input on stdin and read it with STDIN:
const piped = await runPhpStrict(vm, `<?php
$total = 0;
while (($line = fgets(STDIN)) !== false) {
foreach (preg_split('/\\s+/', trim($line)) as $tok) {
if ($tok !== "") $total += (int)$tok;
}
}
echo "sum=$total\\n";
`, { stdin: "3 4 5 6\\n" });
console.log(piped.stdout.trim()); // 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([
runPhp(vm, `<?php echo "A " . (111 * 2) . "\\n";`),
runPhp(vm, `<?php echo "B " . (222 * 2) . "\\n";`),
]);
console.log(a.stdout.trim(), "/", b.stdout.trim()); // A 222 / B 444
A program that exits non-zero now rejects the promise, so a try/catch around runPhpStrict() cleanly handles a failed run:
try {
await runPhpStrict(vm, `<?php exit(3);`);
} catch (err) {
console.log(err.message); // runPhpStrict: php exited 3: ...
}
Run a Server
The same snapshot that runs one-off scripts can host a long-running PHP web server. Boot a fresh VM from snapshotId — it already has the php binary — and keep the vmId it returns for routing. Pass idleTimeoutSeconds: null so the server VM stays up instead of pausing when idle.
const { vm: server, vmId } = await freestyle.vms.create({
snapshotId,
idleTimeoutSeconds: null,
});
Write the app to /srv/index.php, then a systemd unit. systemd is PID 1 on the VM, so a unit is the durable way to run a server: it supervises the process and restarts it if it crashes. Units do not source a shell profile, so point ExecStart at the absolute php path. The server must bind 0.0.0.0 (not 127.0.0.1) so the platform can route external traffic to it.
await server.fs.writeTextFile(
"/srv/index.php",
`<?php
header("Content-Type: text/plain");
echo "hello from php " . PHP_VERSION . "\\n";
`,
);
await server.fs.writeTextFile(
"/etc/systemd/system/php-server.service",
`[Unit]
Description=PHP built-in server
After=network.target
[Service]
ExecStart=/usr/bin/php -S 0.0.0.0:3000 -t /srv
WorkingDirectory=/srv
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
Enable and start the unit, then wait until it actually serves. Poll localhost:3000 until it returns 200 so you don’t route a domain at a server that isn’t listening yet.
await server.exec(
"systemctl daemon-reload && systemctl enable --now php-server",
);
let ready = false;
while (!ready) {
const probe = await server.exec(
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
);
ready = (probe.stdout ?? "").trim() === "200";
if (!ready) await new Promise((r) => setTimeout(r, 1000));
}
Map a domain to the VM’s port. style.dev subdomains need no DNS setup or verification, and 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);
console.log(await res.text());
// hello from php 8.4.21
The request lands on your php -S process inside the VM. Edit /srv/index.php and restart the unit with systemctl restart php-server whenever you want to ship a new version, or snapshot the running server so every VM you boot from it comes up already answering HTTP.
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 php-server -f\n");
// session.detach() drops your handle — the service keeps running in the VM.