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
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 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 map below. A leading-dot .style.dev entry matches any Freestyle subdomain (what this guide uses); if you map your own custom domain, list that instead — app.example.com, or .example.com for its subdomains. 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";

// nodeSnapshotId comes from the Node.js guide — a snapshot with Node 22 (via nvm) ready.
const { vm: builder } = await freestyle.vms.create({ 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 map. A leading-dot
    // ".style.dev" matches any Freestyle subdomain; use "app.example.com" (or
    // ".example.com" for its subdomains) for a custom domain, or one exact host.
    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. Pick your own unique *.style.dev subdomain; it needs no DNS or verification.

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

const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 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 mapped, 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