Freestyle Docs

Freestyle / Guides

How to Run Docker in a Sandbox

Bake the Docker Engine and Compose plugin into a VM snapshot, then boot sandboxes that run containers and multi-service docker compose stacks — with native overlayfs storage, cgroup v2, and published ports — and stream container logs live.

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
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 Docker Installed

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 { vm: builder } = await freestyle.vms.create();

// 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: 600_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({ 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, so map a domain to it. The web service above publishes port 8080, so route the hostname there:

await freestyle.domains.mappings.create({
  domain: "app.example.com",
  vmId,
  vmPort: 8080,
});

The domain must be verified first, and HTTPS is provisioned automatically.

esc