Freestyle Docs

Freestyle / Guides

How to Run a Vite Dev Server in a Sandbox

Bake a Vite dev server into a VM snapshot, run it under systemd, and open it on a public domain.

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@latest
bun add freestyle@latest
npm install freestyle@latest
yarn 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.
esc