---
title: "How to Run Docker in a Sandbox"
description: "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."
url: "/docs/guides/run-docker-in-a-sandbox"
---

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

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
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.

```ts
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`.

```ts
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.

```ts
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](https://www.freestyle.sh/docs/vms/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:

```ts
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](https://www.freestyle.sh/docs/vms/domains) to it. The `web` service above publishes port `8080`, so route the hostname there:

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

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