Freestyle sandboxes are microVMs with a full Linux kernel, so the Docker Engine runs inside one natively — overlayfs storage, cgroup v2, and bridge networking all work, no vfs fallback. This guide bakes Docker and the Compose plugin into a snapshot, then boots VMs that run containers and docker compose stacks instantly.
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 Docker Installed
The default base snapshot already ships Docker — Engine, compose, and buildx — running from boot, so for most work you can skip straight to Run a Container. Build your own snapshot when you need a specific Engine version or a daemon configuration of your own.
Create a VM, install Docker with the official convenience script — it pulls the Engine and the docker compose plugin — then start the daemon under systemd and wait until it answers. A Freestyle snapshot is a full memory and disk capture, so it preserves the running daemon, not just the installed files; VMs booted from this snapshot come up with Docker already active.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
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: "docker-builder" });
// get.docker.com installs the Engine + the Compose v2 plugin. It's a big install,
// so give it room.
await builder.exec({
command: "curl -fsSL https://get.docker.com | sh",
timeoutMs: 300_000,
});
// systemd is PID 1 in the VM, so let it supervise dockerd. Wait until the daemon
// answers before snapshotting.
await builder.exec("systemctl enable --now docker");
for (let i = 0; i < 15; i++) {
const r = await builder.exec("docker info >/dev/null 2>&1 && echo READY || echo WAIT");
if (r.stdout?.includes("READY")) break;
await builder.exec("sleep 2");
}
// Confirm the engine picked the native overlayfs driver (not vfs).
const info = await builder.exec("docker info --format '{{.Driver}} / cgroup {{.CgroupVersion}}'");
console.log(info.stdout?.trim()); // overlayfs / cgroup 2
const { snapshotId } = await builder.snapshot();
await builder.delete();
Run a Container
Boot a VM from the snapshot. Docker is already running, so vm.exec() can run containers immediately — no install, no systemctl start.
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: "docker-server", snapshotId, idleTimeoutSeconds: null });
const hello = await vm.exec("docker run --rm hello-world");
console.log(hello.stdout?.includes("Hello from Docker")); // true
// Any image works — this pulls alpine and runs a command in it.
const ok = await vm.exec("docker run --rm alpine echo 'container ok'");
console.log(ok.stdout?.trim()); // container ok
Run a Docker Compose Stack
Write a compose.yaml into the VM and bring it up. The Compose plugin came with the engine, so docker compose is available with no extra install. Published ports answer on the VM’s loopback.
await vm.exec("mkdir -p /root/app");
await vm.fs.writeTextFile(
"/root/app/compose.yaml",
`services:
web:
image: nginx:alpine
ports:
- "8080:80"
worker:
image: alpine
command: sh -c "while true; do echo 'worker tick'; sleep 5; done"
`,
);
await vm.exec("cd /root/app && docker compose up -d");
console.log((await vm.exec("cd /root/app && docker compose ps")).stdout);
// The web service's published port answers.
const res = await vm.exec("curl -s -o /dev/null -w '%{http_code}' localhost:8080");
console.log(res.stdout); // 200
Stream the Container Logs
vm.exec() buffers a command and only returns once it finishes, so it can’t show a container’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 stack with docker compose logs -f (or docker logs -f <container> for a single one). 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 every service in the stack; new lines stream in until you detach.
session.write("cd /root/app && docker compose logs -f\n");
// session.detach() drops your handle — the containers keep running.
Expose a Container on a Domain
A published container port behaves like any other service on the VM. The web service above publishes port 8080, so route a domain there — any unused subdomain of style.dev needs no verification:
await freestyle.tls.rules.create({
action: "allow",
domain: "my-app.style.dev", // any unused style.dev subdomain
source: { public: true },
destination: { vmId, port: 8080 },
});
A domain of your own has to be verified first; HTTPS is provisioned automatically either way.