Build a snapshot with a Vite dev server already running, then boot a VM that serves it and route a public domain to it. The one Vite-specific step is telling the dev server to accept your domain.
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 Vite Dev Server
Vite runs on Node, so start from a snapshot with Node installed — build one by following How to Run Node.js in a Sandbox, which sets up Node 22 with nvm. Boot a builder from that snapshotId, scaffold a Vite app, and configure the dev server. Vite refuses requests whose Host header it does not recognize, so two settings matter: server.host makes it bind 0.0.0.0 (not just localhost), and server.allowedHosts has to list whatever host you serve the app on, the domain you route below — my-app.style.dev for one exact host, or a leading-dot .style.dev to match any name you take. Run it under systemd, wait until it serves, then snapshot — a Freestyle snapshot captures the running process, so VMs booted from it come up serving.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
// nodeSnapshotId comes from the Node.js guide — a snapshot with Node 22 (via nvm) ready.
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: "vite-builder", snapshotId: nodeSnapshotId });
// Scaffold a Vite app and install its dependencies. `node` sources nvm each call.
const node = "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";
await builder.exec(`${node} mkdir -p /srv && cd /srv && npm create vite@latest app -- --template vanilla`);
await builder.exec(`${node} cd /srv/app && npm install`);
Write a Vite config that binds 0.0.0.0 and allows the public host:
await builder.fs.writeTextFile(
"/srv/app/vite.config.js",
`import { defineConfig } from "vite";
export default defineConfig({
server: {
host: true,
port: 5173,
// Accept the Host you serve on — match this to whatever you route: one exact
// host like "my-app.style.dev", or a leading-dot ".style.dev" to match any
// name you take.
allowedHosts: [".style.dev"],
},
});
`,
);
Run the dev server as a systemd unit. systemd is PID 1, so it keeps the process alive; the unit sources nvm so npm is on PATH:
await builder.fs.writeTextFile(
"/etc/systemd/system/vite.service",
`[Service]
Environment=HOME=/root
WorkingDirectory=/srv/app
ExecStart=/usr/bin/bash -lc 'export NVM_DIR=/opt/nvm; . "$NVM_DIR/nvm.sh"; exec npm run dev'
Restart=always
[Install]
WantedBy=multi-user.target
`,
);
await builder.exec("systemctl daemon-reload && systemctl enable --now vite");
// Wait until Vite serves on localhost, then snapshot the running dev server.
let code = "";
while (code !== "200") {
await new Promise((r) => setTimeout(r, 2000));
code = (
await builder.exec("curl -s -o /dev/null -w '%{http_code}' http://localhost:5173/")
).stdout.trim();
}
const { snapshotId } = await builder.snapshot();
await builder.delete();
Open It on a Domain
Create a VM from the snapshot — Vite is already serving — then route a domain to port 5173. 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. Either way it has to match the allowedHosts entry you configured above.
const { vm, 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: "vite-server", snapshotId, idleTimeoutSeconds: null });
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: 5173 },
});
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
Open the printed URL in a browser and the Vite app loads, hot-reload included. Because allowedHosts covers the domain you routed, the dev server accepts the request instead of returning a host-not-allowed error.
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 vm.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 vite -f\n");
// session.detach() drops your handle — the service keeps running in the VM.